From 584d2a517f592270254a3e4583a77ebcf905f341 Mon Sep 17 00:00:00 2001 From: str1k3r <115313679+S1l3ntStr1ke87@users.noreply.github.com> Date: Thu, 18 Jun 2026 05:50:13 -0400 Subject: [PATCH] Feat: Add Leaderboard --- .../Common/UI/UIScene_LeaderboardsMenu.cpp | 8 + .../WindowsLeaderboardManager.cpp | 458 +++++++++++++++++- .../Leaderboards/WindowsLeaderboardManager.h | 63 ++- 3 files changed, 505 insertions(+), 24 deletions(-) diff --git a/Minecraft.Client/Common/UI/UIScene_LeaderboardsMenu.cpp b/Minecraft.Client/Common/UI/UIScene_LeaderboardsMenu.cpp index ad27fa9a..19d2bb2f 100644 --- a/Minecraft.Client/Common/UI/UIScene_LeaderboardsMenu.cpp +++ b/Minecraft.Client/Common/UI/UIScene_LeaderboardsMenu.cpp @@ -424,6 +424,12 @@ void UIScene_LeaderboardsMenu::handleInput(int iPad, int key, bool repeat, bool void UIScene_LeaderboardsMenu::ReadStats(int startIndex) { +#ifdef _WINDOWS64 + // load before so real stats cant get overwritten afterwards + m_labelInfo.setLabel(app.GetString(IDS_LEADERBOARD_LOADING)); + m_labelInfo.setVisible(true); +#endif + //If startIndex == -1, then use default values if( startIndex == -1 ) { @@ -491,9 +497,11 @@ void UIScene_LeaderboardsMenu::ReadStats(int startIndex) break; } +#if defined(_DURANGO) || defined(__ORBIS__) || defined(__PS3__) || defined(__PSVITA__) || defined(_XBOX) //Show the loading message m_labelInfo.setLabel(app.GetString(IDS_LEADERBOARD_LOADING)); m_labelInfo.setVisible(true); +#endif } bool UIScene_LeaderboardsMenu::OnStatsReadComplete(LeaderboardManager::eStatsReturn retIn, int numResults, LeaderboardManager::ViewOut results) diff --git a/Minecraft.Client/Windows64/Leaderboards/WindowsLeaderboardManager.cpp b/Minecraft.Client/Windows64/Leaderboards/WindowsLeaderboardManager.cpp index cc197ce5..1b1e7665 100644 --- a/Minecraft.Client/Windows64/Leaderboards/WindowsLeaderboardManager.cpp +++ b/Minecraft.Client/Windows64/Leaderboards/WindowsLeaderboardManager.cpp @@ -2,4 +2,460 @@ #include "WindowsLeaderboardManager.h" -LeaderboardManager *LeaderboardManager::m_instance = new WindowsLeaderboardManager(); //Singleton instance of the LeaderboardManager \ No newline at end of file +#include +#include + +#include "../Network/json.hpp" +using json = nlohmann::json; + +#include "../../Minecraft.h" +#include "../../StatsCounter.h" +#include "../Windows64_Launcher.h" +#include "../Windows64_Minecraft.h" +#include "../Network/WinsockNetLayer.h" + +#include "../../../Minecraft.World/Stats.h" +#include "../../../Minecraft.World/StringHelpers.h" +#include "../../../Minecraft.World/net.minecraft.world.item.h" +#include "../../../Minecraft.World/net.minecraft.world.level.tile.h" + +wchar_t g_Win64LeaderboardURL[256] = L"leaderboard.mclegacyedition.xyz"; + +namespace +{ + static int ClampDifficulty(int d) + { + if (d < 0) return 0; + if (d > 3) return 3; + return d; + } + + static unsigned long ClampToULong(unsigned long long v) + { + if (v > static_cast(ULONG_MAX)) return ULONG_MAX; + return static_cast(v); + } + + static unsigned int CombinePigmanKills(StatsCounter* stats, unsigned int difficulty) + { + if (!stats) return 0; + return stats->getValue(Stats::killsZombiePigman, difficulty) + stats->getValue(Stats::killsNetherZombiePigman, difficulty); + } + + static PlayerUID HexToUid(const std::string& hex) + { + if (hex.empty()) return INVALID_XUID; + unsigned long long raw = 0; + sscanf_s(hex.c_str(), "%llX", &raw); + return static_cast(raw); + } + + static void ParseServerEntries(const std::string& jsonText, + std::vector& outScores) + { + if (jsonText.empty()) return; + try + { + json arr = json::parse(jsonText); + if (!arr.is_array()) return; + for (auto& entry : arr) + { + LeaderboardManager::ReadScore score = {}; + score.m_rank = 1; + score.m_idsErrorMessage = 0; + + if (entry.contains("uuid") && entry["uuid"].is_string()) + score.m_uid = HexToUid(entry["uuid"].get()); + + if (entry.contains("username") && entry["username"].is_string()) + score.m_name = convStringToWstring(entry["username"].get()); + + if (entry.contains("totalScore") && entry["totalScore"].is_number()) + score.m_totalScore = ClampToULong( + static_cast(entry["totalScore"].get())); + + if (entry.contains("stats") && entry["stats"].is_array()) + { + auto& sa = entry["stats"]; + unsigned int count = static_cast( + (std::min)(sa.size(), LeaderboardManager::ReadScore::STATSDATA_MAX)); + score.m_statsSize = static_cast(count); + for (unsigned int i = 0; i < count; ++i) + if (sa[i].is_number()) + score.m_statsData[i] = static_cast(sa[i].get()); + } + + outScores.push_back(score); + } + } + catch (...) {} + } + + static void AssignRanks(std::vector& scores) + { + for (size_t i = 0; i < scores.size(); ++i) + scores[i].m_rank = static_cast(i + 1); + } + + static bool BuildLocalScore(LeaderboardManager::ReadScore& out, + StatsCounter* stats, + LeaderboardManager::EStatsType type, + unsigned int diff) + { + ZeroMemory(&out, sizeof(out)); + out.m_rank = 1; + out.m_idsErrorMessage = 0; + + unsigned long long total = 0; + auto col = [&](unsigned int idx, unsigned int val) + { + if (idx >= LeaderboardManager::ReadScore::STATSDATA_MAX) return; + out.m_statsData[idx] = val; + total += val; + }; + + switch (type) + { + case LeaderboardManager::eStatsType_Travelling: + out.m_statsSize = 4; + col(0, stats->getValue(Stats::walkOneM, diff)); + col(1, stats->getValue(Stats::fallOneM, diff)); + col(2, stats->getValue(Stats::minecartOneM, diff)); + col(3, stats->getValue(Stats::boatOneM, diff)); + break; + case LeaderboardManager::eStatsType_Mining: + out.m_statsSize = 7; + col(0, stats->getValue(Stats::blocksMined[Tile::dirt_Id], diff)); + col(1, stats->getValue(Stats::blocksMined[Tile::cobblestone_Id], diff)); + col(2, stats->getValue(Stats::blocksMined[Tile::sand_Id], diff)); + col(3, stats->getValue(Stats::blocksMined[Tile::stone_Id], diff)); + col(4, stats->getValue(Stats::blocksMined[Tile::gravel_Id], diff)); + col(5, stats->getValue(Stats::blocksMined[Tile::clay_Id], diff)); + col(6, stats->getValue(Stats::blocksMined[Tile::obsidian_Id], diff)); + break; + case LeaderboardManager::eStatsType_Farming: + out.m_statsSize = 6; + col(0, stats->getValue(Stats::itemsCollected[Item::egg_Id], diff)); + col(1, stats->getValue(Stats::blocksMined[Tile::wheat_Id], diff)); + col(2, stats->getValue(Stats::blocksMined[Tile::mushroom_brown_Id], diff)); + col(3, stats->getValue(Stats::blocksMined[Tile::reeds_Id], diff)); + col(4, stats->getValue(Stats::cowsMilked, diff)); + col(5, stats->getValue(Stats::itemsCollected[Tile::pumpkin_Id], diff)); + break; + case LeaderboardManager::eStatsType_Kills: + out.m_statsSize = 7; + col(0, stats->getValue(Stats::killsZombie, diff)); + col(1, stats->getValue(Stats::killsSkeleton, diff)); + col(2, stats->getValue(Stats::killsCreeper, diff)); + col(3, stats->getValue(Stats::killsSpider, diff)); + col(4, stats->getValue(Stats::killsSpiderJockey, diff)); + col(5, CombinePigmanKills(stats, diff)); + col(6, stats->getValue(Stats::killsSlime, diff)); + break; + default: + return false; + } + + out.m_totalScore = ClampToULong(total); + return true; + } +} + +LeaderboardManager* LeaderboardManager::m_instance = new WindowsLeaderboardManager(); + +bool WindowsLeaderboardManager::WriteStats(unsigned int viewCount, ViewIn views) +{ + if (views == nullptr || viewCount == 0) + return true; + + for (unsigned int i = 0; i < viewCount; ++i) + { + const RegisterScore& src = views[i]; + + int diff = ClampDifficulty(src.m_difficulty); + if (src.m_commentData.m_statsType == eStatsType_Kills && diff == 0) + diff = 1; + + int type = static_cast(src.m_commentData.m_statsType); + if (type < 0 || type >= static_cast(eStatsType_MAX)) continue; + + json statsArr = json::array(); + unsigned long long total = 0; + + switch (src.m_commentData.m_statsType) + { + case eStatsType_Travelling: + statsArr = { src.m_commentData.m_travelling.m_walked, + src.m_commentData.m_travelling.m_fallen, + src.m_commentData.m_travelling.m_minecart, + src.m_commentData.m_travelling.m_boat }; + break; + case eStatsType_Mining: + statsArr = { src.m_commentData.m_mining.m_dirt, + src.m_commentData.m_mining.m_cobblestone, + src.m_commentData.m_mining.m_sand, + src.m_commentData.m_mining.m_stone, + src.m_commentData.m_mining.m_gravel, + src.m_commentData.m_mining.m_clay, + src.m_commentData.m_mining.m_obsidian }; + break; + case eStatsType_Farming: + statsArr = { src.m_commentData.m_farming.m_eggs, + src.m_commentData.m_farming.m_wheat, + src.m_commentData.m_farming.m_mushroom, + src.m_commentData.m_farming.m_sugarcane, + src.m_commentData.m_farming.m_milk, + src.m_commentData.m_farming.m_pumpkin }; + break; + case eStatsType_Kills: + statsArr = { src.m_commentData.m_kills.m_zombie, + src.m_commentData.m_kills.m_skeleton, + src.m_commentData.m_kills.m_creeper, + src.m_commentData.m_kills.m_spider, + src.m_commentData.m_kills.m_spiderJockey, + src.m_commentData.m_kills.m_zombiePigman, + src.m_commentData.m_kills.m_slime }; + break; + default: + continue; + } + + for (auto& v : statsArr) total += v.get(); + + unsigned long long serverTotal = total; + if (src.m_score > 0 && static_cast(src.m_score) > serverTotal) + serverTotal = static_cast(src.m_score); + + json body; + body["type"] = type; + body["difficulty"] = diff; + body["stats"] = statsArr; + body["totalScore"] = serverTotal; + + sendScores(body.dump()); + } + + delete[] views; + return true; +} + +bool WindowsLeaderboardManager::ReadStats_Friends(LeaderboardReadListener* callback, + int difficulty, EStatsType type, PlayerUID myUID, + unsigned int startIndex, unsigned int readCount) +{ + if (!LeaderboardManager::ReadStats_Friends(callback, difficulty, type, startIndex, myUID, readCount)) + return false; + return ReadNetworkStats(callback, difficulty, type, eFM_Friends, startIndex, readCount); +} + +bool WindowsLeaderboardManager::ReadStats_MyScore(LeaderboardReadListener* callback, + int difficulty, EStatsType type, PlayerUID myUID, unsigned int readCount) +{ + if (!LeaderboardManager::ReadStats_MyScore(callback, difficulty, type, myUID, readCount)) + return false; + return ReadNetworkStats(callback, difficulty, type, eFM_MyScore, 1, readCount); +} + +bool WindowsLeaderboardManager::ReadStats_TopRank(LeaderboardReadListener* callback, + int difficulty, EStatsType type, + unsigned int startIndex, unsigned int readCount) +{ + if (!LeaderboardManager::ReadStats_TopRank(callback, difficulty, type, startIndex, readCount)) + return false; + return ReadNetworkStats(callback, difficulty, type, eFM_TopRank, startIndex, readCount); +} + +bool WindowsLeaderboardManager::ReadNetworkStats(LeaderboardReadListener* callback, + int difficulty, EStatsType type, + EFilterMode filterMode, + unsigned int startIndex, unsigned int readCount) +{ + if (!callback) return false; + + unsigned int diff = static_cast(ClampDifficulty(difficulty)); + if (type == eStatsType_Kills && diff == 0) diff = 1; + + // Submit live score only when authenticated. + if (!Windows64Launcher::IsInOfflineMode || Windows64Minecraft::IsOfflineMode) + { + Minecraft* mc = Minecraft::GetInstance(); + const int pad = ProfileManager.GetPrimaryPad(); + if (mc && pad >= 0 && pad < XUSER_MAX_COUNT) + { + StatsCounter* stats = mc->stats[pad]; + if (stats) + { + LeaderboardManager::ReadScore localScore = {}; + if (BuildLocalScore(localScore, stats, type, diff)) + { + json statsArr = json::array(); + for (unsigned int i = 0; i < localScore.m_statsSize; ++i) + statsArr.push_back(localScore.m_statsData[i]); + + json submitBody; + submitBody["type"] = static_cast(type); + submitBody["difficulty"] = static_cast(diff); + submitBody["stats"] = statsArr; + submitBody["totalScore"] = static_cast(localScore.m_totalScore); + + sendScores(submitBody.dump()); + } + } + } + } + + std::vector allRows; + + if (filterMode == eFM_Friends) + { + unsigned int limit = (readCount > 0) ? (std::min)(readCount, 100u) : 64u; + unsigned int offset = (startIndex > 1) ? (startIndex - 1) : 0; + ParseServerEntries(fetchFriends(type, diff, offset, limit), allRows); + } + else if (filterMode == eFM_MyScore) + { + unsigned int limit = (readCount > 0) ? (std::min)(readCount, 100u) : 64u; + unsigned int offset = (startIndex > 1) ? (startIndex - 1) : 0; + ParseServerEntries(fetchMyscore(type, diff, offset, limit), allRows); + } + else + { + unsigned int limit = (readCount > 0) ? (std::min)(readCount, 100u) : 64u; + unsigned int offset = (startIndex > 1) ? (startIndex - 1) : 0; + ParseServerEntries(fetchOverall(type, diff, offset, limit), allRows); + } + + std::sort(allRows.begin(), allRows.end(), + [](const LeaderboardManager::ReadScore& a, const LeaderboardManager::ReadScore& b) + { + if (a.m_totalScore != b.m_totalScore) return a.m_totalScore > b.m_totalScore; + const wchar_t* na = a.m_name.empty() ? L"" : a.m_name.c_str(); + const wchar_t* nb = b.m_name.empty() ? L"" : b.m_name.c_str(); + int cmp = _wcsicmp(na, nb); + if (cmp != 0) return cmp < 0; + return _wcsicmp(na, nb) < 0; + }); + + AssignRanks(allRows); + + if (allRows.empty()) + { + ReadView empty = {}; + callback->OnStatsReadComplete(eStatsReturn_NoResults, 0, empty); + return true; + } + + size_t pageStart = 0; + size_t pageCount = allRows.size(); + + if (filterMode == eFM_TopRank) + { + if (startIndex > 1) + pageStart = (std::min)(static_cast(startIndex - 1), allRows.size()); + pageCount = allRows.size() - pageStart; + if (readCount > 0) + pageCount = (std::min)(pageCount, static_cast(readCount)); + } + + if (pageStart >= allRows.size()) + { + ReadView empty = {}; + callback->OnStatsReadComplete(eStatsReturn_NoResults, 0, empty); + return true; + } + + pageCount = (std::min)(pageCount, allRows.size() - pageStart); + + std::vector page( + allRows.begin() + static_cast(pageStart), + allRows.begin() + static_cast(pageStart + pageCount)); + + if (page.empty()) + { + ReadView empty = {}; + callback->OnStatsReadComplete(eStatsReturn_NoResults, 0, empty); + return true; + } + + ReadView view = {}; + view.m_numQueries = static_cast(page.size()); + view.m_queries = page.data(); + + int totalResults = (filterMode == eFM_MyScore) + ? static_cast(page.size()) + : static_cast(allRows.size()); + + callback->OnStatsReadComplete(eStatsReturn_Success, totalResults, view); + return true; +} + +int WindowsLeaderboardManager::sendScores(const std::string& _data) { + std::vector headers; +#if defined(MINECRAFT_SERVER_BUILD) + std::string authToken = ServerAuth::GetAuthenticationToken(); +#else + std::string authToken; + if (Windows64Minecraft::IsExternalLauncher()) { + authToken = Windows64Minecraft::GetAuthenticationTicket(); + } else { + authToken = Windows64Launcher::GetAuthenticationToken(); + } +#endif + headers.push_back(L"Content-Type: application/json"); + headers.push_back(L"Authorization: " + std::wstring(authToken.begin(), authToken.end())); + + HttpResponse response = WinsockNetLayer::DoWinHttpRequest(g_Win64LeaderboardURL, L"/sendscores", L"POST", _data, headers); + return response.status; +} + +std::string WindowsLeaderboardManager::fetchOverall(int type, int difficulty, int offset, int limit) { + std::vector headers; + std::wstring path = L"/overall?type=" + std::to_wstring(type) + + L"&difficulty=" + std::to_wstring(difficulty) + + L"&offset=" + std::to_wstring(offset) + + L"&limit=" + std::to_wstring(limit); + HttpResponse response = WinsockNetLayer::DoWinHttpRequest(g_Win64LeaderboardURL, path, L"GET", "", headers); + return response.body; +} + +std::string WindowsLeaderboardManager::fetchFriends(int type, int difficulty, int offset, int limit) { + std::vector headers; +#if defined(MINECRAFT_SERVER_BUILD) + std::string authToken = ServerAuth::GetAuthenticationToken(); +#else + std::string authToken; + if (Windows64Minecraft::IsExternalLauncher()) { + authToken = Windows64Minecraft::GetAuthenticationTicket(); + } else { + authToken = Windows64Launcher::GetAuthenticationToken(); + } +#endif + headers.push_back(L"Authorization: " + std::wstring(authToken.begin(), authToken.end())); + std::wstring path = L"/friends?type=" + std::to_wstring(type) + + L"&difficulty=" + std::to_wstring(difficulty) + + L"&offset=" + std::to_wstring(offset) + + L"&limit=" + std::to_wstring(limit); + HttpResponse response = WinsockNetLayer::DoWinHttpRequest(g_Win64LeaderboardURL, path, L"GET", "", headers); + return response.body; +} + +std::string WindowsLeaderboardManager::fetchMyscore(int type, int difficulty, int offset, int limit) { + std::vector headers; +#if defined(MINECRAFT_SERVER_BUILD) + std::string authToken = ServerAuth::GetAuthenticationToken(); +#else + std::string authToken; + if (Windows64Minecraft::IsExternalLauncher()) { + authToken = Windows64Minecraft::GetAuthenticationTicket(); + } else { + authToken = Windows64Launcher::GetAuthenticationToken(); + } +#endif + headers.push_back(L"Authorization: " + std::wstring(authToken.begin(), authToken.end())); + std::wstring path = L"/myscore?type=" + std::to_wstring(type) + + L"&difficulty=" + std::to_wstring(difficulty) + + L"&offset=" + std::to_wstring(offset) + + L"&limit=" + std::to_wstring(limit); + HttpResponse response = WinsockNetLayer::DoWinHttpRequest(g_Win64LeaderboardURL, path, L"GET", "", headers); + return response.body; +} \ No newline at end of file diff --git a/Minecraft.Client/Windows64/Leaderboards/WindowsLeaderboardManager.h b/Minecraft.Client/Windows64/Leaderboards/WindowsLeaderboardManager.h index a66bc9cc..8e21e1ed 100644 --- a/Minecraft.Client/Windows64/Leaderboards/WindowsLeaderboardManager.h +++ b/Minecraft.Client/Windows64/Leaderboards/WindowsLeaderboardManager.h @@ -1,36 +1,53 @@ #pragma once - #include "Common/Leaderboards/LeaderboardManager.h" class WindowsLeaderboardManager : public LeaderboardManager { public: - virtual void Tick() {} + virtual void Tick() override {} + virtual bool OpenSession() override { return true; } + virtual void CloseSession() override {} + virtual void DeleteSession() override {} - //Open a session - virtual bool OpenSession() { return true; } + virtual bool WriteStats(unsigned int viewCount, ViewIn views) override; - //Close a session - virtual void CloseSession() {} + virtual bool ReadStats_Friends( + LeaderboardReadListener* callback, + int difficulty, + EStatsType type, + PlayerUID myUID, + unsigned int startIndex, + unsigned int readCount) override; - //Delete a session - virtual void DeleteSession() {} + virtual bool ReadStats_MyScore( + LeaderboardReadListener* callback, + int difficulty, + EStatsType type, + PlayerUID myUID, + unsigned int readCount) override; - //Write the given stats - //This is called synchronously and will not free any memory allocated for views when it is done + virtual bool ReadStats_TopRank( + LeaderboardReadListener* callback, + int difficulty, + EStatsType type, + unsigned int startIndex, + unsigned int readCount) override; - virtual bool WriteStats(unsigned int viewCount, ViewIn views) { return false; } + virtual void FlushStats() override {} + virtual void CancelOperation() override {} + virtual bool isIdle() override { return true; } - virtual bool ReadStats_Friends(LeaderboardReadListener *callback, int difficulty, EStatsType type, PlayerUID myUID) { return false; } - virtual bool ReadStats_MyScore(LeaderboardReadListener *callback, int difficulty, EStatsType type, PlayerUID myUID, unsigned int readCount) { return false; } - virtual bool ReadStats_TopRank(LeaderboardReadListener *callback, int difficulty, EStatsType type, unsigned int startIndex, unsigned int readCount) { return false; } +private: + bool ReadNetworkStats( + LeaderboardReadListener* callback, + int difficulty, + EStatsType type, + EFilterMode filterMode, + unsigned int startIndex, + unsigned int readCount); - //Perform a flush of the stats - virtual void FlushStats() {} - - //Cancel the current operation - virtual void CancelOperation() {} - - //Is the leaderboard manager idle. - virtual bool isIdle() { return true; } -}; + int sendScores(const std::string& data); + std::string fetchOverall(int type, int difficulty, int offset, int limit); + std::string fetchFriends(int type, int difficulty, int offset, int limit); + std::string fetchMyscore(int type, int difficulty, int offset, int limit); +}; \ No newline at end of file