From f29a26f7e1cba315dec5b73d7e10badc7b2f7f6f Mon Sep 17 00:00:00 2001 From: tintinhamans <5984296+tintinhamans@users.noreply.github.com> Date: Thu, 20 Aug 2026 13:40:40 +0200 Subject: [PATCH] fix(ngmp): correct stats and upload caches --- .../Thread/PersistentStorageThread.cpp | 5 + .../GeneralsOnline/OnlineServices_Init.h | 47 ++-- .../OnlineServices_StatsInterface.h | 38 ++- .../GUI/GUICallbacks/Menus/WOLLobbyMenu.cpp | 2 +- .../GeneralsOnline/OnlineServices_Init.cpp | 81 ++++-- .../OnlineServices_StatsInterface.cpp | 237 ++++++++++++++---- 6 files changed, 305 insertions(+), 105 deletions(-) diff --git a/Core/GameEngine/Source/GameNetwork/GameSpy/Thread/PersistentStorageThread.cpp b/Core/GameEngine/Source/GameNetwork/GameSpy/Thread/PersistentStorageThread.cpp index e182c150b54..3e1b48306a7 100644 --- a/Core/GameEngine/Source/GameNetwork/GameSpy/Thread/PersistentStorageThread.cpp +++ b/Core/GameEngine/Source/GameNetwork/GameSpy/Thread/PersistentStorageThread.cpp @@ -1094,6 +1094,11 @@ PSPlayerStats::PSPlayerStats() void PSPlayerStats::reset() { id = 0; +#if defined(GENERALS_ONLINE) + elo_rating = 0; + monthly_elo_rating = 0; + elo_num_matches = 0; +#endif locale = 0; gamesAsRandom = 0; lastFPS = 0; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h index 7b2e31001f8..7768da00e68 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_Init.h @@ -532,46 +532,39 @@ class NGMP_OnlineServicesManager m_vecCachedScreenshotBytes_MatchStart = vecData; } - void CacheScreenshotBytes_EndMatch(std::vector& vecData) - { - std::scoped_lock ssLock(m_ScreenshotMutex); - m_vecCachedScreenshotBytes_MatchEnd = vecData; - } - - void CacheReplayBytes(std::vector& vecData) - { - std::scoped_lock ssLock(m_ScreenshotMutex); - m_vecCachedReplayBytes = vecData; - } - + void CacheScreenshotBytes_EndMatch(uint64_t matchID, std::vector data); + void CacheReplayBytes(uint64_t matchID, std::vector data); void SetScreenshotS3URI_StartMatch(const char* szURI) { std::scoped_lock ssLock(m_ScreenshotMutex); m_strCachedScreenshot_MatchStart_S3URI = std::string(szURI); } - void SetScreenshotS3URI_EndMatch(const char* szURI) - { - std::scoped_lock ssLock(m_ScreenshotMutex); - m_strCachedScreenshot_MatchEnd_S3URI = std::string(szURI); - } - - void SetScreenshotS3URI_Replay(const char* szURI) - { - std::scoped_lock ssLock(m_ScreenshotMutex); - m_strCacheReplay_S3URI = std::string(szURI); - } + void SetScreenshotS3URI_EndMatch(uint64_t matchID, std::string uri); + void SetScreenshotS3URI_Replay(uint64_t matchID, std::string uri); private: // NOTE: Accessed from multiple threads, dont access directly, use helpers above to lock std::string m_strCachedScreenshot_MatchStart_S3URI; - std::string m_strCachedScreenshot_MatchEnd_S3URI; - std::string m_strCacheReplay_S3URI; // screenshots / replays that require caching std::vector m_vecCachedScreenshotBytes_MatchStart; - std::vector m_vecCachedScreenshotBytes_MatchEnd; - std::vector m_vecCachedReplayBytes; + + struct CachedMatchUpload + { + uint64_t dataMatchID = 0; + uint64_t uriMatchID = 0; + std::vector bytes; + std::string signedURI; + }; + + void CacheMatchUploadBytes(CachedMatchUpload& upload, uint64_t matchID, std::vector data); + void CacheMatchUploadURI(CachedMatchUpload& upload, uint64_t matchID, std::string uri); + + // Data and URLs may arrive independently. Keeping their shared match ID here + // prevents a late response from being paired with media from another match. + CachedMatchUpload m_cachedMatchEndUpload; + CachedMatchUpload m_cachedReplayUpload; // main thread SS Upload static std::mutex m_ScreenshotMutex; diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.h index f543c669961..1fa4ec8770c 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.h +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.h @@ -5,6 +5,10 @@ #include "GameNetwork/RankPointValue.h" #include "GameNetwork/GameSpy/PersistentStorageThread.h" +#include +#include +#include + class PSPlayerStats; struct GlobalStats @@ -468,21 +472,37 @@ class NGMP_OnlineServices_StatsInterface void findPlayerStatsByID(int64_t userID, std::function cb, EStatsRequestPolicy requestPolicy); void findPlayerStatsByBatch(std::vector vecUserIDs, std::function cb); + // Returns the last-known cached values, even when stale. Use + // HasFreshPlayerStats() to decide whether a refresh is required. bool getPlayerStatsFromCache(int64_t userID, PSPlayerStats* outStats); - bool ArePlayerStatsCached(int64_t userID) - { - return m_mapCachedStats.contains(userID); - } + bool HasFreshPlayerStats(int64_t userID); - void UpdateMyStats(PSPlayerStats stats); + void UpdateMyStats(const PSPlayerStats& stats); void CommitMyOutcome(ScoreKeeper* pScoreKeeper, bool bWon); private: - std::string JSONSerialize(PSPlayerStats stats); + struct PlayerStatsCacheEntry + { + PSPlayerStats stats; + std::chrono::steady_clock::time_point lastRefreshAt{}; + std::chrono::steady_clock::time_point lastAccessAt{}; + uint64_t cacheRevision = 0; + uint64_t updateRevision = 0; + bool hasStats = false; + }; + + std::string JSONSerialize(const PSPlayerStats& stats) const; + PlayerStatsCacheEntry& GetOrCreatePlayerStatsCacheEntry(int64_t userID); + uint64_t AdvancePlayerStatsCacheRevision(int64_t userID); + uint64_t AdvancePlayerStatsUpdateRevision(int64_t userID); + bool TryCachePlayerStats(const PSPlayerStats& stats, uint64_t expectedRevision); + void MarkPlayerStatsCacheStale(int64_t userID); private: - std::unordered_map m_mapStatsLastRefresh; - std::unordered_map m_mapCachedStats; - const static int64_t m_cacheTTL = 600000; // 10 minutes + std::unordered_map m_playerStatsCache; + uint64_t m_nextStatsCacheRevision = 0; + uint64_t m_nextStatsUpdateRevision = 0; + static constexpr std::chrono::minutes STATS_CACHE_TTL{ 10 }; + static constexpr std::size_t MAX_PLAYER_STATS_CACHE_ENTRIES = 128; }; diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLLobbyMenu.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLLobbyMenu.cpp index aa2d7be2c20..9af38e4f2f9 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLLobbyMenu.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/GUI/GUICallbacks/Menus/WOLLobbyMenu.cpp @@ -754,7 +754,7 @@ void PopulateLobbyPlayerListbox() { NetworkRoomMember& netRoomMember = kvPair.second; - if (!pStatsInterface->ArePlayerStatsCached(netRoomMember.user_id)) + if (!pStatsInterface->HasFreshPlayerStats(netRoomMember.user_id)) { vecUserStatsToRequest.push_back(netRoomMember.user_id); } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp index c1f140b0c9d..d992a0f615a 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_Init.cpp @@ -15,6 +15,7 @@ #include "WW3D2/surfaceclass.h" #include "WW3D2/dx8wrapper.h" #include +#include #define STB_IMAGE_WRITE_IMPLEMENTATION #define STB_IMAGE_RESIZE_IMPLEMENTATION @@ -118,7 +119,8 @@ void NGMP_OnlineServicesManager::CaptureScreenshotForProbe(EScreenshotType scree NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); if (pLobbyInterface != nullptr) { - NGMP_OnlineServicesManager::GetInstance()->CaptureScreenshot(true, [strURI, screenshotType](std::vector vecData) + const uint64_t matchID = pLobbyInterface->GetCurrentMatchID(); + NGMP_OnlineServicesManager::GetInstance()->CaptureScreenshot(true, [strURI = std::move(strURI), screenshotType, matchID](std::vector vecData) { CHECK_WORKER_THREAD; @@ -135,7 +137,7 @@ void NGMP_OnlineServicesManager::CaptureScreenshotForProbe(EScreenshotType scree } else if (screenshotType == EScreenshotType::SCREENSHOT_TYPE_SCORESCREEN) { - NGMP_OnlineServicesManager::GetInstance()->CacheScreenshotBytes_EndMatch(vecData); + NGMP_OnlineServicesManager::GetInstance()->CacheScreenshotBytes_EndMatch(matchID, std::move(vecData)); } else { @@ -248,6 +250,13 @@ void NGMP_OnlineServicesManager::CommitReplay(AsciiString absoluteReplayPath) if (serviceConf.do_replay_upload) { + NGMP_OnlineServices_LobbyInterface* pLobbyInterface = NGMP_OnlineServicesManager::GetInterface(); + const uint64_t matchID = pLobbyInterface == nullptr ? 0 : pLobbyInterface->GetCurrentMatchID(); + if (matchID == 0) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[MediaUpload] Cannot cache replay: match ID is unavailable"); + return; + } FILE* pFile = fopen(absoluteReplayPath.str(), "rb"); std::vector replayData; @@ -265,11 +274,55 @@ void NGMP_OnlineServicesManager::CommitReplay(AsciiString absoluteReplayPath) } // cache the data until we get an S3 URL from server - NGMP_OnlineServicesManager::GetInstance()->CacheReplayBytes(replayData); + NGMP_OnlineServicesManager::GetInstance()->CacheReplayBytes(matchID, std::move(replayData)); } } } +void NGMP_OnlineServicesManager::CacheMatchUploadBytes(CachedMatchUpload& upload, uint64_t matchID, std::vector data) +{ + if (matchID == 0) + { + return; + } + + std::scoped_lock ssLock(m_ScreenshotMutex); + upload.dataMatchID = matchID; + upload.bytes = std::move(data); +} + +void NGMP_OnlineServicesManager::CacheMatchUploadURI(CachedMatchUpload& upload, uint64_t matchID, std::string uri) +{ + if (matchID == 0) + { + return; + } + + std::scoped_lock ssLock(m_ScreenshotMutex); + upload.uriMatchID = matchID; + upload.signedURI = std::move(uri); +} + +void NGMP_OnlineServicesManager::CacheScreenshotBytes_EndMatch(uint64_t matchID, std::vector data) +{ + CacheMatchUploadBytes(m_cachedMatchEndUpload, matchID, std::move(data)); +} + +void NGMP_OnlineServicesManager::CacheReplayBytes(uint64_t matchID, std::vector data) +{ + CacheMatchUploadBytes(m_cachedReplayUpload, matchID, std::move(data)); +} + +void NGMP_OnlineServicesManager::SetScreenshotS3URI_EndMatch(uint64_t matchID, std::string uri) +{ + CacheMatchUploadURI(m_cachedMatchEndUpload, matchID, std::move(uri)); +} + +void NGMP_OnlineServicesManager::SetScreenshotS3URI_Replay(uint64_t matchID, std::string uri) +{ + CacheMatchUploadURI(m_cachedReplayUpload, matchID, std::move(uri)); +} + void NGMP_OnlineServicesManager::WaitForScreenshotThreads() { std::scoped_lock lock(m_mutexScreenshotThreads); @@ -910,31 +963,30 @@ void NGMP_OnlineServicesManager::Tick() } } - if (!m_vecCachedScreenshotBytes_MatchEnd.empty()) // we have data waiting + if (!m_cachedMatchEndUpload.bytes.empty()) // we have data waiting { - if (!m_strCachedScreenshot_MatchEnd_S3URI.empty()) // and we have a URL + if (m_cachedMatchEndUpload.dataMatchID == m_cachedMatchEndUpload.uriMatchID && !m_cachedMatchEndUpload.signedURI.empty()) // and we have a matching URL { // queue it S3ScreenshotEntry newEntry; newEntry.screenshotType = EScreenshotType::SCREENSHOT_TYPE_SCORESCREEN; - newEntry.vecBytes = m_vecCachedScreenshotBytes_MatchEnd; - newEntry.strSignedURI = m_strCachedScreenshot_MatchEnd_S3URI; - m_vecGuardedSSData.push_back(newEntry); + newEntry.vecBytes = std::move(m_cachedMatchEndUpload.bytes); + newEntry.strSignedURI = std::move(m_cachedMatchEndUpload.signedURI); + m_vecGuardedSSData.push_back(std::move(newEntry)); // clear data - m_vecCachedScreenshotBytes_MatchEnd = std::vector(); - m_strCachedScreenshot_MatchEnd_S3URI = std::string(); + m_cachedMatchEndUpload = {}; } } - if (!m_vecCachedReplayBytes.empty()) // we have data waiting + if (!m_cachedReplayUpload.bytes.empty()) // we have data waiting { - if (!m_strCacheReplay_S3URI.empty()) // and we have a URL + if (m_cachedReplayUpload.dataMatchID == m_cachedReplayUpload.uriMatchID && !m_cachedReplayUpload.signedURI.empty()) // and we have a matching URL { // do the upload std::map mapHeaders; mapHeaders["Content-Type"] = "application/octet-stream"; - NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->SendS3PUTRequest(m_strCacheReplay_S3URI.c_str(), EIPProtocolVersion::DONT_CARE, mapHeaders, m_vecCachedReplayBytes, [=](bool bSuccess, int statusCode, std::string strBody, HTTPRequest* pReq) + NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->SendS3PUTRequest(m_cachedReplayUpload.signedURI.c_str(), EIPProtocolVersion::DONT_CARE, mapHeaders, m_cachedReplayUpload.bytes, [=](bool bSuccess, int statusCode, std::string strBody, HTTPRequest* pReq) { #if _DEBUG if (statusCode != 200) @@ -947,8 +999,7 @@ void NGMP_OnlineServicesManager::Tick() }, nullptr, HTTP_UPLOAD_TIMEOUT); // clear data - NGMP_OnlineServicesManager::GetInstance()->m_vecCachedReplayBytes.clear(); - NGMP_OnlineServicesManager::GetInstance()->m_strCacheReplay_S3URI = std::string(); + m_cachedReplayUpload = {}; } } } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.cpp index 865faf2956e..50c3363ffef 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/OnlineServices_StatsInterface.cpp @@ -9,6 +9,9 @@ #include "Common/PlayerTemplate.h" #include "GameNetwork/GameSpy/LadderDefs.h" +#include +#include + NGMP_OnlineServices_StatsInterface::NGMP_OnlineServices_StatsInterface() { TheRankPointValues = NEW RankPoints; @@ -84,15 +87,9 @@ void NGMP_OnlineServices_StatsInterface::findPlayerStatsByID(int64_t userID, std if (requestPolicy == EStatsRequestPolicy::CACHED_ONLY) { NetworkLog(ELogVerbosity::LOG_DEBUG, "[StatsRequest] Getting stats for user %lld (cache only, not making request due to policy)", userID); - // is it cached? - if (m_mapCachedStats.contains(userID)) - { - cb(true, m_mapCachedStats[userID]); - } - else - { - cb(false, PSPlayerStats()); - } + PSPlayerStats stats; + const bool found = getPlayerStatsFromCache(userID, &stats); + cb(found, stats); } else { @@ -105,39 +102,28 @@ void NGMP_OnlineServices_StatsInterface::findPlayerStatsByID(int64_t userID, std } else if (requestPolicy == EStatsRequestPolicy::RESPECT_CACHE_ALLOW_REQUEST) { - // do we have a cache time? if not, we'll need to retrieve regardless - if (!m_mapStatsLastRefresh.contains(userID)) + if (!HasFreshPlayerStats(userID)) { - NetworkLog(ELogVerbosity::LOG_DEBUG, "[StatsRequest] Getting stats for user %lld (respecting cache, but the user has no cached data)", userID); + NetworkLog(ELogVerbosity::LOG_DEBUG, "[StatsRequest] Getting stats for user %lld (cache is missing or stale)", userID); bDoRequest = true; } - else - { - int64_t currTime = std::chrono::duration_cast(std::chrono::utc_clock::now().time_since_epoch()).count(); - int64_t lastCacheTime = m_mapStatsLastRefresh[userID]; - - if ((currTime - lastCacheTime) >= m_cacheTTL) - { - NetworkLog(ELogVerbosity::LOG_DEBUG, "[StatsRequest] Getting stats for user %lld (respecting cache, but the cache is older then the TTL)", userID); - bDoRequest = true; - } - } } if (bDoRequest) { std::string strURI = std::format("{}/{}", NGMP_OnlineServicesManager::GetAPIEndpoint("PlayerStats"), userID); + const uint64_t cacheRevision = AdvancePlayerStatsCacheRevision(userID); std::map mapHeaders; - NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->SendGETRequest(strURI.c_str(), EIPProtocolVersion::DONT_CARE, mapHeaders, [=](bool bSuccess, int statusCode, std::string strBody, HTTPRequest* pReq) + NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->SendGETRequest(strURI.c_str(), EIPProtocolVersion::DONT_CARE, mapHeaders, [this, userID, cb, cacheRevision](bool bSuccess, int statusCode, std::string strBody, HTTPRequest*) { PSPlayerStats stats; stats.id = userID; try { - if (bSuccess && !strBody.empty()) + if (bSuccess && statusCode >= 200 && statusCode < 300 && !strBody.empty()) { nlohmann::json jsonObject = nlohmann::json::parse(strBody); nlohmann::json jsonObjectRoot = jsonObject["stats"]; @@ -205,11 +191,21 @@ void NGMP_OnlineServices_StatsInterface::findPlayerStatsByID(int64_t userID, std PROCESS_JSON_STANDARD_RESULT(lastLadderPort); PROCESS_JSON_STANDARD_RESULT(lastLadderHost); - NetworkLog(ELogVerbosity::LOG_DEBUG, "Cached stats for user %lld", userID); - m_mapCachedStats[userID] = stats; - m_mapStatsLastRefresh[userID] = std::chrono::duration_cast(std::chrono::utc_clock::now().time_since_epoch()).count(); + if (stats.id != userID) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[StatsRequest] Ignored mismatched stats for user %lld (received %d)", userID, stats.id); + cb(false, PSPlayerStats()); + return; + } + + if (!TryCachePlayerStats(stats, cacheRevision)) + { + PSPlayerStats cachedStats; + const bool found = getPlayerStatsFromCache(userID, &cachedStats); + cb(found, cachedStats); + return; + } - // cb cb(true, stats); } else @@ -232,14 +228,9 @@ void NGMP_OnlineServices_StatsInterface::findPlayerStatsByID(int64_t userID, std } else // cached data instead { - if (m_mapCachedStats.contains(userID)) - { - cb(true, m_mapCachedStats[userID]); - } - else - { - cb(false, PSPlayerStats()); - } + PSPlayerStats stats; + const bool found = getPlayerStatsFromCache(userID, &stats); + cb(found, stats); } } @@ -257,14 +248,25 @@ void NGMP_OnlineServices_StatsInterface::findPlayerStatsByBatch(std::vector mapHeaders; + std::unordered_map cacheRevisions; + std::vector uniqueUserIDs; + uniqueUserIDs.reserve(vecUserIDs.size()); + for (int64_t userID : vecUserIDs) + { + if (!cacheRevisions.contains(userID)) + { + cacheRevisions.emplace(userID, AdvancePlayerStatsCacheRevision(userID)); + uniqueUserIDs.push_back(userID); + } + } nlohmann::json j; - j["user_ids"] = vecUserIDs; + j["user_ids"] = uniqueUserIDs; std::string strPostData = j.dump(); - NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->SendPOSTRequest(strURI.c_str(), EIPProtocolVersion::DONT_CARE, mapHeaders, strPostData.c_str(), [=](bool bSuccess, int statusCode, std::string strBody, HTTPRequest* pReq) + NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->SendPOSTRequest(strURI.c_str(), EIPProtocolVersion::DONT_CARE, mapHeaders, strPostData.c_str(), [this, cb, cacheRevisions](bool bSuccess, int statusCode, std::string strBody, HTTPRequest*) { - if (!bSuccess) + if (!bSuccess || statusCode < 200 || statusCode >= 300) { cb(false); } @@ -274,6 +276,8 @@ void NGMP_OnlineServices_StatsInterface::findPlayerStatsByBatch(std::vector receivedUserIDs; + bool parsedAllResults = true; std::list> missingConnections; for (const auto& statsUserIter : jsonObject["stats"]) @@ -345,21 +349,41 @@ void NGMP_OnlineServices_StatsInterface::findPlayerStatsByBatch(std::vector(std::chrono::utc_clock::now().time_since_epoch()).count(); + auto revisionIt = cacheRevisions.find(stats.id); + if (revisionIt == cacheRevisions.end()) + { + parsedAllResults = false; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[StatsBatch] Ignored unexpected stats for user %d", stats.id); + } + else if (!receivedUserIDs.insert(stats.id).second) + { + parsedAllResults = false; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[StatsBatch] Ignored duplicate stats for user %d", stats.id); + } + else if (!TryCachePlayerStats(stats, revisionIt->second)) + { + parsedAllResults = false; + } } catch (nlohmann::json::exception& jsonException) { + parsedAllResults = false; NetworkLog(ELogVerbosity::LOG_RELEASE, "StatsBatch: Unparsable JSON 1: %s (%s)", strBody.c_str(), jsonException.what()); } catch (...) { + parsedAllResults = false; NetworkLog(ELogVerbosity::LOG_RELEASE, "StatsBatch: Unparsable JSON 2: %s", strBody.c_str()); } } - cb(true); + if (receivedUserIDs.size() != cacheRevisions.size()) + { + parsedAllResults = false; + NetworkLog(ELogVerbosity::LOG_RELEASE, "[StatsBatch] Response omitted %zu requested user(s)", cacheRevisions.size() - receivedUserIDs.size()); + } + + cb(parsedAllResults); } catch (nlohmann::json::exception& jsonException) { @@ -380,10 +404,16 @@ void NGMP_OnlineServices_StatsInterface::findPlayerStatsByBatch(std::vectorsecond.hasStats) { - *outStats = m_mapCachedStats[userID]; + cacheIt->second.lastAccessAt = std::chrono::steady_clock::now(); + *outStats = cacheIt->second.stats; return true; } @@ -391,7 +421,88 @@ bool NGMP_OnlineServices_StatsInterface::getPlayerStatsFromCache(int64_t userID, return false; } -void NGMP_OnlineServices_StatsInterface::UpdateMyStats(PSPlayerStats stats) +bool NGMP_OnlineServices_StatsInterface::HasFreshPlayerStats(int64_t userID) +{ + auto cacheIt = m_playerStatsCache.find(userID); + if (cacheIt == m_playerStatsCache.end() || !cacheIt->second.hasStats + || cacheIt->second.lastRefreshAt == std::chrono::steady_clock::time_point{}) + { + return false; + } + + const auto currentTime = std::chrono::steady_clock::now(); + cacheIt->second.lastAccessAt = currentTime; + return currentTime >= cacheIt->second.lastRefreshAt + && currentTime - cacheIt->second.lastRefreshAt < STATS_CACHE_TTL; +} + +NGMP_OnlineServices_StatsInterface::PlayerStatsCacheEntry& NGMP_OnlineServices_StatsInterface::GetOrCreatePlayerStatsCacheEntry(int64_t userID) +{ + auto cacheIt = m_playerStatsCache.find(userID); + if (cacheIt != m_playerStatsCache.end()) + { + return cacheIt->second; + } + + if (m_playerStatsCache.size() >= MAX_PLAYER_STATS_CACHE_ENTRIES) + { + auto oldestIt = std::min_element(m_playerStatsCache.begin(), m_playerStatsCache.end(), [](const auto& lhs, const auto& rhs) + { + return lhs.second.lastAccessAt < rhs.second.lastAccessAt; + }); + if (oldestIt != m_playerStatsCache.end()) + { + m_playerStatsCache.erase(oldestIt); + } + } + + return m_playerStatsCache.try_emplace(userID).first->second; +} + +uint64_t NGMP_OnlineServices_StatsInterface::AdvancePlayerStatsCacheRevision(int64_t userID) +{ + PlayerStatsCacheEntry& entry = GetOrCreatePlayerStatsCacheEntry(userID); + entry.lastAccessAt = std::chrono::steady_clock::now(); + entry.cacheRevision = ++m_nextStatsCacheRevision; + return entry.cacheRevision; +} + +uint64_t NGMP_OnlineServices_StatsInterface::AdvancePlayerStatsUpdateRevision(int64_t userID) +{ + PlayerStatsCacheEntry& entry = GetOrCreatePlayerStatsCacheEntry(userID); + entry.lastAccessAt = std::chrono::steady_clock::now(); + entry.updateRevision = ++m_nextStatsUpdateRevision; + return entry.updateRevision; +} + +bool NGMP_OnlineServices_StatsInterface::TryCachePlayerStats(const PSPlayerStats& stats, uint64_t expectedRevision) +{ + auto cacheIt = m_playerStatsCache.find(stats.id); + if (cacheIt == m_playerStatsCache.end() || cacheIt->second.cacheRevision != expectedRevision) + { + NetworkLog(ELogVerbosity::LOG_DEBUG, "[StatsCache] Ignored superseded stats for user %d", stats.id); + return false; + } + + PlayerStatsCacheEntry& entry = cacheIt->second; + entry.stats = stats; + entry.lastRefreshAt = std::chrono::steady_clock::now(); + entry.lastAccessAt = entry.lastRefreshAt; + entry.hasStats = true; + NetworkLog(ELogVerbosity::LOG_DEBUG, "[StatsCache] Cached stats for user %d", stats.id); + return true; +} + +void NGMP_OnlineServices_StatsInterface::MarkPlayerStatsCacheStale(int64_t userID) +{ + PlayerStatsCacheEntry& entry = GetOrCreatePlayerStatsCacheEntry(userID); + entry.lastRefreshAt = {}; + entry.lastAccessAt = std::chrono::steady_clock::now(); + entry.cacheRevision = ++m_nextStatsCacheRevision; + NetworkLog(ELogVerbosity::LOG_DEBUG, "[StatsCache] Marked stats stale for user %lld", userID); +} + +void NGMP_OnlineServices_StatsInterface::UpdateMyStats(const PSPlayerStats& stats) { std::string strURI = NGMP_OnlineServicesManager::GetAPIEndpoint("PlayerStats"); @@ -399,10 +510,30 @@ void NGMP_OnlineServices_StatsInterface::UpdateMyStats(PSPlayerStats stats) // TODO_NGMP: Only serialize what exists, dont serialize null? std::string strJsonData = JSONSerialize(stats); + const int statsID = stats.id; + const uint64_t updateRevision = AdvancePlayerStatsUpdateRevision(statsID); - NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->SendPUTRequest(strURI.c_str(), EIPProtocolVersion::DONT_CARE, mapHeaders, strJsonData.c_str(), [=](bool bSuccess, int statusCode, std::string strBody, HTTPRequest* pReq) + NGMP_OnlineServicesManager::GetInstance()->GetHTTPManager()->SendPUTRequest(strURI.c_str(), EIPProtocolVersion::DONT_CARE, mapHeaders, strJsonData.c_str(), [this, statsID, updateRevision](bool bSuccess, int statusCode, std::string, HTTPRequest*) { + auto cacheIt = m_playerStatsCache.find(statsID); + if (cacheIt == m_playerStatsCache.end() || cacheIt->second.updateRevision != updateRevision) + { + NetworkLog(ELogVerbosity::LOG_DEBUG, "[StatsUpdate] Ignored superseded update result for user %d", statsID); + return; + } + if (bSuccess && statusCode >= 200 && statusCode < 300) + { + // The deployed service does not return canonical stats from PUT. + // Keep the last-known values available while refreshing them instead + // of caching the submitted client values. + MarkPlayerStatsCacheStale(statsID); + findPlayerStatsByID(statsID, [](bool, PSPlayerStats) {}, EStatsRequestPolicy::BYPASS_CACHE_FORCE_REQUEST); + } + else + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[StatsUpdate] Failed to upload stats for user %d (HTTP %d)", statsID, statusCode); + } }); } @@ -481,8 +612,8 @@ void NGMP_OnlineServices_StatsInterface::CommitMyOutcome(ScoreKeeper* pScoreKeep nlohmann::json jsonObject = nlohmann::json::parse(strBody); MatchOutcomeResponse matchOutcomeResp = jsonObject.get(); - NGMP_OnlineServicesManager::GetInstance()->SetScreenshotS3URI_EndMatch(matchOutcomeResp.screenshot_url.c_str()); - NGMP_OnlineServicesManager::GetInstance()->SetScreenshotS3URI_Replay(matchOutcomeResp.replay_url.c_str()); + NGMP_OnlineServicesManager::GetInstance()->SetScreenshotS3URI_EndMatch(currentMatchID, matchOutcomeResp.screenshot_url); + NGMP_OnlineServicesManager::GetInstance()->SetScreenshotS3URI_Replay(currentMatchID, matchOutcomeResp.replay_url); } catch (nlohmann::json::exception&) { @@ -497,10 +628,10 @@ void NGMP_OnlineServices_StatsInterface::CommitMyOutcome(ScoreKeeper* pScoreKeep } } -std::string NGMP_OnlineServices_StatsInterface::JSONSerialize(PSPlayerStats stats) +std::string NGMP_OnlineServices_StatsInterface::JSONSerialize(const PSPlayerStats& stats) const { nlohmann::json j; - PerGeneralMap::iterator it; + PerGeneralMap::const_iterator it; #define ITERATE_OVER_GREATER_THAN_ZERO(ENUMVAL, ARR) i = 0; for (it = ARR.begin(); it != ARR.end(); ++it) \ { \