瀏覽代碼

Merge pull request #4359 from Laserlicht/statistic

Writing some statistics and make it possible to export via csv
Ivan Savenko 1 年之前
父節點
當前提交
7d30b660ea

+ 2 - 31
client/CServerHandler.cpp

@@ -35,6 +35,7 @@
 #include "../lib/TurnTimerInfo.h"
 #include "../lib/VCMIDirs.h"
 #include "../lib/campaign/CampaignState.h"
+#include "../lib/gameState/HighScore.h"
 #include "../lib/CPlayerState.h"
 #include "../lib/mapping/CMapInfo.h"
 #include "../lib/mapObjects/CGTownInstance.h"
@@ -672,39 +673,9 @@ void CServerHandler::startGameplay(VCMI_LIB_WRAP_NAMESPACE(CGameState) * gameSta
 	setState(EClientState::GAMEPLAY);
 }
 
-HighScoreParameter CServerHandler::prepareHighScores(PlayerColor player, bool victory)
-{
-	const auto * gs = client->gameState();
-	const auto * playerState = gs->getPlayerState(player);
-
-	HighScoreParameter param;
-	param.difficulty = gs->getStartInfo()->difficulty;
-	param.day = gs->getDate();
-	param.townAmount = gs->howManyTowns(player);
-	param.usedCheat = gs->getPlayerState(player)->cheated;
-	param.hasGrail = false;
-	for(const CGHeroInstance * h : playerState->heroes)
-		if(h->hasArt(ArtifactID::GRAIL))
-			param.hasGrail = true;
-	for(const CGTownInstance * t : playerState->towns)
-		if(t->builtBuildings.count(BuildingID::GRAIL))
-			param.hasGrail = true;
-	param.allEnemiesDefeated = true;
-	for (PlayerColor otherPlayer(0); otherPlayer < PlayerColor::PLAYER_LIMIT; ++otherPlayer)
-	{
-		auto ps = gs->getPlayerState(otherPlayer, false);
-		if(ps && otherPlayer != player && !ps->checkVanquished())
-			param.allEnemiesDefeated = false;
-	}
-	param.scenarioName = gs->getMapHeader()->name.toString();
-	param.playerName = gs->getStartInfo()->playerInfos.find(player)->second.name;
-
-	return param;
-}
-
 void CServerHandler::showHighScoresAndEndGameplay(PlayerColor player, bool victory)
 {
-	HighScoreParameter param = prepareHighScores(player, victory);
+	HighScoreParameter param = HighScore::prepareHighScores(client->gameState(), player, victory);
 
 	if(victory && client->gameState()->getStartInfo()->campState)
 	{

+ 0 - 4
client/CServerHandler.h

@@ -40,8 +40,6 @@ class GlobalLobbyClient;
 class GameChatHandler;
 class IServerRunner;
 
-class HighScoreCalculation;
-
 enum class ESelectionScreen : ui8;
 enum class ELoadMode : ui8;
 
@@ -128,8 +126,6 @@ class CServerHandler final : public IServerAPI, public LobbyInfo, public INetwor
 
 	bool isServerLocal() const;
 
-	HighScoreParameter prepareHighScores(PlayerColor player, bool victory);
-
 public:
 	/// High-level connection overlay that is capable of (de)serializing network data
 	std::shared_ptr<CConnection> logicConnection;

+ 0 - 68
client/mainmenu/CHighScoreScreen.cpp

@@ -34,74 +34,6 @@
 #include "../../lib/constants/EntityIdentifiers.h"
 #include "../../lib/gameState/HighScore.h"
 
-auto HighScoreCalculation::calculate()
-{
-	struct Result
-	{
-		int basic = 0;
-		int total = 0;
-		int sumDays = 0;
-		bool cheater = false;
-	};
-	
-	Result firstResult;
-	Result summary;
-	const std::array<double, 5> difficultyMultipliers{0.8, 1.0, 1.3, 1.6, 2.0}; 
-	for(auto & param : parameters)
-	{
-		double tmp = 200 - (param.day + 10) / (param.townAmount + 5) + (param.allEnemiesDefeated ? 25 : 0) + (param.hasGrail ? 25 : 0);
-		firstResult = Result{static_cast<int>(tmp), static_cast<int>(tmp * difficultyMultipliers.at(param.difficulty)), param.day, param.usedCheat};
-		summary.basic += firstResult.basic * 5.0 / parameters.size();
-		summary.total += firstResult.total * 5.0 / parameters.size();
-		summary.sumDays += firstResult.sumDays;
-		summary.cheater |= firstResult.cheater;
-	}
-
-	if(parameters.size() == 1)
-		return firstResult;
-
-	return summary;
-}
-
-struct HighScoreCreature
-{
-	CreatureID creature;
-	int min;
-	int max;
-};
-
-static std::vector<HighScoreCreature> getHighscoreCreaturesList()
-{
-	JsonNode configCreatures(JsonPath::builtin("CONFIG/highscoreCreatures.json"));
-
-	std::vector<HighScoreCreature> ret;
-
-	for(auto & json : configCreatures["creatures"].Vector())
-	{
-		HighScoreCreature entry;
-		entry.creature = CreatureID::decode(json["creature"].String());
-		entry.max = json["max"].isNull() ? std::numeric_limits<int>::max() : json["max"].Integer();
-		entry.min = json["min"].isNull() ? std::numeric_limits<int>::min() : json["min"].Integer();
-
-		ret.push_back(entry);
-	}
-
-	return ret;
-}
-
-CreatureID HighScoreCalculation::getCreatureForPoints(int points, bool campaign)
-{
-	static const std::vector<HighScoreCreature> creatures = getHighscoreCreaturesList();
-
-	int divide = campaign ? 5 : 1;
-
-	for(auto & creature : creatures)
-		if(points / divide <= creature.max && points / divide >= creature.min)
-			return creature.creature;
-
-	throw std::runtime_error("Unable to find creature for score " + std::to_string(points));
-}
-
 CHighScoreScreen::CHighScoreScreen(HighScorePage highscorepage, int highlighted)
 	: CWindowObject(BORDERED), highscorepage(highscorepage), highlighted(highlighted)
 {

+ 0 - 10
client/mainmenu/CHighScoreScreen.h

@@ -21,16 +21,6 @@ class CFilledTexture;
 
 class TransparentFilledRectangle;
 
-class HighScoreCalculation
-{
-public:
-	std::vector<HighScoreParameter> parameters;
-	bool isCampaign = false;
-
-	auto calculate();
-	static CreatureID getCreatureForPoints(int points, bool campaign);
-};
-
 class CHighScoreScreen : public CWindowObject
 {
 public:

+ 3 - 0
lib/CMakeLists.txt

@@ -99,9 +99,11 @@ set(lib_MAIN_SRCS
 
 	gameState/CGameState.cpp
 	gameState/CGameStateCampaign.cpp
+	gameState/HighScore.cpp
 	gameState/InfoAboutArmy.cpp
 	gameState/RumorState.cpp
 	gameState/TavernHeroesPool.cpp
+	gameState/GameStatistics.cpp
 
 	mapObjectConstructors/AObjectTypeHandler.cpp
 	mapObjectConstructors/CBankInstanceConstructor.cpp
@@ -468,6 +470,7 @@ set(lib_MAIN_HEADERS
 	gameState/RumorState.h
 	gameState/SThievesGuildInfo.h
 	gameState/TavernHeroesPool.h
+	gameState/GameStatistics.h
 	gameState/TavernSlot.h
 	gameState/QuestInfo.h
 

+ 6 - 145
lib/gameState/CGameState.cpp

@@ -1538,137 +1538,6 @@ bool CGameState::checkForStandardLoss(const PlayerColor & player) const
 	return pState.checkVanquished();
 }
 
-struct statsHLP
-{
-	using TStat = std::pair<PlayerColor, si64>;
-	//converts [<player's color, value>] to vec[place] -> platers
-	static std::vector< std::vector< PlayerColor > > getRank( std::vector<TStat> stats )
-	{
-		std::sort(stats.begin(), stats.end(), statsHLP());
-
-		//put first element
-		std::vector< std::vector<PlayerColor> > ret;
-		std::vector<PlayerColor> tmp;
-		tmp.push_back( stats[0].first );
-		ret.push_back( tmp );
-
-		//the rest of elements
-		for(int g=1; g<stats.size(); ++g)
-		{
-			if(stats[g].second == stats[g-1].second)
-			{
-				(ret.end()-1)->push_back( stats[g].first );
-			}
-			else
-			{
-				//create next occupied rank
-				std::vector<PlayerColor> tmp;
-				tmp.push_back(stats[g].first);
-				ret.push_back(tmp);
-			}
-		}
-
-		return ret;
-	}
-
-	bool operator()(const TStat & a, const TStat & b) const
-	{
-		return a.second > b.second;
-	}
-
-	static const CGHeroInstance * findBestHero(CGameState * gs, const PlayerColor & color)
-	{
-		std::vector<ConstTransitivePtr<CGHeroInstance> > &h = gs->players[color].heroes;
-		if(h.empty())
-			return nullptr;
-		//best hero will be that with highest exp
-		int best = 0;
-		for(int b=1; b<h.size(); ++b)
-		{
-			if(h[b]->exp > h[best]->exp)
-			{
-				best = b;
-			}
-		}
-		return h[best];
-	}
-
-	//calculates total number of artifacts that belong to given player
-	static int getNumberOfArts(const PlayerState * ps)
-	{
-		int ret = 0;
-		for(auto h : ps->heroes)
-		{
-			ret += (int)h->artifactsInBackpack.size() + (int)h->artifactsWorn.size();
-		}
-		return ret;
-	}
-
-	// get total strength of player army
-	static si64 getArmyStrength(const PlayerState * ps)
-	{
-		si64 str = 0;
-
-		for(auto h : ps->heroes)
-		{
-			if(!h->inTownGarrison)		//original h3 behavior
-				str += h->getArmyStrength();
-		}
-		return str;
-	}
-
-	// get total gold income
-	static int getIncome(const PlayerState * ps, int percentIncome)
-	{
-		int totalIncome = 0;
-		const CGObjectInstance * heroOrTown = nullptr;
-
-		//Heroes can produce gold as well - skill, specialty or arts
-		for(const auto & h : ps->heroes)
-		{
-			totalIncome += h->valOfBonuses(Selector::typeSubtype(BonusType::GENERATE_RESOURCE, BonusSubtypeID(GameResID(GameResID::GOLD)))) * percentIncome / 100;
-
-			if(!heroOrTown)
-				heroOrTown = h;
-		}
-
-		//Add town income of all towns
-		for(const auto & t : ps->towns)
-		{
-			totalIncome += t->dailyIncome()[EGameResID::GOLD];
-
-			if(!heroOrTown)
-				heroOrTown = t;
-		}
-
-		/// FIXME: Dirty dirty hack
-		/// Stats helper need some access to gamestate.
-		std::vector<const CGObjectInstance *> ownedObjects;
-		for(const CGObjectInstance * obj : heroOrTown->cb->gameState()->map->objects)
-		{
-			if(obj && obj->tempOwner == ps->color)
-				ownedObjects.push_back(obj);
-		}
-		/// This is code from CPlayerSpecificInfoCallback::getMyObjects
-		/// I'm really need to find out about callback interface design...
-
-		for(const auto * object : ownedObjects)
-		{
-			//Mines
-			if ( object->ID == Obj::MINE )
-			{
-				const auto * mine = dynamic_cast<const CGMine *>(object);
-				assert(mine);
-
-				if (mine->producedResource == EGameResID::GOLD)
-					totalIncome += mine->getProducedQuantity();
-			}
-		}
-
-		return totalIncome;
-	}
-};
-
 void CGameState::obtainPlayersStats(SThievesGuildInfo & tgi, int level)
 {
 	auto playerInactive = [&](const PlayerColor & color) 
@@ -1688,7 +1557,7 @@ void CGameState::obtainPlayersStats(SThievesGuildInfo & tgi, int level)
 			stat.second = VAL_GETTER; \
 			stats.push_back(stat); \
 		} \
-		tgi.FIELD = statsHLP::getRank(stats); \
+		tgi.FIELD = Statistic::getRank(stats); \
 	}
 
 	for(auto & elem : players)
@@ -1710,7 +1579,7 @@ void CGameState::obtainPlayersStats(SThievesGuildInfo & tgi, int level)
 		{
 			if(playerInactive(player.second.color))
 				continue;
-			const CGHeroInstance * best = statsHLP::findBestHero(this, player.second.color);
+			const CGHeroInstance * best = Statistic::findBestHero(this, player.second.color);
 			InfoAboutHero iah;
 			iah.initFromHero(best, (level >= 2) ? InfoAboutHero::EInfoLevel::DETAILED : InfoAboutHero::EInfoLevel::BASIC);
 			iah.army.clear();
@@ -1731,27 +1600,19 @@ void CGameState::obtainPlayersStats(SThievesGuildInfo & tgi, int level)
 	}
 	if(level >= 3) //obelisks found
 	{
-		auto getObeliskVisited = [&](const TeamID & t)
-		{
-			if(map->obelisksVisited.count(t))
-				return map->obelisksVisited[t];
-			else
-				return ui8(0);
-		};
-
-		FILL_FIELD(obelisks, getObeliskVisited(gs->getPlayerTeam(g->second.color)->id))
+		FILL_FIELD(obelisks, Statistic::getObeliskVisited(gs, gs->getPlayerTeam(g->second.color)->id))
 	}
 	if(level >= 4) //artifacts
 	{
-		FILL_FIELD(artifacts, statsHLP::getNumberOfArts(&g->second))
+		FILL_FIELD(artifacts, Statistic::getNumberOfArts(&g->second))
 	}
 	if(level >= 4) //army strength
 	{
-		FILL_FIELD(army, statsHLP::getArmyStrength(&g->second))
+		FILL_FIELD(army, Statistic::getArmyStrength(&g->second))
 	}
 	if(level >= 5) //income
 	{
-		FILL_FIELD(income, statsHLP::getIncome(&g->second, scenarioOps->getIthPlayersSettings(g->second.color).handicap.percentIncome))
+		FILL_FIELD(income, Statistic::getIncome(gs, &g->second))
 	}
 	if(level >= 2) //best hero's stats
 	{

+ 5 - 0
lib/gameState/CGameState.h

@@ -15,6 +15,7 @@
 #include "../ConstTransitivePtr.h"
 
 #include "RumorState.h"
+#include "GameStatistics.h"
 
 namespace boost
 {
@@ -90,6 +91,8 @@ public:
 	CBonusSystemNode globalEffects;
 	RumorState currentRumor;
 
+	StatisticDataSet statistic;
+
 	static boost::shared_mutex mutex;
 
 	void updateEntity(Metatype metatype, int32_t index, const JsonNode & data) override;
@@ -167,6 +170,8 @@ public:
 		h & currentRumor;
 		h & campaign;
 		h & allocatedArtifacts;
+		if (h.version >= Handler::Version::STATISTICS)
+			h & statistic;
 
 		BONUS_TREE_DESERIALIZATION_FIX
 	}

+ 370 - 0
lib/gameState/GameStatistics.cpp

@@ -0,0 +1,370 @@
+/*
+ * GameStatistics.cpp, part of VCMI engine
+ *
+ * Authors: listed in file AUTHORS in main folder
+ *
+ * License: GNU General Public License v2.0 or later
+ * Full text of license available in license.txt file, in main folder
+ *
+ */
+#include "StdInc.h"
+#include "GameStatistics.h"
+#include "../CPlayerState.h"
+#include "../constants/StringConstants.h"
+#include "CGameState.h"
+#include "TerrainHandler.h"
+#include "CHeroHandler.h"
+#include "StartInfo.h"
+#include "HighScore.h"
+#include "../mapObjects/CGHeroInstance.h"
+#include "../mapObjects/CGTownInstance.h"
+#include "../mapObjects/CGObjectInstance.h"
+#include "../mapObjects/MiscObjects.h"
+#include "../mapping/CMap.h"
+#include "../entities/building/CBuilding.h"
+
+
+VCMI_LIB_NAMESPACE_BEGIN
+
+void StatisticDataSet::add(StatisticDataSetEntry entry)
+{
+	data.push_back(entry);
+}
+
+StatisticDataSetEntry StatisticDataSet::createEntry(const PlayerState * ps, const CGameState * gs)
+{
+	StatisticDataSetEntry data;
+
+	HighScoreParameter param = HighScore::prepareHighScores(gs, ps->color, false);
+	HighScoreCalculation scenarioHighScores;
+	scenarioHighScores.parameters.push_back(param);
+	scenarioHighScores.isCampaign = false;
+
+	data.map = gs->map->name.toString();
+	data.timestamp = std::time(0);
+	data.day = gs->getDate(Date::DAY);
+	data.player = ps->color;
+	data.team = ps->team;
+	data.isHuman = ps->isHuman();
+	data.status = ps->status;
+	data.resources = ps->resources;
+	data.numberHeroes = ps->heroes.size();
+	data.numberTowns = gs->howManyTowns(ps->color);
+	data.numberArtifacts = Statistic::getNumberOfArts(ps);
+	data.numberDwellings = gs->getPlayerState(ps->color)->dwellings.size();
+	data.armyStrength = Statistic::getArmyStrength(ps, true);
+	data.totalExperience = Statistic::getTotalExperience(ps);
+	data.income = Statistic::getIncome(gs, ps);
+	data.mapExploredRatio = Statistic::getMapExploredRatio(gs, ps->color);
+	data.obeliskVisitedRatio = Statistic::getObeliskVisitedRatio(gs, ps->team);
+	data.townBuiltRatio = Statistic::getTownBuiltRatio(ps);
+	data.hasGrail = param.hasGrail;
+	data.numMines = Statistic::getNumMines(gs, ps);
+	data.score = scenarioHighScores.calculate().total;
+	data.maxHeroLevel = Statistic::findBestHero(gs, ps->color) ? Statistic::findBestHero(gs, ps->color)->level : 0;
+	data.numBattlesNeutral = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).numBattlesNeutral : 0;
+	data.numBattlesPlayer = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).numBattlesPlayer : 0;
+	data.numWinBattlesNeutral = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).numWinBattlesNeutral : 0;
+	data.numWinBattlesPlayer = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).numWinBattlesPlayer : 0;
+	data.numHeroSurrendered = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).numHeroSurrendered : 0;
+	data.numHeroEscaped = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).numHeroEscaped : 0;
+	data.spentResourcesForArmy = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).spentResourcesForArmy : TResources();
+	data.spentResourcesForBuildings = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).spentResourcesForBuildings : TResources();
+	data.tradeVolume = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).tradeVolume : TResources();
+	data.movementPointsUsed = gs->statistic.accumulatedValues.count(ps->color) ? gs->statistic.accumulatedValues.at(ps->color).movementPointsUsed : 0;
+
+	return data;
+}
+
+std::string StatisticDataSet::toCsv()
+{
+	std::stringstream ss;
+
+	auto resources = std::vector<EGameResID>{EGameResID::GOLD, EGameResID::WOOD, EGameResID::MERCURY, EGameResID::ORE, EGameResID::SULFUR, EGameResID::CRYSTAL, EGameResID::GEMS};
+
+	ss << "Map" << ";";
+	ss << "Timestamp" << ";";
+	ss << "Day" << ";";
+	ss << "Player" << ";";
+	ss << "Team" << ";";
+	ss << "IsHuman" << ";";
+	ss << "Status" << ";";
+	ss << "NumberHeroes" << ";";
+	ss << "NumberTowns" << ";";
+	ss << "NumberArtifacts" << ";";
+	ss << "NumberDwellings" << ";";
+	ss << "ArmyStrength" << ";";
+	ss << "TotalExperience" << ";";
+	ss << "Income" << ";";
+	ss << "MapExploredRatio" << ";";
+	ss << "ObeliskVisitedRatio" << ";";
+	ss << "TownBuiltRatio" << ";";
+	ss << "HasGrail" << ";";
+	ss << "Score" << ";";
+	ss << "MaxHeroLevel" << ";";
+	ss << "NumBattlesNeutral" << ";";
+	ss << "NumBattlesPlayer" << ";";
+	ss << "NumWinBattlesNeutral" << ";";
+	ss << "NumWinBattlesPlayer" << ";";
+	ss << "NumHeroSurrendered" << ";";
+	ss << "NumHeroEscaped" << ";";
+	ss << "MovementPointsUsed";
+	for(auto & resource : resources)
+		ss << ";" << GameConstants::RESOURCE_NAMES[resource];
+	for(auto & resource : resources)
+		ss << ";" << GameConstants::RESOURCE_NAMES[resource] + "Mines";
+	for(auto & resource : resources)
+		ss << ";" << GameConstants::RESOURCE_NAMES[resource] + "SpentResourcesForArmy";
+	for(auto & resource : resources)
+		ss << ";" << GameConstants::RESOURCE_NAMES[resource] + "SpentResourcesForBuildings";
+	for(auto & resource : resources)
+		ss << ";" << GameConstants::RESOURCE_NAMES[resource] + "TradeVolume";
+	ss << "\r\n";
+
+	for(auto & entry : data)
+	{
+		ss << entry.map << ";";
+		ss << vstd::getFormattedDateTime(entry.timestamp, "%Y-%m-%dT%H:%M:%S") << ";";
+		ss << entry.day << ";";
+		ss << GameConstants::PLAYER_COLOR_NAMES[entry.player] << ";";
+		ss << entry.team.getNum() << ";";
+		ss << entry.isHuman << ";";
+		ss << (int)entry.status << ";";
+		ss << entry.numberHeroes << ";";
+		ss << entry.numberTowns <<  ";";
+		ss << entry.numberArtifacts << ";";
+		ss << entry.numberDwellings << ";";
+		ss << entry.armyStrength << ";";
+		ss << entry.totalExperience << ";";
+		ss << entry.income << ";";
+		ss << entry.mapExploredRatio << ";";
+		ss << entry.obeliskVisitedRatio << ";";
+		ss << entry.townBuiltRatio << ";";
+		ss << entry.hasGrail << ";";
+		ss << entry.score << ";";
+		ss << entry.maxHeroLevel << ";";
+		ss << entry.numBattlesNeutral << ";";
+		ss << entry.numBattlesPlayer << ";";
+		ss << entry.numWinBattlesNeutral << ";";
+		ss << entry.numWinBattlesPlayer << ";";
+		ss << entry.numHeroSurrendered << ";";
+		ss << entry.numHeroEscaped << ";";
+		ss << entry.movementPointsUsed;
+		for(auto & resource : resources)
+			ss << ";" << entry.resources[resource];
+		for(auto & resource : resources)
+			ss << ";" << entry.numMines[resource];
+		for(auto & resource : resources)
+			ss << ";" << entry.spentResourcesForArmy[resource];
+		for(auto & resource : resources)
+			ss << ";" << entry.spentResourcesForBuildings[resource];
+		for(auto & resource : resources)
+			ss << ";" << entry.tradeVolume[resource];
+		ss << "\r\n";
+	}
+
+	return ss.str();
+}
+
+std::vector<const CGMine *> Statistic::getMines(const CGameState * gs, const PlayerState * ps)
+{
+	std::vector<const CGMine *> tmp;
+
+	/// FIXME: Dirty dirty hack
+	/// Stats helper need some access to gamestate.
+	std::vector<const CGObjectInstance *> ownedObjects;
+	for(const CGObjectInstance * obj : gs->map->objects)
+	{
+		if(obj && obj->tempOwner == ps->color)
+			ownedObjects.push_back(obj);
+	}
+	/// This is code from CPlayerSpecificInfoCallback::getMyObjects
+	/// I'm really need to find out about callback interface design...
+
+	for(const auto * object : ownedObjects)
+	{
+		//Mines
+		if ( object->ID == Obj::MINE )
+		{
+			const auto * mine = dynamic_cast<const CGMine *>(object);
+			assert(mine);
+
+			tmp.push_back(mine);
+		}
+	}
+
+	return tmp;
+}
+
+//calculates total number of artifacts that belong to given player
+int Statistic::getNumberOfArts(const PlayerState * ps)
+{
+	int ret = 0;
+	for(auto h : ps->heroes)
+	{
+		ret += (int)h->artifactsInBackpack.size() + (int)h->artifactsWorn.size();
+	}
+	return ret;
+}
+
+// get total strength of player army
+si64 Statistic::getArmyStrength(const PlayerState * ps, bool withTownGarrison)
+{
+	si64 str = 0;
+
+	for(auto h : ps->heroes)
+	{
+		if(!h->inTownGarrison || withTownGarrison)		//original h3 behavior
+			str += h->getArmyStrength();
+	}
+	return str;
+}
+
+// get total experience of all heroes
+si64 Statistic::getTotalExperience(const PlayerState * ps)
+{
+	si64 tmp = 0;
+
+	for(auto h : ps->heroes)
+		tmp += h->exp;
+	
+	return tmp;
+}
+
+// get total gold income
+int Statistic::getIncome(const CGameState * gs, const PlayerState * ps)
+{
+	int percentIncome = gs->getStartInfo()->getIthPlayersSettings(ps->color).handicap.percentIncome;
+	int totalIncome = 0;
+
+	//Heroes can produce gold as well - skill, specialty or arts
+	for(const auto & h : ps->heroes)
+		totalIncome += h->valOfBonuses(Selector::typeSubtype(BonusType::GENERATE_RESOURCE, BonusSubtypeID(GameResID(GameResID::GOLD)))) * percentIncome / 100;
+
+	//Add town income of all towns
+	for(const auto & t : ps->towns)
+		totalIncome += t->dailyIncome()[EGameResID::GOLD];
+
+	for(const CGMine * mine : getMines(gs, ps))
+		if(mine->producedResource == EGameResID::GOLD)
+			totalIncome += mine->getProducedQuantity();
+
+	return totalIncome;
+}
+
+float Statistic::getMapExploredRatio(const CGameState * gs, PlayerColor player)
+{
+	float visible = 0.0;
+	float numTiles = 0.0;
+
+	for(int layer = 0; layer < (gs->map->twoLevel ? 2 : 1); layer++)
+		for(int y = 0; y < gs->map->height; ++y)
+			for(int x = 0; x < gs->map->width; ++x)
+			{
+				TerrainTile tile = gs->map->getTile(int3(x, y, layer));
+
+				if(tile.blocked && (!tile.visitable))
+					continue;
+
+				if(gs->isVisible(int3(x, y, layer), player))
+					visible++;
+				numTiles++;
+			}
+	
+	return visible / numTiles;
+}
+
+const CGHeroInstance * Statistic::findBestHero(const CGameState * gs, const PlayerColor & color)
+{
+	auto &h = gs->players.at(color).heroes;
+	if(h.empty())
+		return nullptr;
+	//best hero will be that with highest exp
+	int best = 0;
+	for(int b=1; b<h.size(); ++b)
+	{
+		if(h[b]->exp > h[best]->exp)
+		{
+			best = b;
+		}
+	}
+	return h[best];
+}
+
+std::vector<std::vector<PlayerColor>> Statistic::getRank(std::vector<std::pair<PlayerColor, si64>> stats)
+{
+	std::sort(stats.begin(), stats.end(), [](const std::pair<PlayerColor, si64> & a, const std::pair<PlayerColor, si64> & b) { return a.second > b.second; });
+
+	//put first element
+	std::vector< std::vector<PlayerColor> > ret;
+	std::vector<PlayerColor> tmp;
+	tmp.push_back( stats[0].first );
+	ret.push_back( tmp );
+
+	//the rest of elements
+	for(int g=1; g<stats.size(); ++g)
+	{
+		if(stats[g].second == stats[g-1].second)
+		{
+			(ret.end()-1)->push_back( stats[g].first );
+		}
+		else
+		{
+			//create next occupied rank
+			std::vector<PlayerColor> tmp;
+			tmp.push_back(stats[g].first);
+			ret.push_back(tmp);
+		}
+	}
+
+	return ret;
+}
+
+int Statistic::getObeliskVisited(const CGameState * gs, const TeamID & t)
+{
+	if(gs->map->obelisksVisited.count(t))
+		return gs->map->obelisksVisited.at(t);
+	else
+		return 0;
+}
+
+float Statistic::getObeliskVisitedRatio(const CGameState * gs, const TeamID & t)
+{
+	if(!gs->map->obeliskCount)
+		return 0;
+	return (float)getObeliskVisited(gs, t) / (float)gs->map->obeliskCount;
+}
+
+std::map<EGameResID, int> Statistic::getNumMines(const CGameState * gs, const PlayerState * ps)
+{
+	std::map<EGameResID, int> tmp;
+
+	for(auto & res : EGameResID::ALL_RESOURCES())
+		tmp[res] = 0;
+
+	for(const CGMine * mine : getMines(gs, ps))
+		tmp[mine->producedResource]++;
+	
+	return tmp;
+}
+
+float Statistic::getTownBuiltRatio(const PlayerState * ps)
+{
+	float built = 0.0;
+	float total = 0.0;
+
+	for(const auto & t : ps->towns)
+	{
+		built += t->builtBuildings.size();
+		for(const auto & b : t->town->buildings)
+			if(!t->forbiddenBuildings.count(b.first))
+				total += 1;
+	}
+
+	if(total < 1)
+		return 0;
+	
+	return built / total;
+}
+
+VCMI_LIB_NAMESPACE_END

