1 /*
2 This file is part of Warzone 2100.
3 Copyright (C) 1999-2004 Eidos Interactive
4 Copyright (C) 2005-2020 Warzone 2100 Project
5
6 Warzone 2100 is free software; you can redistribute it and/or modify
7 it under the terms of the GNU General Public License as published by
8 the Free Software Foundation; either version 2 of the License, or
9 (at your option) any later version.
10
11 Warzone 2100 is distributed in the hope that it will be useful,
12 but WITHOUT ANY WARRANTY; without even the implied warranty of
13 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 GNU General Public License for more details.
15
16 You should have received a copy of the GNU General Public License
17 along with Warzone 2100; if not, write to the Free Software
18 Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
19 */
20 /*
21 * MultiStat.c
22 *
23 * Alex Lee , pumpkin studios, EIDOS
24 *
25 * load / update / store multiplayer statistics for league tables etc...
26 */
27
28 #include "lib/framework/file.h"
29 #include "lib/framework/frame.h"
30 #include "lib/framework/wzapp.h"
31 #include "lib/netplay/nettypes.h"
32
33 #include "activity.h"
34 #include "clparse.h"
35 #include "main.h"
36 #include "mission.h" // for cheats
37 #include "multistat.h"
38 #include "urlrequest.h"
39
40 #include <utility>
41 #include <memory>
42 #include <SQLiteCpp/SQLiteCpp.h>
43
44
45 // ////////////////////////////////////////////////////////////////////////////
46 // STATS STUFF
47 // ////////////////////////////////////////////////////////////////////////////
48 static PLAYERSTATS playerStats[MAX_PLAYERS];
49
50
51 // ////////////////////////////////////////////////////////////////////////////
52 // Get Player's stats
getMultiStats(UDWORD player)53 PLAYERSTATS const &getMultiStats(UDWORD player)
54 {
55 return playerStats[player];
56 }
57
NETauto(PLAYERSTATS::Autorating & ar)58 static void NETauto(PLAYERSTATS::Autorating &ar)
59 {
60 NETauto(ar.valid);
61 if (ar.valid)
62 {
63 NETauto(ar.dummy);
64 NETauto(ar.star);
65 NETauto(ar.medal);
66 NETauto(ar.level);
67 NETauto(ar.elo);
68 NETauto(ar.autohoster);
69 }
70 }
71
Autorating(nlohmann::json const & json)72 PLAYERSTATS::Autorating::Autorating(nlohmann::json const &json)
73 {
74 try {
75 dummy = json["dummy"].get<bool>();
76 star[0] = json["star"][0].get<uint8_t>();
77 star[1] = json["star"][1].get<uint8_t>();
78 star[2] = json["star"][2].get<uint8_t>();
79 medal = json["medal"].get<uint8_t>();
80 level = json["level"].get<uint8_t>();
81 elo = json["elo"].get<std::string>();
82 autohoster = json["autohoster"].get<bool>();
83 valid = true;
84 } catch (const std::exception &e) {
85 debug(LOG_WARNING, "Error parsing rating JSON: %s", e.what());
86 }
87 }
88
lookupRatingAsync(uint32_t playerIndex)89 void lookupRatingAsync(uint32_t playerIndex)
90 {
91 if (playerStats[playerIndex].identity.empty())
92 {
93 return;
94 }
95
96 auto hash = playerStats[playerIndex].identity.publicHashString();
97 if (hash.empty())
98 {
99 return;
100 }
101
102 std::string url = autoratingUrl(hash);
103 if (url.empty())
104 {
105 return;
106 }
107
108 URLDataRequest req;
109 req.url = url;
110 debug(LOG_INFO, "Requesting \"%s\"", req.url.c_str());
111 req.onSuccess = [playerIndex, hash](std::string const &url, HTTPResponseDetails const &response, std::shared_ptr<MemoryStruct> const &data) {
112 wzAsyncExecOnMainThread([playerIndex, hash, url, response, data] {
113 if (response.httpStatusCode() != 200 || !data || data->size == 0)
114 {
115 debug(LOG_WARNING, "Failed to retrieve data from \"%s\", got [%ld].", url.c_str(), response.httpStatusCode());
116 return;
117 }
118 if (playerStats[playerIndex].identity.publicHashString() != hash)
119 {
120 debug(LOG_WARNING, "Got data from \"%s\", but player is already gone.", url.c_str());
121 return;
122 }
123 try {
124 playerStats[playerIndex].autorating = nlohmann::json::parse(data->memory, data->memory + data->size);
125 if (playerStats[playerIndex].autorating.valid)
126 {
127 setMultiStats(playerIndex, playerStats[playerIndex], false);
128 }
129 }
130 catch (const std::exception &e) {
131 debug(LOG_WARNING, "JSON document from \"%s\" is invalid: %s", url.c_str(), e.what());
132 }
133 catch (...) {
134 debug(LOG_FATAL, "Unexpected exception parsing JSON \"%s\"", url.c_str());
135 }
136 });
137 };
138 req.onFailure = [](std::string const &url, WZ_DECL_UNUSED URLRequestFailureType type, WZ_DECL_UNUSED optional<HTTPResponseDetails> transferDetails) {
139 debug(LOG_WARNING, "Failure fetching \"%s\".", url.c_str());
140 };
141 req.maxDownloadSizeLimit = 4096;
142 urlRequestData(req);
143 }
144
145 // ////////////////////////////////////////////////////////////////////////////
146 // Set Player's stats
147 // send stats to all players when bLocal is false
setMultiStats(uint32_t playerIndex,PLAYERSTATS plStats,bool bLocal)148 bool setMultiStats(uint32_t playerIndex, PLAYERSTATS plStats, bool bLocal)
149 {
150 if (playerIndex >= MAX_PLAYERS)
151 {
152 return true;
153 }
154
155 // First copy over the data into our local array
156 playerStats[playerIndex] = std::move(plStats);
157
158 if (!bLocal)
159 {
160 // Now send it to all other players
161 NETbeginEncode(NETbroadcastQueue(), NET_PLAYER_STATS);
162 // Send the ID of the player's stats we're updating
163 NETuint32_t(&playerIndex);
164
165 NETauto(playerStats[playerIndex].autorating);
166
167 // Send over the actual stats
168 NETuint32_t(&playerStats[playerIndex].played);
169 NETuint32_t(&playerStats[playerIndex].wins);
170 NETuint32_t(&playerStats[playerIndex].losses);
171 NETuint32_t(&playerStats[playerIndex].totalKills);
172 NETuint32_t(&playerStats[playerIndex].totalScore);
173 NETuint32_t(&playerStats[playerIndex].recentKills);
174 NETuint32_t(&playerStats[playerIndex].recentScore);
175
176 EcKey::Key identity;
177 if (!playerStats[playerIndex].identity.empty())
178 {
179 identity = playerStats[playerIndex].identity.toBytes(EcKey::Public);
180 }
181 NETbytes(&identity);
182 NETend();
183 }
184
185 return true;
186 }
187
recvMultiStats(NETQUEUE queue)188 void recvMultiStats(NETQUEUE queue)
189 {
190 uint32_t playerIndex;
191
192 NETbeginDecode(queue, NET_PLAYER_STATS);
193 // Retrieve the ID number of the player for which we need to
194 // update the stats
195 NETuint32_t(&playerIndex);
196
197 if (playerIndex >= MAX_PLAYERS)
198 {
199 NETend();
200 return;
201 }
202
203
204 if (playerIndex != queue.index && queue.index != NET_HOST_ONLY)
205 {
206 HandleBadParam("NET_PLAYER_STATS given incorrect params.", playerIndex, queue.index);
207 NETend();
208 return;
209 }
210
211 NETauto(playerStats[playerIndex].autorating);
212
213 // we don't what to update ourselves, we already know our score (FIXME: rewrite setMultiStats())
214 if (!myResponsibility(playerIndex))
215 {
216 // Retrieve the actual stats
217 NETuint32_t(&playerStats[playerIndex].played);
218 NETuint32_t(&playerStats[playerIndex].wins);
219 NETuint32_t(&playerStats[playerIndex].losses);
220 NETuint32_t(&playerStats[playerIndex].totalKills);
221 NETuint32_t(&playerStats[playerIndex].totalScore);
222 NETuint32_t(&playerStats[playerIndex].recentKills);
223 NETuint32_t(&playerStats[playerIndex].recentScore);
224
225 EcKey::Key identity;
226 NETbytes(&identity);
227 EcKey::Key prevIdentity;
228 if (!playerStats[playerIndex].identity.empty())
229 {
230 prevIdentity = playerStats[playerIndex].identity.toBytes(EcKey::Public);
231 }
232 playerStats[playerIndex].identity.clear();
233 if (!identity.empty())
234 {
235 playerStats[playerIndex].identity.fromBytes(identity, EcKey::Public);
236 }
237 if (identity != prevIdentity)
238 {
239 ingame.PingTimes[playerIndex] = PING_LIMIT;
240 }
241 }
242 NETend();
243
244 if (realSelectedPlayer == 0 && !playerStats[playerIndex].autorating.valid)
245 {
246 lookupRatingAsync(playerIndex);
247 }
248 }
249
250 // ////////////////////////////////////////////////////////////////////////////
251 // Load Player Stats
252
loadMultiStatsFile(const std::string & fileName,PLAYERSTATS * st,bool skipLoadingIdentity=false)253 static bool loadMultiStatsFile(const std::string& fileName, PLAYERSTATS *st, bool skipLoadingIdentity = false)
254 {
255 char *pFileData = nullptr;
256 UDWORD size = 0;
257
258 if (loadFile(fileName.c_str(), &pFileData, &size))
259 {
260 if (strncmp(pFileData, "WZ.STA.v3", 9) != 0)
261 {
262 free(pFileData);
263 pFileData = nullptr;
264 return false; // wrong version or not a stats file
265 }
266
267 char identity[1001];
268 identity[0] = '\0';
269 if (!skipLoadingIdentity)
270 {
271 sscanf(pFileData, "WZ.STA.v3\n%u %u %u %u %u\n%1000[A-Za-z0-9+/=]",
272 &st->wins, &st->losses, &st->totalKills, &st->totalScore, &st->played, identity);
273 }
274 else
275 {
276 sscanf(pFileData, "WZ.STA.v3\n%u %u %u %u %u\n",
277 &st->wins, &st->losses, &st->totalKills, &st->totalScore, &st->played);
278 }
279 free(pFileData);
280 if (identity[0] != '\0')
281 {
282 st->identity.fromBytes(base64Decode(identity), EcKey::Private);
283 }
284 }
285
286 return true;
287 }
288
loadMultiStats(char * sPlayerName,PLAYERSTATS * st)289 bool loadMultiStats(char *sPlayerName, PLAYERSTATS *st)
290 {
291 *st = PLAYERSTATS(); // clear in case we don't get to load
292
293 // Prevent an empty player name (where the first byte is a 0x0 terminating char already)
294 if (!*sPlayerName)
295 {
296 strcpy(sPlayerName, _("Player"));
297 }
298
299 std::string fileName = std::string(MultiPlayersPath) + sPlayerName + ".sta2";
300
301 debug(LOG_WZ, "loadMultiStats: %s", fileName.c_str());
302
303 // check player .sta2 already exists
304 if (PHYSFS_exists(fileName.c_str()))
305 {
306 if (!loadMultiStatsFile(fileName, st))
307 {
308 return false;
309 }
310 }
311 else
312 {
313 // one-time porting of old .sta player files to .sta2
314 fileName = std::string(MultiPlayersPath) + sPlayerName + ".sta";
315 if (PHYSFS_exists(fileName.c_str()))
316 {
317 if (!loadMultiStatsFile(fileName, st, true))
318 {
319 return false;
320 }
321 }
322 }
323
324 if (st->identity.empty())
325 {
326 st->identity = EcKey::generate(); // Generate new identity.
327 saveMultiStats(sPlayerName, sPlayerName, st); // Save new identity.
328 }
329
330 // reset recent scores
331 st->recentKills = 0;
332 st->recentScore = 0;
333
334 // clear any skirmish stats.
335 for (size_t size = 0; size < MAX_PLAYERS; size++)
336 {
337 ingame.skScores[size][0] = 0;
338 ingame.skScores[size][1] = 0;
339 }
340
341 return true;
342 }
343
344 // ////////////////////////////////////////////////////////////////////////////
345 // Save Player Stats
saveMultiStats(const char * sFileName,const char * sPlayerName,const PLAYERSTATS * st)346 bool saveMultiStats(const char *sFileName, const char *sPlayerName, const PLAYERSTATS *st)
347 {
348 if (Cheated)
349 {
350 return false;
351 }
352 char buffer[1000];
353
354 ssprintf(buffer, "WZ.STA.v3\n%u %u %u %u %u\n%s\n",
355 st->wins, st->losses, st->totalKills, st->totalScore, st->played, base64Encode(st->identity.toBytes(EcKey::Private)).c_str());
356
357 std::string fileName = std::string(MultiPlayersPath) + sFileName + ".sta2";
358
359 saveFile(fileName.c_str(), buffer, strlen(buffer));
360
361 return true;
362 }
363
364 // ////////////////////////////////////////////////////////////////////////////
365 // score update functions
366
367 // update players damage stats.
updateMultiStatsDamage(UDWORD attacker,UDWORD defender,UDWORD inflicted)368 void updateMultiStatsDamage(UDWORD attacker, UDWORD defender, UDWORD inflicted)
369 {
370 // damaging features like skyscrapers does not count
371 if (defender != PLAYER_FEATURE)
372 {
373 if (NetPlay.bComms)
374 {
375 // killing and getting killed by scavengers does not influence scores in MP games
376 if (attacker != scavengerSlot() && defender != scavengerSlot())
377 {
378 // FIXME: Why in the world are we using two different structs for stats when we can use only one?
379 playerStats[attacker].totalScore += 2 * inflicted;
380 playerStats[attacker].recentScore += 2 * inflicted;
381 playerStats[defender].totalScore -= inflicted;
382 playerStats[defender].recentScore -= inflicted;
383 }
384 }
385 else
386 {
387 ingame.skScores[attacker][0] += 2 * inflicted; // increment skirmish players rough score.
388 ingame.skScores[defender][0] -= inflicted; // increment skirmish players rough score.
389 }
390 }
391 }
392
393 // update games played.
updateMultiStatsGames()394 void updateMultiStatsGames()
395 {
396 if (selectedPlayer >= MAX_PLAYERS)
397 {
398 return;
399 }
400 ++playerStats[selectedPlayer].played;
401 }
402
403 // games won
updateMultiStatsWins()404 void updateMultiStatsWins()
405 {
406 if (selectedPlayer >= MAX_PLAYERS)
407 {
408 return;
409 }
410 ++playerStats[selectedPlayer].wins;
411 }
412
413 //games lost.
updateMultiStatsLoses()414 void updateMultiStatsLoses()
415 {
416 if (selectedPlayer >= MAX_PLAYERS)
417 {
418 return;
419 }
420 ++playerStats[selectedPlayer].losses;
421 }
422
423 // update kills
updateMultiStatsKills(BASE_OBJECT * psKilled,UDWORD player)424 void updateMultiStatsKills(BASE_OBJECT *psKilled, UDWORD player)
425 {
426 if (player < MAX_PLAYERS)
427 {
428 if (NetPlay.bComms)
429 {
430 // killing scavengers does not count in MP games
431 if (psKilled != nullptr && psKilled->player != scavengerSlot())
432 {
433 // FIXME: Why in the world are we using two different structs for stats when we can use only one?
434 ++playerStats[player].totalKills;
435 ++playerStats[player].recentKills;
436 }
437 }
438 else
439 {
440 ingame.skScores[player][1]++;
441 }
442 }
443 }
444
445 class KnownPlayersDB {
446 public:
447 struct PlayerInfo {
448 int64_t local_id;
449 std::string name;
450 EcKey::Key pk;
451 };
452
453 public:
454 // Caller is expected to handle thrown exceptions
KnownPlayersDB(const std::string & knownPlayersDBPath)455 KnownPlayersDB(const std::string& knownPlayersDBPath)
456 {
457 db = std::unique_ptr<SQLite::Database>(new SQLite::Database(knownPlayersDBPath, SQLite::OPEN_READWRITE | SQLite::OPEN_CREATE));
458 db->exec("PRAGMA journal_mode=WAL");
459 createKnownPlayersDBTables();
460 query_findPlayerByName = std::unique_ptr<SQLite::Statement>(new SQLite::Statement(*db, "SELECT local_id, name, pk FROM known_players WHERE name = ?"));
461 query_insertNewKnownPlayer = std::unique_ptr<SQLite::Statement>(new SQLite::Statement(*db, "INSERT OR IGNORE INTO known_players(name, pk) VALUES(?, ?)"));
462 query_updateKnownPlayerKey = std::unique_ptr<SQLite::Statement>(new SQLite::Statement(*db, "UPDATE known_players SET pk = ? WHERE name = ?"));
463 }
464
465 public:
findPlayerByName(const std::string & name)466 optional<PlayerInfo> findPlayerByName(const std::string& name)
467 {
468 if (name.empty())
469 {
470 return nullopt;
471 }
472 cleanupCache();
473 const auto i = findPlayerCache.find(name);
474 if (i != findPlayerCache.end())
475 {
476 return i->second.first;
477 }
478 optional<PlayerInfo> result;
479 try {
480 query_findPlayerByName->bind(1, name);
481 if (query_findPlayerByName->executeStep())
482 {
483 PlayerInfo data;
484 data.local_id = query_findPlayerByName->getColumn(0).getInt64();
485 data.name = query_findPlayerByName->getColumn(1).getString();
486 std::string publicKeyb64 = query_findPlayerByName->getColumn(2).getString();
487 data.pk = base64Decode(publicKeyb64);
488 result = data;
489 }
490 }
491 catch (const std::exception& e) {
492 debug(LOG_ERROR, "Failure to query database for player; error: %s", e.what());
493 result = nullopt;
494 }
495 try {
496 query_findPlayerByName->reset();
497 }
498 catch (const std::exception& e) {
499 debug(LOG_ERROR, "Failed to reset prepared statement; error: %s", e.what());
500 }
501 // add to the current in-memory cache
502 findPlayerCache[name] = std::pair<optional<PlayerInfo>, UDWORD>(result, realTime);
503 return result;
504 }
505
506 // Note: May throw on database error!
addKnownPlayer(std::string const & name,EcKey const & key,bool overrideCurrentKey)507 void addKnownPlayer(std::string const &name, EcKey const &key, bool overrideCurrentKey)
508 {
509 if (key.empty())
510 {
511 return;
512 }
513
514 std::string publicKeyb64 = base64Encode(key.toBytes(EcKey::Public));
515
516 // Begin transaction
517 SQLite::Transaction transaction(*db);
518
519 query_insertNewKnownPlayer->bind(1, name);
520 query_insertNewKnownPlayer->bind(2, publicKeyb64);
521 if (query_insertNewKnownPlayer->exec() == 0 && overrideCurrentKey)
522 {
523 query_updateKnownPlayerKey->bind(1, publicKeyb64);
524 query_updateKnownPlayerKey->bind(2, name);
525 if (query_updateKnownPlayerKey->exec() == 0)
526 {
527 debug(LOG_WARNING, "Failed to update known_player (%s)", name.c_str());
528 }
529 query_updateKnownPlayerKey->reset();
530 }
531 query_insertNewKnownPlayer->reset();
532
533 // remove from the current in-memory cache
534 findPlayerCache.erase(name);
535
536 // Commit transaction
537 transaction.commit();
538 }
539
540 private:
createKnownPlayersDBTables()541 void createKnownPlayersDBTables()
542 {
543 SQLite::Transaction transaction(*db);
544 if (!db->tableExists("known_players"))
545 {
546 db->exec("CREATE TABLE known_players (local_id INTEGER PRIMARY KEY, name TEXT UNIQUE, pk TEXT)");
547 }
548 transaction.commit();
549 }
550
cleanupCache()551 void cleanupCache()
552 {
553 const UDWORD CACHE_CLEAN_INTERAL = 10 * GAME_TICKS_PER_SEC;
554 if (realTime - lastCacheClean > CACHE_CLEAN_INTERAL)
555 {
556 findPlayerCache.clear();
557 lastCacheClean = realTime;
558 }
559 }
560
561 private:
562 std::unique_ptr<SQLite::Database> db; // Must be the first-listed member variable so it is destructed last
563 std::unique_ptr<SQLite::Statement> query_findPlayerByName;
564 std::unique_ptr<SQLite::Statement> query_insertNewKnownPlayer;
565 std::unique_ptr<SQLite::Statement> query_updateKnownPlayerKey;
566 std::unordered_map<std::string, std::pair<optional<PlayerInfo>, UDWORD>> findPlayerCache;
567 UDWORD lastCacheClean = 0;
568 };
569
570 static std::unique_ptr<KnownPlayersDB> knownPlayersDB;
571
initKnownPlayers()572 void initKnownPlayers()
573 {
574 if (!knownPlayersDB)
575 {
576 const char *pWriteDir = PHYSFS_getWriteDir();
577 ASSERT_OR_RETURN(, pWriteDir, "PHYSFS_getWriteDir returned null");
578 std::string knownPlayersDBPath = std::string(pWriteDir) + "/" + "knownPlayers.db";
579 try {
580 knownPlayersDB = std::unique_ptr<KnownPlayersDB>(new KnownPlayersDB(knownPlayersDBPath));
581 }
582 catch (std::exception& e) {
583 // error loading SQLite database
584 debug(LOG_ERROR, "Unable to load or initialize SQLite3 database (%s); error: %s", knownPlayersDBPath.c_str(), e.what());
585 return;
586 }
587 }
588 }
589
shutdownKnownPlayers()590 void shutdownKnownPlayers()
591 {
592 knownPlayersDB.reset();
593 }
594
isLocallyKnownPlayer(std::string const & name,EcKey const & key)595 bool isLocallyKnownPlayer(std::string const &name, EcKey const &key)
596 {
597 ASSERT_OR_RETURN(false, knownPlayersDB.operator bool(), "knownPlayersDB is uninitialized");
598 if (key.empty())
599 {
600 return false;
601 }
602 auto result = knownPlayersDB->findPlayerByName(name);
603 if (!result.has_value())
604 {
605 return false;
606 }
607 return result.value().pk == key.toBytes(EcKey::Public);
608 }
609
addKnownPlayer(std::string const & name,EcKey const & key,bool override)610 void addKnownPlayer(std::string const &name, EcKey const &key, bool override)
611 {
612 if (key.empty())
613 {
614 return;
615 }
616 ASSERT_OR_RETURN(, knownPlayersDB.operator bool(), "knownPlayersDB is uninitialized");
617
618 try {
619 knownPlayersDB->addKnownPlayer(name, key, override);
620 }
621 catch (const std::exception& e) {
622 debug(LOG_ERROR, "Failed to add known_player with error: %s", e.what());
623 }
624 }
625
getMultiPlayUnitsKilled(uint32_t player)626 uint32_t getMultiPlayUnitsKilled(uint32_t player)
627 {
628 // Let's use the real score for MP games
629 // FIXME: Why in the world are we using two different structs for stats when we can use only one?
630 if (NetPlay.bComms)
631 {
632 return getMultiStats(player).recentKills;
633 }
634 else
635 {
636 // estimated kills
637 return static_cast<uint32_t>(ingame.skScores[player][1]);
638 }
639 }
640
getSelectedPlayerUnitsKilled()641 uint32_t getSelectedPlayerUnitsKilled()
642 {
643 if (ActivityManager::instance().getCurrentGameMode() != ActivitySink::GameMode::CAMPAIGN)
644 {
645 return getMultiPlayUnitsKilled(selectedPlayer);
646 }
647 else
648 {
649 return missionData.unitsKilled;
650 }
651 }
652