From 61dded7043250ff7d5375002228fb4fb74e15fa1 Mon Sep 17 00:00:00 2001 From: LillyJadeKatrin Date: Sat, 17 Jun 2023 10:56:11 -0400 Subject: [PATCH 1/3] Added Leaderboard info map to AchievementManager The leaderboard map created here contains information useful to displaying leaderboard stats in the Achievement dialog, including each leaderboard's name and description and a partial list of entries for display. The entire map is exposed to the UI in a single call for simplicity. --- Source/Core/Core/AchievementManager.cpp | 7 +++++++ Source/Core/Core/AchievementManager.h | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/Source/Core/Core/AchievementManager.cpp b/Source/Core/Core/AchievementManager.cpp index 1b6ff11d8e..030257228a 100644 --- a/Source/Core/Core/AchievementManager.cpp +++ b/Source/Core/Core/AchievementManager.cpp @@ -712,6 +712,12 @@ AchievementManager::GetAchievementProgress(AchievementId achievement_id, u32* va return ResponseType::SUCCESS; } +const std::unordered_map& +AchievementManager::GetLeaderboardsInfo() const +{ + return m_leaderboard_map; +} + AchievementManager::RichPresence AchievementManager::GetRichPresence() { std::lock_guard lg{m_lock}; @@ -732,6 +738,7 @@ void AchievementManager::CloseGame() m_game_id = 0; m_game_badge.name = ""; m_unlock_map.clear(); + m_leaderboard_map.clear(); rc_api_destroy_fetch_game_data_response(&m_game_data); std::memset(&m_game_data, 0, sizeof(m_game_data)); m_queue.Cancel(); diff --git a/Source/Core/Core/AchievementManager.h b/Source/Core/Core/AchievementManager.h index 2e2e3e938d..d12abf2167 100644 --- a/Source/Core/Core/AchievementManager.h +++ b/Source/Core/Core/AchievementManager.h @@ -54,6 +54,7 @@ public: using AchievementId = u32; static constexpr size_t FORMAT_SIZE = 24; using FormattedValue = std::array; + using LeaderboardRank = u32; static constexpr size_t RP_SIZE = 256; using RichPresence = std::array; using Badge = std::vector; @@ -83,6 +84,21 @@ public: static constexpr std::string_view GOLD = "#FFD700"; static constexpr std::string_view BLUE = "#0B71C1"; + struct LeaderboardEntry + { + std::string username; + FormattedValue score; + LeaderboardRank rank; + }; + + struct LeaderboardStatus + { + std::string name; + std::string description; + u32 player_index = 0; + std::unordered_map entries; + }; + static AchievementManager* GetInstance(); void Init(); void SetUpdateCallback(UpdateCallback callback); @@ -113,6 +129,7 @@ public: const UnlockStatus& GetUnlockStatus(AchievementId achievement_id) const; AchievementManager::ResponseType GetAchievementProgress(AchievementId achievement_id, u32* value, u32* target); + const std::unordered_map& GetLeaderboardsInfo() const; RichPresence GetRichPresence(); void CloseGame(); @@ -165,6 +182,7 @@ private: time_t m_last_ping_time = 0; std::unordered_map m_unlock_map; + std::unordered_map m_leaderboard_map; Common::WorkQueueThread> m_queue; Common::WorkQueueThread> m_image_queue; From 04df930e0d0e3b9cec91664350ae63848cad8e41 Mon Sep 17 00:00:00 2001 From: LillyJadeKatrin Date: Sat, 17 Jun 2023 11:19:16 -0400 Subject: [PATCH 2/3] Added FetchBoardInfo to AchievementManager FetchBoardInfo is called (via the work queue asynchronously) on a leaderboard every time it is activated or submitted to. It makes two calls to the RetroAchievements API for fetching leaderboard info, one that requests the top four entries in the leaderboard and another that requests the player's entry, the two entries above the player and the two entries below. All of these are inserted into a single map (resolving any overlaps) so the result can be exposed to the UI. --- Source/Core/Core/AchievementManager.cpp | 102 +++++++++++++++++++++++- Source/Core/Core/AchievementManager.h | 1 + 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/Source/Core/Core/AchievementManager.cpp b/Source/Core/Core/AchievementManager.cpp index 030257228a..d5acdc84e9 100644 --- a/Source/Core/Core/AchievementManager.cpp +++ b/Source/Core/Core/AchievementManager.cpp @@ -7,6 +7,7 @@ #include +#include #include #include "Common/HttpRequest.h" @@ -268,9 +269,15 @@ void AchievementManager::ActivateDeactivateLeaderboards() for (u32 ix = 0; ix < m_game_data.num_leaderboards; ix++) { auto leaderboard = m_game_data.leaderboards[ix]; + u32 leaderboard_id = leaderboard.id; if (m_is_game_loaded && leaderboards_enabled && hardcore_mode_enabled) { - rc_runtime_activate_lboard(&m_runtime, leaderboard.id, leaderboard.definition, nullptr, 0); + rc_runtime_activate_lboard(&m_runtime, leaderboard_id, leaderboard.definition, nullptr, 0); + m_queue.EmplaceItem([this, leaderboard_id] { + FetchBoardInfo(leaderboard_id); + if (m_update_callback) + m_update_callback(); + }); } else { @@ -712,7 +719,7 @@ AchievementManager::GetAchievementProgress(AchievementId achievement_id, u32* va return ResponseType::SUCCESS; } -const std::unordered_map& +const std::unordered_map& AchievementManager::GetLeaderboardsInfo() const { return m_leaderboard_map; @@ -962,6 +969,90 @@ AchievementManager::ResponseType AchievementManager::FetchUnlockData(bool hardco return r_type; } +AchievementManager::ResponseType AchievementManager::FetchBoardInfo(AchievementId leaderboard_id) +{ + std::string username = Config::Get(Config::RA_USERNAME); + LeaderboardStatus lboard{}; + + { + rc_api_fetch_leaderboard_info_response_t board_info{}; + const rc_api_fetch_leaderboard_info_request_t fetch_board_request = { + .leaderboard_id = leaderboard_id, .count = 4, .first_entry = 1, .username = nullptr}; + const ResponseType r_type = + Request( + fetch_board_request, &board_info, rc_api_init_fetch_leaderboard_info_request, + rc_api_process_fetch_leaderboard_info_response); + if (r_type != ResponseType::SUCCESS) + { + ERROR_LOG_FMT(ACHIEVEMENTS, "Failed to fetch info for leaderboard ID {}.", leaderboard_id); + rc_api_destroy_fetch_leaderboard_info_response(&board_info); + return r_type; + } + lboard.name = board_info.title; + lboard.description = board_info.description; + lboard.entries.clear(); + for (u32 i = 0; i < board_info.num_entries; ++i) + { + const auto& org_entry = board_info.entries[i]; + LeaderboardEntry dest_entry = + LeaderboardEntry{.username = org_entry.username, .rank = org_entry.rank}; + if (rc_runtime_format_lboard_value(dest_entry.score.data(), FORMAT_SIZE, org_entry.score, + board_info.format) == 0) + { + ERROR_LOG_FMT(ACHIEVEMENTS, "Failed to format leaderboard score {}.", org_entry.score); + strncpy(dest_entry.score.data(), fmt::format("{}", org_entry.score).c_str(), FORMAT_SIZE); + } + lboard.entries[org_entry.index] = dest_entry; + } + rc_api_destroy_fetch_leaderboard_info_response(&board_info); + } + + { + // Retrieve, if exists, the player's entry, the two entries above the player, and the two + // entries below the player, for a total of five entries. Technically I only need one entry + // below, but the API is ambiguous what happens if an even number and a username are provided. + rc_api_fetch_leaderboard_info_response_t board_info{}; + const rc_api_fetch_leaderboard_info_request_t fetch_board_request = { + .leaderboard_id = leaderboard_id, + .count = 5, + .first_entry = 0, + .username = username.c_str()}; + const ResponseType r_type = + Request( + fetch_board_request, &board_info, rc_api_init_fetch_leaderboard_info_request, + rc_api_process_fetch_leaderboard_info_response); + if (r_type != ResponseType::SUCCESS) + { + ERROR_LOG_FMT(ACHIEVEMENTS, "Failed to fetch info for leaderboard ID {}.", leaderboard_id); + rc_api_destroy_fetch_leaderboard_info_response(&board_info); + return r_type; + } + for (u32 i = 0; i < board_info.num_entries; ++i) + { + const auto& org_entry = board_info.entries[i]; + LeaderboardEntry dest_entry = + LeaderboardEntry{.username = org_entry.username, .rank = org_entry.rank}; + if (rc_runtime_format_lboard_value(dest_entry.score.data(), FORMAT_SIZE, org_entry.score, + board_info.format) == 0) + { + ERROR_LOG_FMT(ACHIEVEMENTS, "Failed to format leaderboard score {}.", org_entry.score); + strncpy(dest_entry.score.data(), fmt::format("{}", org_entry.score).c_str(), FORMAT_SIZE); + } + lboard.entries[org_entry.index] = dest_entry; + if (org_entry.username == username) + lboard.player_index = org_entry.index; + } + rc_api_destroy_fetch_leaderboard_info_response(&board_info); + } + + { + std::lock_guard lg{m_lock}; + m_leaderboard_map[leaderboard_id] = lboard; + } + + return ResponseType::SUCCESS; +} + void AchievementManager::ActivateDeactivateAchievement(AchievementId id, bool enabled, bool unofficial, bool encore) { @@ -1205,7 +1296,12 @@ void AchievementManager::HandleLeaderboardTriggeredEvent(const rc_runtime_event_ m_game_data.leaderboards[ix].title), OSD::Duration::VERY_LONG, OSD::Color::YELLOW); } - return; + m_queue.EmplaceItem([this, event_id] { + FetchBoardInfo(event_id); + if (m_update_callback) + m_update_callback(); + }); + break; } } ERROR_LOG_FMT(ACHIEVEMENTS, "Invalid leaderboard triggered event with id {}.", event_id); diff --git a/Source/Core/Core/AchievementManager.h b/Source/Core/Core/AchievementManager.h index d12abf2167..b5c1eee200 100644 --- a/Source/Core/Core/AchievementManager.h +++ b/Source/Core/Core/AchievementManager.h @@ -146,6 +146,7 @@ private: ResponseType StartRASession(); ResponseType FetchGameData(); ResponseType FetchUnlockData(bool hardcore); + ResponseType FetchBoardInfo(AchievementId leaderboard_id); void ActivateDeactivateAchievement(AchievementId id, bool enabled, bool unofficial, bool encore); void GenerateRichPresence(); From b824d55093a961efbdcdfd4f330d96306e835070 Mon Sep 17 00:00:00 2001 From: LillyJadeKatrin Date: Sat, 17 Jun 2023 11:47:56 -0400 Subject: [PATCH 3/3] Add Leaderboards tab to Achievement dialog A new tab is added to the Achievements dialog to chart out the leaderboards in a table. Each row of the table contains the leaderboard information and up to four relevant entries, varying based on how many entries are in the leaderboard, whether or not the player has a submitted score, and where in the leaderboard the player's score is. --- .../AchievementLeaderboardWidget.cpp | 127 ++++++++++++++++++ .../AchievementLeaderboardWidget.h | 24 ++++ .../Achievements/AchievementsWindow.cpp | 7 + .../Achievements/AchievementsWindow.h | 2 + Source/Core/DolphinQt/CMakeLists.txt | 2 + Source/Core/DolphinQt/DolphinQt.vcxproj | 2 + 6 files changed, 164 insertions(+) create mode 100644 Source/Core/DolphinQt/Achievements/AchievementLeaderboardWidget.cpp create mode 100644 Source/Core/DolphinQt/Achievements/AchievementLeaderboardWidget.h diff --git a/Source/Core/DolphinQt/Achievements/AchievementLeaderboardWidget.cpp b/Source/Core/DolphinQt/Achievements/AchievementLeaderboardWidget.cpp new file mode 100644 index 0000000000..243bc4453c --- /dev/null +++ b/Source/Core/DolphinQt/Achievements/AchievementLeaderboardWidget.cpp @@ -0,0 +1,127 @@ +// Copyright 2023 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#ifdef USE_RETRO_ACHIEVEMENTS +#include "DolphinQt/Achievements/AchievementLeaderboardWidget.h" + +#include +#include +#include +#include +#include +#include +#include + +#include + +#include +#include +#include + +#include "Common/CommonTypes.h" +#include "Core/AchievementManager.h" +#include "Core/Config/AchievementSettings.h" +#include "Core/Config/MainSettings.h" +#include "Core/Core.h" + +#include "DolphinQt/Config/ControllerInterface/ControllerInterfaceWindow.h" +#include "DolphinQt/QtUtils/ClearLayoutRecursively.h" +#include "DolphinQt/QtUtils/ModalMessageBox.h" +#include "DolphinQt/QtUtils/NonDefaultQPushButton.h" +#include "DolphinQt/QtUtils/SignalBlocking.h" +#include "DolphinQt/Settings.h" + +AchievementLeaderboardWidget::AchievementLeaderboardWidget(QWidget* parent) : QWidget(parent) +{ + m_common_box = new QGroupBox(); + m_common_layout = new QGridLayout(); + + { + std::lock_guard lg{*AchievementManager::GetInstance()->GetLock()}; + UpdateData(); + } + + m_common_box->setLayout(m_common_layout); + + auto* layout = new QVBoxLayout; + layout->setContentsMargins(0, 0, 0, 0); + layout->setAlignment(Qt::AlignTop); + layout->addWidget(m_common_box); + setLayout(layout); +} + +void AchievementLeaderboardWidget::UpdateData() +{ + ClearLayoutRecursively(m_common_layout); + + if (!AchievementManager::GetInstance()->IsGameLoaded()) + return; + const auto& leaderboards = AchievementManager::GetInstance()->GetLeaderboardsInfo(); + int row = 0; + for (const auto& board_row : leaderboards) + { + const AchievementManager::LeaderboardStatus& board = board_row.second; + QLabel* a_title = new QLabel(QString::fromStdString(board.name)); + QLabel* a_description = new QLabel(QString::fromStdString(board.description)); + QVBoxLayout* a_col_left = new QVBoxLayout(); + a_col_left->addWidget(a_title); + a_col_left->addWidget(a_description); + if (row > 0) + { + QFrame* a_divider = new QFrame(); + a_divider->setFrameShape(QFrame::HLine); + m_common_layout->addWidget(a_divider, row - 1, 0); + } + m_common_layout->addLayout(a_col_left, row, 0); + // Each leaderboard entry is displayed with four values. These are *generally* intended to be, + // in order, the first place entry, the entry one above the player, the player's entry, and + // the entry one below the player. + // Edge cases: + // * If there are fewer than four entries in the leaderboard, all entries will be shown in + // order and the remainder of the list will be padded with empty values. + // * If the player does not currently have a score in the leaderboard, or is in the top 3, + // the four slots will be the top four players in order. + // * If the player is last place, the player will be in the fourth slot, and the second and + // third slots will be the two players above them. The first slot will always be first place. + std::array to_display{1, 2, 3, 4}; + if (board.player_index > to_display.size() - 1) + { + // If the rank one below than the player is found, offset = 1. + u32 offset = static_cast(board.entries.count(board.player_index + 1)); + // Example: player is 10th place but not last + // to_display = {1, 10-3+1+1, 10-3+1+2, 10-3+1+3} = {1, 9, 10, 11} + // Example: player is 15th place and is last + // to_display = {1, 15-3+0+1, 15-3+0+2, 15-3+0+3} = {1, 13, 14, 15} + for (size_t i = 1; i < to_display.size(); ++i) + to_display[i] = board.player_index - 3 + offset + static_cast(i); + } + for (size_t i = 0; i < to_display.size(); ++i) + { + u32 index = to_display[i]; + QLabel* a_rank = new QLabel(QStringLiteral("---")); + QLabel* a_username = new QLabel(QStringLiteral("---")); + QLabel* a_score = new QLabel(QStringLiteral("---")); + const auto it = board.entries.find(index); + if (it != board.entries.end()) + { + a_rank->setText(tr("Rank %1").arg(it->second.rank)); + a_username->setText(QString::fromStdString(it->second.username)); + a_score->setText(QString::fromUtf8(it->second.score.data())); + } + QVBoxLayout* a_col = new QVBoxLayout(); + a_col->addWidget(a_rank); + a_col->addWidget(a_username); + a_col->addWidget(a_score); + if (row > 0) + { + QFrame* a_divider = new QFrame(); + a_divider->setFrameShape(QFrame::HLine); + m_common_layout->addWidget(a_divider, row - 1, static_cast(i) + 1); + } + m_common_layout->addLayout(a_col, row, static_cast(i) + 1); + } + row += 2; + } +} + +#endif // USE_RETRO_ACHIEVEMENTS diff --git a/Source/Core/DolphinQt/Achievements/AchievementLeaderboardWidget.h b/Source/Core/DolphinQt/Achievements/AchievementLeaderboardWidget.h new file mode 100644 index 0000000000..055ea6ab3f --- /dev/null +++ b/Source/Core/DolphinQt/Achievements/AchievementLeaderboardWidget.h @@ -0,0 +1,24 @@ +// Copyright 2023 Dolphin Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#ifdef USE_RETRO_ACHIEVEMENTS +#include + +class QGroupBox; +class QGridLayout; + +class AchievementLeaderboardWidget final : public QWidget +{ + Q_OBJECT +public: + explicit AchievementLeaderboardWidget(QWidget* parent); + void UpdateData(); + +private: + QGroupBox* m_common_box; + QGridLayout* m_common_layout; +}; + +#endif // USE_RETRO_ACHIEVEMENTS diff --git a/Source/Core/DolphinQt/Achievements/AchievementsWindow.cpp b/Source/Core/DolphinQt/Achievements/AchievementsWindow.cpp index d376da6109..8d01663c87 100644 --- a/Source/Core/DolphinQt/Achievements/AchievementsWindow.cpp +++ b/Source/Core/DolphinQt/Achievements/AchievementsWindow.cpp @@ -11,6 +11,7 @@ #include #include "DolphinQt/Achievements/AchievementHeaderWidget.h" +#include "DolphinQt/Achievements/AchievementLeaderboardWidget.h" #include "DolphinQt/Achievements/AchievementProgressWidget.h" #include "DolphinQt/Achievements/AchievementSettingsWidget.h" #include "DolphinQt/QtUtils/QueueOnObject.h" @@ -42,10 +43,14 @@ void AchievementsWindow::CreateMainLayout() m_header_widget = new AchievementHeaderWidget(this); m_tab_widget = new QTabWidget(); m_progress_widget = new AchievementProgressWidget(m_tab_widget); + m_leaderboard_widget = new AchievementLeaderboardWidget(m_tab_widget); m_tab_widget->addTab( GetWrappedWidget(new AchievementSettingsWidget(m_tab_widget, this), this, 125, 100), tr("Settings")); m_tab_widget->addTab(GetWrappedWidget(m_progress_widget, this, 125, 100), tr("Progress")); + m_tab_widget->setTabVisible(1, AchievementManager::GetInstance()->IsGameLoaded()); + m_tab_widget->addTab(GetWrappedWidget(m_leaderboard_widget, this, 125, 100), tr("Leaderboards")); + m_tab_widget->setTabVisible(2, AchievementManager::GetInstance()->IsGameLoaded()); m_button_box = new QDialogButtonBox(QDialogButtonBox::Close); @@ -70,6 +75,8 @@ void AchievementsWindow::UpdateData() // Settings tab handles its own updates ... indeed, that calls this m_progress_widget->UpdateData(); m_tab_widget->setTabVisible(1, AchievementManager::GetInstance()->IsGameLoaded()); + m_leaderboard_widget->UpdateData(); + m_tab_widget->setTabVisible(2, AchievementManager::GetInstance()->IsGameLoaded()); } update(); } diff --git a/Source/Core/DolphinQt/Achievements/AchievementsWindow.h b/Source/Core/DolphinQt/Achievements/AchievementsWindow.h index d8407a3a17..6fd7165e1f 100644 --- a/Source/Core/DolphinQt/Achievements/AchievementsWindow.h +++ b/Source/Core/DolphinQt/Achievements/AchievementsWindow.h @@ -10,6 +10,7 @@ #include "DolphinQt/QtUtils/QueueOnObject.h" class AchievementHeaderWidget; +class AchievementLeaderboardWidget; class AchievementProgressWidget; class QDialogButtonBox; class QTabWidget; @@ -30,6 +31,7 @@ private: AchievementHeaderWidget* m_header_widget; QTabWidget* m_tab_widget; AchievementProgressWidget* m_progress_widget; + AchievementLeaderboardWidget* m_leaderboard_widget; QDialogButtonBox* m_button_box; }; diff --git a/Source/Core/DolphinQt/CMakeLists.txt b/Source/Core/DolphinQt/CMakeLists.txt index 87fc14c766..b271fab7fb 100644 --- a/Source/Core/DolphinQt/CMakeLists.txt +++ b/Source/Core/DolphinQt/CMakeLists.txt @@ -30,6 +30,8 @@ add_executable(dolphin-emu CheatsManager.h Achievements/AchievementHeaderWidget.cpp Achievements/AchievementHeaderWidget.h + Achievements/AchievementLeaderboardWidget.cpp + Achievements/AchievementLeaderboardWidget.h Achievements/AchievementProgressWidget.cpp Achievements/AchievementProgressWidget.h Achievements/AchievementSettingsWidget.cpp diff --git a/Source/Core/DolphinQt/DolphinQt.vcxproj b/Source/Core/DolphinQt/DolphinQt.vcxproj index f73ea5c1cc..35f1110e4c 100644 --- a/Source/Core/DolphinQt/DolphinQt.vcxproj +++ b/Source/Core/DolphinQt/DolphinQt.vcxproj @@ -51,6 +51,7 @@ + @@ -261,6 +262,7 @@ +