+ 156 - 0
lib/gameState/GameStatistics.h

@@ -0,0 +1,156 @@
+/*
+ * GameSTatistics.h, part of VCMI engine
+ *
+ * Authors: listed in file AUTHORS in main folder
+ *
+ * License: GNU General Public License v2.0 or later
+ * Full text of license available in license.txt file, in main folder
+ *
+ */
+#pragma once
+
+#include "../GameConstants.h"
+#include "../ResourceSet.h"
+
+VCMI_LIB_NAMESPACE_BEGIN
+
+struct PlayerState;
+class CGameState;
+class CGHeroInstance;
+class CGMine;
+
+struct DLL_LINKAGE StatisticDataSetEntry
+{
+	std::string map;
+	time_t timestamp;
+    int day;
+    PlayerColor player;
+	TeamID team;
+	bool isHuman;
+	EPlayerStatus status;
+	TResources resources;
+	int numberHeroes;
+	int numberTowns;
+	int numberArtifacts;
+	int numberDwellings;
+	si64 armyStrength;
+	si64 totalExperience;
+	int income;
+	float mapExploredRatio;
+	float obeliskVisitedRatio;
+	float townBuiltRatio;
+	bool hasGrail;
+	std::map<EGameResID, int> numMines;
+	int score;
+	int maxHeroLevel;
+	int numBattlesNeutral;
+	int numBattlesPlayer;
+	int numWinBattlesNeutral;
+	int numWinBattlesPlayer;
+	int numHeroSurrendered;
+	int numHeroEscaped;
+	TResources spentResourcesForArmy;
+	TResources spentResourcesForBuildings;
+	TResources tradeVolume;
+	si64 movementPointsUsed;
+
+	template <typename Handler> void serialize(Handler &h)
+	{
+		h & map;
+		h & timestamp;
+		h & day;
+		h & player;
+		h & team;
+		h & isHuman;
+		h & status;
+		h & resources;
+		h & numberHeroes;
+		h & numberTowns;
+		h & numberArtifacts;
+		h & numberDwellings;
+		h & armyStrength;
+		h & totalExperience;
+		h & income;
+		h & mapExploredRatio;
+		h & obeliskVisitedRatio;
+		h & townBuiltRatio;
+		h & hasGrail;
+		h & numMines;
+		h & score;
+		h & maxHeroLevel;
+		h & numBattlesNeutral;
+		h & numBattlesPlayer;
+		h & numWinBattlesNeutral;
+		h & numWinBattlesPlayer;
+		h & numHeroSurrendered;
+		h & numHeroEscaped;
+		h & spentResourcesForArmy;
+		h & spentResourcesForBuildings;
+		h & tradeVolume;
+		h & movementPointsUsed;
+	}
+};
+
+class DLL_LINKAGE StatisticDataSet
+{
+    std::vector<StatisticDataSetEntry> data;
+
+public:
+    void add(StatisticDataSetEntry entry);
+	static StatisticDataSetEntry createEntry(const PlayerState * ps, const CGameState * gs);
+    std::string toCsv();
+
+	struct PlayerAccumulatedValueStorage // holds some actual values needed for stats
+	{
+		int numBattlesNeutral;
+		int numBattlesPlayer;
+		int numWinBattlesNeutral;
+		int numWinBattlesPlayer;
+		int numHeroSurrendered;
+		int numHeroEscaped;
+		TResources spentResourcesForArmy;
+		TResources spentResourcesForBuildings;
+		TResources tradeVolume;
+		si64 movementPointsUsed;
+
+		template <typename Handler> void serialize(Handler &h)
+		{
+			h & numBattlesNeutral;
+			h & numBattlesPlayer;
+			h & numWinBattlesNeutral;
+			h & numWinBattlesPlayer;
+			h & numHeroSurrendered;
+			h & numHeroEscaped;
+			h & spentResourcesForArmy;
+			h & spentResourcesForBuildings;
+			h & tradeVolume;
+			h & movementPointsUsed;
+		}
+	};
+	std::map<PlayerColor, PlayerAccumulatedValueStorage> accumulatedValues;
+
+	template <typename Handler> void serialize(Handler &h)
+	{
+		h & data;
+		h & accumulatedValues;
+	}
+};
+
+class DLL_LINKAGE Statistic
+{
+	static std::vector<const CGMine *> getMines(const CGameState * gs, const PlayerState * ps);
+public:
+	static int getNumberOfArts(const PlayerState * ps);
+	static si64 getArmyStrength(const PlayerState * ps, bool withTownGarrison = false);
+	static si64 getTotalExperience(const PlayerState * ps);
+	static int getIncome(const CGameState * gs, const PlayerState * ps);
+	static float getMapExploredRatio(const CGameState * gs, PlayerColor player);
+	static const CGHeroInstance * findBestHero(const CGameState * gs, const PlayerColor & color);
+	static std::vector<std::vector<PlayerColor>> getRank(std::vector<std::pair<PlayerColor, si64>> stats);
+	static int getObeliskVisited(const CGameState * gs, const TeamID & t);
+	static float getObeliskVisitedRatio(const CGameState * gs, const TeamID & t);
+	static std::map<EGameResID, int> getNumMines(const CGameState * gs, const PlayerState * ps);
+	static float getTownBuiltRatio(const PlayerState * ps);
+};
+
+VCMI_LIB_NAMESPACE_END

