#include "LeaderboardManager.h" #include #include #include #include "Database.h" #include "EntityManager.h" #include "Character.h" #include "Game.h" #include "GameMessages.h" #include "Logger.h" #include "dConfig.h" #include "CDClientManager.h" #include "GeneralUtils.h" #include "Entity.h" #include "LDFFormat.h" #include "DluAssert.h" #include "CDActivitiesTable.h" #include "Metrics.hpp" namespace LeaderboardManager { std::map leaderboardCache; } Leaderboard::Leaderboard(const GameID gameID, const Leaderboard::InfoType infoType, const bool weekly, LWOOBJID relatedPlayer, const Leaderboard::Type leaderboardType) { this->gameID = gameID; this->weekly = weekly; this->infoType = infoType; this->leaderboardType = leaderboardType; this->relatedPlayer = relatedPlayer; } Leaderboard::~Leaderboard() { Clear(); } void Leaderboard::Clear() { for (auto& entry : entries) for (auto ldfData : entry) delete ldfData; } inline void WriteLeaderboardRow(std::ostringstream& leaderboard, const uint32_t& index, LDFBaseData* data) { leaderboard << "\nResult[0].Row[" << index << "]." << data->GetString(); } void Leaderboard::Serialize(RakNet::BitStream& bitStream) const { bitStream.Write(gameID); bitStream.Write(infoType); std::ostringstream leaderboard; leaderboard << "ADO.Result=7:1"; // Unused in 1.10.64, but is in captures leaderboard << "\nResult.Count=1:1"; // number of results, always 1 if (!this->entries.empty()) leaderboard << "\nResult[0].Index=0:RowNumber"; // "Primary key". Live doesn't include this if there are no entries. leaderboard << "\nResult[0].RowCount=1:" << entries.size(); int32_t rowNumber = 0; for (auto& entry : entries) { for (auto* data : entry) { WriteLeaderboardRow(leaderboard, rowNumber, data); } rowNumber++; } // Serialize the thing to a BitStream uint32_t leaderboardSize = leaderboard.tellp(); bitStream.Write(leaderboardSize); // Doing this all in 1 call so there is no possbility of a dangling pointer. bitStream.WriteAlignedBytes(reinterpret_cast(GeneralUtils::ASCIIToUTF16(leaderboard.str()).c_str()), leaderboardSize * sizeof(char16_t)); if (leaderboardSize > 0) bitStream.Write(0); bitStream.Write0(); bitStream.Write0(); } // Takes the resulting query from a leaderboard lookup and converts it to the LDF we need // to send it to a client. void QueryToLdf(Leaderboard& leaderboard, const std::vector& leaderboardEntries) { using enum Leaderboard::Type; leaderboard.Clear(); if (leaderboardEntries.empty()) return; for (const auto& leaderboardEntry : leaderboardEntries) { constexpr int32_t MAX_NUM_DATA_PER_ROW = 9; auto& entry = leaderboard.PushBackEntry(); entry.reserve(MAX_NUM_DATA_PER_ROW); entry.push_back(new LDFData(u"CharacterID", leaderboardEntry.charId)); entry.push_back(new LDFData(u"LastPlayed", leaderboardEntry.lastPlayedTimestamp)); entry.push_back(new LDFData(u"NumPlayed", leaderboardEntry.numTimesPlayed)); entry.push_back(new LDFData(u"name", GeneralUtils::ASCIIToUTF16(leaderboardEntry.name))); entry.push_back(new LDFData(u"RowNumber", leaderboardEntry.ranking)); switch (leaderboard.GetLeaderboardType()) { case ShootingGallery: entry.push_back(new LDFData(u"Score", leaderboardEntry.primaryScore)); // Score:1 entry.push_back(new LDFData(u"Streak", leaderboardEntry.secondaryScore)); // Streak:1 entry.push_back(new LDFData(u"HitPercentage", (leaderboardEntry.tertiaryScore / 100.0f))); // HitPercentage:3 between 0 and 1 break; case Racing: entry.push_back(new LDFData(u"BestTime", leaderboardEntry.primaryScore)); // BestLapTime:3 entry.push_back(new LDFData(u"BestLapTime", leaderboardEntry.secondaryScore)); // BestTime:3 entry.push_back(new LDFData(u"License", 1)); // License:1 - 1 if player has completed mission 637 and 0 otherwise entry.push_back(new LDFData(u"NumWins", leaderboardEntry.numWins)); // NumWins:1 break; case UnusedLeaderboard4: entry.push_back(new LDFData(u"Points", leaderboardEntry.primaryScore)); // Points:1 break; case MonumentRace: entry.push_back(new LDFData(u"Time", leaderboardEntry.primaryScore)); // Time:1(?) break; case FootRace: entry.push_back(new LDFData(u"Time", leaderboardEntry.primaryScore)); // Time:1 break; case Survival: entry.push_back(new LDFData(u"Points", leaderboardEntry.primaryScore)); // Points:1 entry.push_back(new LDFData(u"Time", leaderboardEntry.secondaryScore)); // Time:1 break; case SurvivalNS: entry.push_back(new LDFData(u"Wave", leaderboardEntry.primaryScore)); // Wave:1 entry.push_back(new LDFData(u"Time", leaderboardEntry.secondaryScore)); // Time:1 break; case Donations: entry.push_back(new LDFData(u"Score", leaderboardEntry.primaryScore)); // Score:1 break; case None: [[fallthrough]]; default: break; } } } std::vector FilterTo10(const std::vector& leaderboard, const uint32_t relatedPlayer, const Leaderboard::InfoType infoType) { std::vector toReturn; int32_t index = 0; // for friends and top, we dont need to find this players index. if (infoType == Leaderboard::InfoType::MyStanding || infoType == Leaderboard::InfoType::Friends) { for (; index < leaderboard.size(); index++) { if (leaderboard[index].charId == relatedPlayer) break; } } if (leaderboard.size() < 10) { toReturn.assign(leaderboard.begin(), leaderboard.end()); index = 0; } else if (index < 10) { toReturn.assign(leaderboard.begin(), leaderboard.begin() + 10); // get the top 10 since we are in the top 10 index = 0; } else if (index > leaderboard.size() - 10) { toReturn.assign(leaderboard.end() - 10, leaderboard.end()); // get the bottom 10 since we are in the bottom 10 index = leaderboard.size() - 10; } else { toReturn.assign(leaderboard.begin() + index - 5, leaderboard.begin() + index + 5); // get the 5 above and below index -= 5; } int32_t i = index; for (auto& entry : toReturn) { entry.ranking = ++i; } return toReturn; } std::vector FilterWeeklies(const std::vector& leaderboard) { // Filter the leaderboard to only include entries from the last week const auto currentTime = std::chrono::system_clock::now(); auto epochTime = currentTime.time_since_epoch().count(); constexpr auto SECONDS_IN_A_WEEK = 60 * 60 * 24 * 7; // if you think im taking leap seconds into account thats cute. std::vector weeklyLeaderboard; for (const auto& entry : leaderboard) { if (epochTime - entry.lastPlayedTimestamp < SECONDS_IN_A_WEEK) { weeklyLeaderboard.push_back(entry); } } return weeklyLeaderboard; } std::vector FilterFriends(const std::vector& leaderboard, const uint32_t relatedPlayer) { // Filter the leaderboard to only include friends of the player auto friendOfPlayer = Database::Get()->GetFriendsList(relatedPlayer); std::vector friendsLeaderboard; for (const auto& entry : leaderboard) { const auto res = std::ranges::find_if(friendOfPlayer, [&entry, relatedPlayer](const FriendData& data) { return entry.charId == data.friendID || entry.charId == relatedPlayer; }); if (res != friendOfPlayer.cend()) { friendsLeaderboard.push_back(entry); } } return friendsLeaderboard; } std::vector ProcessLeaderboard( const std::vector& leaderboard, const bool weekly, const Leaderboard::InfoType infoType, const uint32_t relatedPlayer) { std::vector toReturn; if (infoType == Leaderboard::InfoType::Friends) { const auto friendsLeaderboard = FilterFriends(leaderboard, relatedPlayer); toReturn = FilterTo10(weekly ? FilterWeeklies(friendsLeaderboard) : friendsLeaderboard, relatedPlayer, infoType); } else { toReturn = FilterTo10(weekly ? FilterWeeklies(leaderboard) : leaderboard, relatedPlayer, infoType); } return toReturn; } void Leaderboard::SetupLeaderboard(bool weekly) { const auto leaderboardType = LeaderboardManager::GetLeaderboardType(gameID); std::vector leaderboardRes; switch (leaderboardType) { case Type::SurvivalNS: leaderboardRes = Database::Get()->GetNsLeaderboard(gameID); break; case Type::Survival: leaderboardRes = Database::Get()->GetAgsLeaderboard(gameID); break; case Type::Racing: [[fallthrough]]; case Type::MonumentRace: leaderboardRes = Database::Get()->GetAscendingLeaderboard(gameID); break; case Type::ShootingGallery: [[fallthrough]]; case Type::FootRace: [[fallthrough]]; case Type::Donations: [[fallthrough]]; case Type::None: [[fallthrough]]; default: leaderboardRes = Database::Get()->GetDescendingLeaderboard(gameID); break; } const auto processedLeaderboard = ProcessLeaderboard(leaderboardRes, weekly, infoType, relatedPlayer); QueryToLdf(*this, processedLeaderboard); } void Leaderboard::Send(const LWOOBJID targetID) const { auto* player = Game::entityManager->GetEntity(relatedPlayer); if (player != nullptr) { GameMessages::SendActivitySummaryLeaderboardData(targetID, this, player->GetSystemAddress()); } } void LeaderboardManager::SaveScore(const LWOOBJID& playerID, const GameID activityId, const float primaryScore, const float secondaryScore, const float tertiaryScore) { const Leaderboard::Type leaderboardType = GetLeaderboardType(activityId); const auto oldScore = Database::Get()->GetPlayerScore(playerID, activityId); ILeaderboard::Score newScore{ .primaryScore = primaryScore, .secondaryScore = secondaryScore, .tertiaryScore = tertiaryScore }; if (oldScore.has_value()) { bool lowerScoreBetter = leaderboardType == Leaderboard::Type::Racing || leaderboardType == Leaderboard::Type::MonumentRace; bool newHighScore = lowerScoreBetter ? newScore < oldScore : newScore > oldScore; // Nimbus station has a weird leaderboard where we need a custom scoring system if (leaderboardType == Leaderboard::Type::SurvivalNS) { newHighScore = newScore.primaryScore > oldScore->primaryScore || (newScore.primaryScore == oldScore->primaryScore && newScore.secondaryScore < oldScore->secondaryScore); } else if (leaderboardType == Leaderboard::Type::Survival && Game::config->GetValue("classic_survival_scoring") == "1") { ILeaderboard::Score oldScoreFlipped{oldScore->secondaryScore, oldScore->primaryScore, oldScore->tertiaryScore}; ILeaderboard::Score newScoreFlipped{newScore.secondaryScore, newScore.primaryScore, newScore.tertiaryScore}; newHighScore = newScoreFlipped > oldScoreFlipped; } if (newHighScore) { Database::Get()->UpdateScore(playerID, activityId, newScore); } } else { Database::Get()->SaveScore(playerID, activityId, newScore); } // track wins separately if (leaderboardType == Leaderboard::Type::Racing && tertiaryScore != 0.0f) { Database::Get()->IncrementNumWins(playerID, activityId); } } void LeaderboardManager::SendLeaderboard(const GameID gameID, const Leaderboard::InfoType infoType, const bool weekly, const LWOOBJID playerID, const LWOOBJID targetID) { Leaderboard leaderboard(gameID, infoType, weekly, playerID, GetLeaderboardType(gameID)); leaderboard.SetupLeaderboard(weekly); leaderboard.Send(targetID); } Leaderboard::Type LeaderboardManager::GetLeaderboardType(const GameID gameID) { auto lookup = leaderboardCache.find(gameID); if (lookup != leaderboardCache.end()) return lookup->second; auto* activitiesTable = CDClientManager::GetTable(); std::vector activities = activitiesTable->Query([gameID](const CDActivities& entry) { return entry.ActivityID == gameID; }); auto type = !activities.empty() ? static_cast(activities.at(0).leaderboardType) : Leaderboard::Type::None; leaderboardCache.insert_or_assign(gameID, type); return type; }