+ 111 - 0
lib/gameState/HighScore.cpp

@@ -0,0 +1,111 @@
+/*
+ * HighScore.cpp, part of VCMI engine
+ *
+ * Authors: listed in file AUTHORS in main folder
+ *
+ * License: GNU General Public License v2.0 or later
+ * Full text of license available in license.txt file, in main folder
+ *
+ */
+#include "StdInc.h"
+#include "HighScore.h"
+#include "../CPlayerState.h"
+#include "../constants/StringConstants.h"
+#include "CGameState.h"
+#include "StartInfo.h"
+#include "../mapping/CMapHeader.h"
+#include "../mapObjects/CGHeroInstance.h"
+#include "../mapObjects/CGTownInstance.h"
+
+VCMI_LIB_NAMESPACE_BEGIN
+
+HighScoreParameter HighScore::prepareHighScores(const CGameState * gs, PlayerColor player, bool victory)
+{
+	const auto * playerState = gs->getPlayerState(player);
+
+	HighScoreParameter param;
+	param.difficulty = gs->getStartInfo()->difficulty;
+	param.day = gs->getDate();
+	param.townAmount = gs->howManyTowns(player);
+	param.usedCheat = gs->getPlayerState(player)->cheated;
+	param.hasGrail = false;
+	for(const CGHeroInstance * h : playerState->heroes)
+		if(h->hasArt(ArtifactID::GRAIL))
+			param.hasGrail = true;
+	for(const CGTownInstance * t : playerState->towns)
+		if(t->builtBuildings.count(BuildingID::GRAIL))
+			param.hasGrail = true;
+	param.allEnemiesDefeated = true;
+	for (PlayerColor otherPlayer(0); otherPlayer < PlayerColor::PLAYER_LIMIT; ++otherPlayer)
+	{
+		auto ps = gs->getPlayerState(otherPlayer, false);
+		if(ps && otherPlayer != player && !ps->checkVanquished())
+			param.allEnemiesDefeated = false;
+	}
+	param.scenarioName = gs->getMapHeader()->name.toString();
+	param.playerName = gs->getStartInfo()->playerInfos.find(player)->second.name;
+
+	return param;
+}
+
+HighScoreCalculation::Result HighScoreCalculation::calculate()
+{
+	Result firstResult;
+	Result summary;
+	const std::array<double, 5> difficultyMultipliers{0.8, 1.0, 1.3, 1.6, 2.0}; 
+	for(auto & param : parameters)
+	{
+		double tmp = 200 - (param.day + 10) / (param.townAmount + 5) + (param.allEnemiesDefeated ? 25 : 0) + (param.hasGrail ? 25 : 0);
+		firstResult = Result{static_cast<int>(tmp), static_cast<int>(tmp * difficultyMultipliers.at(param.difficulty)), param.day, param.usedCheat};
+		summary.basic += firstResult.basic * 5.0 / parameters.size();
+		summary.total += firstResult.total * 5.0 / parameters.size();
+		summary.sumDays += firstResult.sumDays;
+		summary.cheater |= firstResult.cheater;
+	}
+
+	if(parameters.size() == 1)
+		return firstResult;
+
+	return summary;
+}
+
+struct HighScoreCreature
+{
+	CreatureID creature;
+	int min;
+	int max;
+};
+
+static std::vector<HighScoreCreature> getHighscoreCreaturesList()
+{
+	JsonNode configCreatures(JsonPath::builtin("CONFIG/highscoreCreatures.json"));
+
+	std::vector<HighScoreCreature> ret;
+
+	for(auto & json : configCreatures["creatures"].Vector())
+	{
+		HighScoreCreature entry;
+		entry.creature = CreatureID::decode(json["creature"].String());
+		entry.max = json["max"].isNull() ? std::numeric_limits<int>::max() : json["max"].Integer();
+		entry.min = json["min"].isNull() ? std::numeric_limits<int>::min() : json["min"].Integer();
+
+		ret.push_back(entry);
+	}
+
+	return ret;
+}
+
+CreatureID HighScoreCalculation::getCreatureForPoints(int points, bool campaign)
+{
+	static const std::vector<HighScoreCreature> creatures = getHighscoreCreaturesList();
+
+	int divide = campaign ? 5 : 1;
+
+	for(auto & creature : creatures)
+		if(points / divide <= creature.max && points / divide >= creature.min)
+			return creature.creature;
+
+	throw std::runtime_error("Unable to find creature for score " + std::to_string(points));
+}
+
+VCMI_LIB_NAMESPACE_END

+ 27 - 0
lib/gameState/HighScore.h

@@ -9,8 +9,12 @@
  */
 #pragma once
 
+#include "../GameConstants.h"
+
 VCMI_LIB_NAMESPACE_BEGIN
 
+class CGameState;
+
 class DLL_LINKAGE HighScoreParameter
 {
 public:
@@ -37,5 +41,28 @@ public:
 		h & playerName;
 	}
 };
+class DLL_LINKAGE HighScore
+{
+public:
+	static HighScoreParameter prepareHighScores(const CGameState * gs, PlayerColor player, bool victory);
+};
+
+class DLL_LINKAGE HighScoreCalculation
+{
+public:
+	struct Result
+	{
+		int basic = 0;
+		int total = 0;
+		int sumDays = 0;
+		bool cheater = false;
+	};
+
+	std::vector<HighScoreParameter> parameters;
+	bool isCampaign = false;
+
+	Result calculate();
+	static CreatureID getCreatureForPoints(int points, bool campaign);
+};
 
 VCMI_LIB_NAMESPACE_END

+ 2 - 1
lib/serializer/ESerializationVersion.h

@@ -59,6 +59,7 @@ enum class ESerializationVersion : int32_t
 	REMOVE_LIB_RNG, // 849 - removed random number generators from library classes
 	HIGHSCORE_PARAMETERS, // 850 - saves parameter for campaign
   PLAYER_HANDICAP, // 851 - player handicap selection at game start
+	STATISTICS, // 852 - removed random number generators from library classes
 
-	CURRENT = PLAYER_HANDICAP
+	CURRENT = STATISTICS
 };

+ 26 - 1
server/CGameHandler.cpp

@@ -669,6 +669,19 @@ void CGameHandler::onPlayerTurnEnded(PlayerColor which)
 		heroPool->onNewWeek(which);
 }
 
+void CGameHandler::addStatistics()
+{
+	for (auto & elem : gs->players)
+	{
+		if (elem.first == PlayerColor::NEUTRAL || !elem.first.isValidPlayer())
+			continue;
+
+		auto data = StatisticDataSet::createEntry(&elem.second, gs);
+
+		gameState()->statistic.add(data);
+	}
+}
+
 void CGameHandler::onNewTurn()
 {
 	logGlobal->trace("Turn %d", gs->day+1);
@@ -1013,6 +1026,8 @@ void CGameHandler::onNewTurn()
 	}
 
 	synchronizeArtifactHandlerLists(); //new day events may have changed them. TODO better of managing that
+
+	addStatistics();
 }
 
 void CGameHandler::start(bool resume)
@@ -1345,6 +1360,7 @@ bool CGameHandler::moveHero(ObjectInstanceID hid, int3 dst, EMovementMode moveme
 
 		turnTimerHandler->setEndTurnAllowed(h->getOwner(), !movingOntoWater && !movingOntoObstacle);
 		doMove(TryMoveHero::SUCCESS, lookForGuards, visitDest, LEAVING_TILE);
+		gs->statistic.accumulatedValues[asker].movementPointsUsed += tmh.movePoints;
 		return true;
 	}
 }
@@ -2457,7 +2473,10 @@ bool CGameHandler::buildStructure(ObjectInstanceID tid, BuildingID requestedID,
 
 	//Take cost
 	if(!force)
+	{
 		giveResources(t->tempOwner, -requestedBuilding->resources);
+		gs->statistic.accumulatedValues[t->tempOwner].spentResourcesForBuildings += requestedBuilding->resources;
+	}
 
 	//We know what has been built, apply changes. Do this as final step to properly update town window
 	sendAndApply(&ns);
@@ -2559,7 +2578,9 @@ bool CGameHandler::recruitCreatures(ObjectInstanceID objid, ObjectInstanceID dst
 	}
 
 	//recruit
-	giveResources(army->tempOwner, -(c->getFullRecruitCost() * cram));
+	TResources cost = (c->getFullRecruitCost() * cram);
+	giveResources(army->tempOwner, -cost);
+	gs->statistic.accumulatedValues[army->tempOwner].spentResourcesForArmy += cost;
 
 	SetAvailableCreatures sac;
 	sac.tid = objid;
@@ -2612,6 +2633,7 @@ bool CGameHandler::upgradeCreature(ObjectInstanceID objid, SlotID pos, CreatureI
 
 	//take resources
 	giveResources(player, -totalCost);
+	gs->statistic.accumulatedValues[player].spentResourcesForArmy += totalCost;
 
 	//upgrade creature
 	changeStackType(StackLocation(obj, pos), upgID.toCreature());
@@ -3236,6 +3258,9 @@ bool CGameHandler::tradeResources(const IMarket *market, ui32 amountToSell, Play
 	giveResource(player, toSell, -b1 * amountToBoy);
 	giveResource(player, toBuy, b2 * amountToBoy);
 
+	gs->statistic.accumulatedValues[player].tradeVolume[toSell] += -b1 * amountToBoy;
+	gs->statistic.accumulatedValues[player].tradeVolume[toBuy] += b2 * amountToBoy;
+
 	return true;
 }
 

+ 1 - 0
server/CGameHandler.h

@@ -226,6 +226,7 @@ public:
 	void onPlayerTurnStarted(PlayerColor which);
 	void onPlayerTurnEnded(PlayerColor which);
 	void onNewTurn();
+	void addStatistics();
 
 	void handleTimeEvents();
 	void handleTownEvents(CGTownInstance *town, NewTurn &n);

+ 22 - 0
server/battles/BattleResultProcessor.cpp

@@ -497,6 +497,22 @@ void BattleResultProcessor::endBattleConfirm(const CBattleInfoCallback & battle)
 		gameHandler->sendAndApply(&ro);
 	}
 
+	// add statistic
+	if(battle.sideToPlayer(0) == PlayerColor::NEUTRAL || battle.sideToPlayer(1) == PlayerColor::NEUTRAL)
+	{
+		gameHandler->gameState()->statistic.accumulatedValues[battle.sideToPlayer(0)].numBattlesNeutral++;
+		gameHandler->gameState()->statistic.accumulatedValues[battle.sideToPlayer(1)].numBattlesNeutral++;
+		if(!finishingBattle->isDraw())
+			gameHandler->gameState()->statistic.accumulatedValues[battle.sideToPlayer(finishingBattle->winnerSide)].numWinBattlesNeutral++;
+	}
+	else
+	{
+		gameHandler->gameState()->statistic.accumulatedValues[battle.sideToPlayer(0)].numBattlesPlayer++;
+		gameHandler->gameState()->statistic.accumulatedValues[battle.sideToPlayer(1)].numBattlesPlayer++;
+		if(!finishingBattle->isDraw())
+			gameHandler->gameState()->statistic.accumulatedValues[battle.sideToPlayer(finishingBattle->winnerSide)].numWinBattlesPlayer++;
+	}
+
 	BattleResultAccepted raccepted;
 	raccepted.battleID = battle.getBattle()->getBattleID();
 	raccepted.heroResult[0].army = const_cast<CArmedInstance*>(battle.battleGetArmyObject(BattleSide::ATTACKER));
@@ -556,10 +572,16 @@ void BattleResultProcessor::battleAfterLevelUp(const BattleID & battleID, const
 	gameHandler->checkVictoryLossConditions(playerColors);
 
 	if (result.result == EBattleResult::SURRENDER)
+	{
+		gameHandler->gameState()->statistic.accumulatedValues[finishingBattle->loser].numHeroSurrendered++;
 		gameHandler->heroPool->onHeroSurrendered(finishingBattle->loser, finishingBattle->loserHero);
+	}
 
 	if (result.result == EBattleResult::ESCAPE)
+	{
+		gameHandler->gameState()->statistic.accumulatedValues[finishingBattle->loser].numHeroEscaped++;
 		gameHandler->heroPool->onHeroEscaped(finishingBattle->loser, finishingBattle->loserHero);
+	}
 
 	if (result.winner != 2 && finishingBattle->winnerHero && finishingBattle->winnerHero->stacks.empty()
 		&& (!finishingBattle->winnerHero->commander || !finishingBattle->winnerHero->commander->alive))

+ 21 - 0
server/processors/PlayerMessageProcessor.cpp

@@ -29,6 +29,7 @@
 #include "../../lib/networkPacks/PacksForClient.h"
 #include "../../lib/networkPacks/StackLocation.h"
 #include "../../lib/serializer/Connection.h"
+#include "../lib/VCMIDirs.h"
 
 PlayerMessageProcessor::PlayerMessageProcessor(CGameHandler * gameHandler)
 	:gameHandler(gameHandler)
@@ -133,12 +134,30 @@ void PlayerMessageProcessor::commandCheaters(PlayerColor player, const std::vect
 		broadcastSystemMessage("No cheaters registered!");
 }
 
+void PlayerMessageProcessor::commandStatistic(PlayerColor player, const std::vector<std::string> & words)
+{
+	bool isHost = gameHandler->gameLobby()->isPlayerHost(player);
+	if(!isHost)
+		return;
+
+	const boost::filesystem::path outPath = VCMIDirs::get().userCachePath() / "statistic";
+	boost::filesystem::create_directories(outPath);
+
+	const boost::filesystem::path filePath = outPath / (vstd::getDateTimeISO8601Basic(std::time(nullptr)) + ".csv");
+	std::ofstream file(filePath.c_str());
+	std::string csv = gameHandler->gameState()->statistic.toCsv();
+	file << csv;
+
+	broadcastSystemMessage("Statistic files can be found in " + outPath.string() + " directory\n");
+}
+
 void PlayerMessageProcessor::commandHelp(PlayerColor player, const std::vector<std::string> & words)
 {
 	broadcastSystemMessage("Available commands to host:");
 	broadcastSystemMessage("'!exit' - immediately ends current game");
 	broadcastSystemMessage("'!kick <player>' - kick specified player from the game");
 	broadcastSystemMessage("'!save <filename>' - save game under specified filename");
+	broadcastSystemMessage("'!statistic' - save game statistics as csv file");
 	broadcastSystemMessage("Available commands to all players:");
 	broadcastSystemMessage("'!help' - display this help");
 	broadcastSystemMessage("'!cheaters' - list players that entered cheat command during game");
@@ -319,6 +338,8 @@ void PlayerMessageProcessor::handleCommand(PlayerColor player, const std::string
 		commandSave(player, words);
 	if(words[0] == "!cheaters")
 		commandCheaters(player, words);
+	if(words[0] == "!statistic")
+		commandStatistic(player, words);
 }
 
 void PlayerMessageProcessor::cheatGiveSpells(PlayerColor player, const CGHeroInstance * hero)

+ 1 - 0
server/processors/PlayerMessageProcessor.h

@@ -62,6 +62,7 @@ class PlayerMessageProcessor
 	void commandKick(PlayerColor player, const std::vector<std::string> & words);
 	void commandSave(PlayerColor player, const std::vector<std::string> & words);
 	void commandCheaters(PlayerColor player, const std::vector<std::string> & words);
+	void commandStatistic(PlayerColor player, const std::vector<std::string> & words);
 	void commandHelp(PlayerColor player, const std::vector<std::string> & words);
 	void commandVote(PlayerColor player, const std::vector<std::string> & words);