Repository: RetroAchievements/RAIntegration
Pull request #840 (2021-12-05 02:14:56)

support publishing leaderboards


Patch:
diff --git a/rcheevos b/rcheevos
index 4c85f985..d59bb27b 160000
--- a/rcheevos
+++ b/rcheevos
@@ -1,1 +1,1 @@
-Subproject commit 4c85f985e3a3dfa9f38c52299afc905c9194d17a
+Subproject commit d59bb27be1b71a49210a0477e4b36e6f9c605a2a
diff --git a/src/RA_Integration.vcxproj b/src/RA_Integration.vcxproj
index ee8f9c4e..4bf578a8 100644
--- a/src/RA_Integration.vcxproj
+++ b/src/RA_Integration.vcxproj
@@ -187,6 +187,7 @@
     <ClInclude Include="api\SubmitTicket.hh" />
     <ClInclude Include="api\UpdateAchievement.hh" />
     <ClInclude Include="api\UpdateCodeNote.hh" />
+    <ClInclude Include="api\UpdateLeaderboard.hh" />
     <ClInclude Include="api\UploadBadge.hh" />
     <ClInclude Include="data\AsyncObject.hh" />
     <ClInclude Include="data\context\ConsoleContext.hh" />
diff --git a/src/RA_Integration.vcxproj.filters b/src/RA_Integration.vcxproj.filters
index cf02b798..06a02ad6 100644
--- a/src/RA_Integration.vcxproj.filters
+++ b/src/RA_Integration.vcxproj.filters
@@ -959,6 +959,9 @@
     <ClInclude Include="ui\viewmodels\NewAssetViewModel.hh">
       <Filter>UI\ViewModels</Filter>
     </ClInclude>
+    <ClInclude Include="api\UpdateLeaderboard.hh">
+      <Filter>API</Filter>
+    </ClInclude>
   </ItemGroup>
   <ItemGroup>
     <ResourceCompile Include="RA_Shared.rc">
diff --git a/src/api/ApiCall.cpp b/src/api/ApiCall.cpp
index 0221a691..c9dd6f74 100644
--- a/src/api/ApiCall.cpp
+++ b/src/api/ApiCall.cpp
@@ -29,6 +29,7 @@ UpdateCodeNote::Response UpdateCodeNote::Request::Call() const { return Server()
 DeleteCodeNote::Response DeleteCodeNote::Request::Call() const { return Server().DeleteCodeNote(*this); }
 UpdateAchievement::Response UpdateAchievement::Request::Call() const { return Server().UpdateAchievement(*this); }
 FetchAchievementInfo::Response FetchAchievementInfo::Request::Call() const { return Server().FetchAchievementInfo(*this); }
+UpdateLeaderboard::Response UpdateLeaderboard::Request::Call() const { return Server().UpdateLeaderboard(*this); }
 FetchLeaderboardInfo::Response FetchLeaderboardInfo::Request::Call() const { return Server().FetchLeaderboardInfo(*this); }
 LatestClient::Response LatestClient::Request::Call() const { return Server().LatestClient(*this); }
 FetchGamesList::Response FetchGamesList::Request::Call() const { return Server().FetchGamesList(*this); }
diff --git a/src/api/IServer.hh b/src/api/IServer.hh
index 4fad07d2..e32f3436 100644
--- a/src/api/IServer.hh
+++ b/src/api/IServer.hh
@@ -23,6 +23,7 @@
 #include "api/SubmitTicket.hh"
 #include "api/UpdateAchievement.hh"
 #include "api/UpdateCodeNote.hh"
+#include "api/UpdateLeaderboard.hh"
 #include "api/UploadBadge.hh"
 
 namespace ra {
@@ -51,6 +52,7 @@ public:
     virtual DeleteCodeNote::Response DeleteCodeNote(const DeleteCodeNote::Request& request) = 0;
     virtual UpdateAchievement::Response UpdateAchievement(const UpdateAchievement::Request& request) = 0;
     virtual FetchAchievementInfo::Response FetchAchievementInfo(const FetchAchievementInfo::Request& request) = 0;
+    virtual UpdateLeaderboard::Response UpdateLeaderboard(const UpdateLeaderboard::Request& request) = 0;
     virtual FetchLeaderboardInfo::Response FetchLeaderboardInfo(const FetchLeaderboardInfo::Request& request) = 0;
 
     // === other functions ===
diff --git a/src/api/UpdateLeaderboard.hh b/src/api/UpdateLeaderboard.hh
new file mode 100644
index 00000000..323395ce
--- /dev/null
+++ b/src/api/UpdateLeaderboard.hh
@@ -0,0 +1,49 @@
+#ifndef RA_API_UPDATE_LEADERBOARD_HH
+#define RA_API_UPDATE_LEADERBOARD_HH
+#pragma once
+
+#include "ApiCall.hh"
+
+#include "data\Types.hh"
+
+namespace ra {
+namespace api {
+
+class UpdateLeaderboard
+{
+public:
+    static constexpr const char* const Name() noexcept { return "UpdateLeaderboard"; }
+
+    struct Response : ApiResponseBase
+    {
+        unsigned int LeaderboardId{ 0U };
+    };
+
+    struct Request : ApiRequestBase
+    {
+        unsigned int LeaderboardId{ 0U };
+        unsigned int GameId{ 0U };
+        std::wstring Title;
+        std::wstring Description;
+        std::string StartTrigger;
+        std::string SubmitTrigger;
+        std::string CancelTrigger;
+        std::string ValueDefinition;
+        ra::data::ValueFormat Format{ ra::data::ValueFormat::Value };
+        bool LowerIsBetter{ false };
+
+        using Callback = std::function<void(const Response& response)>;
+
+        Response Call() const;
+
+        void CallAsync(Callback&& callback) const
+        {
+            ApiRequestBase::CallAsync<Request, Callback>(*this, std::move(callback));
+        }
+    };
+};
+
+} // namespace api
+} // namespace ra
+
+#endif // !RA_API_UPDATE_LEADERBOARD_HH
diff --git a/src/api/impl/ConnectedServer.cpp b/src/api/impl/ConnectedServer.cpp
index 4ef49fda..54209029 100644
--- a/src/api/impl/ConnectedServer.cpp
+++ b/src/api/impl/ConnectedServer.cpp
@@ -1095,6 +1095,63 @@ FetchAchievementInfo::Response ConnectedServer::FetchAchievementInfo(const Fetch
     return response;
 }
 
+UpdateLeaderboard::Response ConnectedServer::UpdateLeaderboard(const UpdateLeaderboard::Request& request)
+{
+    UpdateLeaderboard::Response response;
+
+    rc_api_update_leaderboard_request_t api_params;
+    memset(&api_params, 0, sizeof(api_params));
+
+    const auto& pUserContext = ra::services::ServiceLocator::Get<ra::data::context::UserContext>();
+    api_params.username = pUserContext.GetUsername().c_str();
+    api_params.api_token = pUserContext.GetApiToken().c_str();
+
+    const std::string sTitle = ra::Narrow(request.Title);
+    const std::string sDescription = ra::Narrow(request.Description);
+
+    api_params.leaderboard_id = request.LeaderboardId;
+    api_params.game_id = request.GameId;
+    api_params.title = sTitle.c_str();
+    api_params.description = sDescription.c_str();
+    api_params.start_trigger = request.StartTrigger.c_str();
+    api_params.submit_trigger = request.SubmitTrigger.c_str();
+    api_params.cancel_trigger = request.CancelTrigger.c_str();
+    api_params.value_definition = request.ValueDefinition.c_str();
+    api_params.lower_is_better = request.LowerIsBetter ? 1 : 0;
+    
+    switch (request.Format)
+    {
+        case ra::data::ValueFormat::Centiseconds: api_params.format = "MILLISECS"; break;
+        case ra::data::ValueFormat::Frames: api_params.format = "TIME"; break;
+        case ra::data::ValueFormat::Minutes: api_params.format = "MINUTES"; break;
+        case ra::data::ValueFormat::Score: api_params.format = "SCORE"; break;
+        case ra::data::ValueFormat::Seconds: api_params.format = "TIMESECS"; break;
+        default: api_params.format = "VALUE"; break;
+    }
+
+    rc_api_request_t api_request;
+    if (rc_api_init_update_leaderboard_request(&api_request, &api_params) == RC_OK)
+    {
+        ra::services::Http::Response httpResponse;
+        if (DoRequest(api_request, UpdateLeaderboard::Name(), httpResponse, response))
+        {
+            rc_api_update_leaderboard_response_t api_response;
+            const auto nResult = rc_api_process_update_leaderboard_response(&api_response, httpResponse.Content().c_str());
+
+            if (ValidateResponse(nResult, api_response.response, UpdateLeaderboard::Name(), httpResponse.StatusCode(), response))
+            {
+                response.Result = ApiResult::Success;
+                response.LeaderboardId = api_response.leaderboard_id;
+            }
+
+            rc_api_destroy_update_leaderboard_response(&api_response);
+        }
+    }
+
+    rc_api_destroy_request(&api_request);
+    return response;
+}
+
 FetchLeaderboardInfo::Response ConnectedServer::FetchLeaderboardInfo(const FetchLeaderboardInfo::Request& request)
 {
     FetchLeaderboardInfo::Response response;
diff --git a/src/api/impl/ConnectedServer.hh b/src/api/impl/ConnectedServer.hh
index fc09db3f..28b53517 100644
--- a/src/api/impl/ConnectedServer.hh
+++ b/src/api/impl/ConnectedServer.hh
@@ -30,6 +30,7 @@ public:
     DeleteCodeNote::Response DeleteCodeNote(const DeleteCodeNote::Request& request) override;
     UpdateAchievement::Response UpdateAchievement(const UpdateAchievement::Request& request) override;
     FetchAchievementInfo::Response FetchAchievementInfo(const FetchAchievementInfo::Request& request) override;
+    UpdateLeaderboard::Response UpdateLeaderboard(const UpdateLeaderboard::Request& request) override;
     FetchLeaderboardInfo::Response FetchLeaderboardInfo(const FetchLeaderboardInfo::Request& request) override;
     LatestClient::Response LatestClient(const LatestClient::Request& request) override;
     FetchGamesList::Response FetchGamesList(const FetchGamesList::Request& request) override;
diff --git a/src/api/impl/ServerBase.hh b/src/api/impl/ServerBase.hh
index da5586d7..d5ae1cdc 100644
--- a/src/api/impl/ServerBase.hh
+++ b/src/api/impl/ServerBase.hh
@@ -94,6 +94,11 @@ public:
         return UnsupportedApi<FetchAchievementInfo::Response>(FetchAchievementInfo::Name());
     }
 
+    UpdateLeaderboard::Response UpdateLeaderboard(const UpdateLeaderboard::Request&) override
+    {
+        return UnsupportedApi<UpdateLeaderboard::Response>(UpdateLeaderboard::Name());
+    }
+
     FetchLeaderboardInfo::Response FetchLeaderboardInfo(const FetchLeaderboardInfo::Request&) override
     {
         return UnsupportedApi<FetchLeaderboardInfo::Response>(FetchLeaderboardInfo::Name());
diff --git a/src/data/models/LeaderboardModel.cpp b/src/data/models/LeaderboardModel.cpp
index 9ee2f48d..0f14d723 100644
--- a/src/data/models/LeaderboardModel.cpp
+++ b/src/data/models/LeaderboardModel.cpp
@@ -216,8 +216,8 @@ std::string LeaderboardModel::GetDefinition() const
 void LeaderboardModel::Serialize(ra::services::TextWriter& pWriter) const
 {
     WriteQuoted(pWriter, GetLocalAssetDefinition(m_pStartTrigger));
-    WriteQuoted(pWriter, GetLocalAssetDefinition(m_pSubmitTrigger));
     WriteQuoted(pWriter, GetLocalAssetDefinition(m_pCancelTrigger));
+    WriteQuoted(pWriter, GetLocalAssetDefinition(m_pSubmitTrigger));
     WriteQuoted(pWriter, GetLocalAssetDefinition(m_pValueDefinition));
 
     switch (GetValueFormat())
@@ -243,13 +243,13 @@ bool LeaderboardModel::Deserialize(ra::Tokenizer& pTokenizer)
     if (!pTokenizer.Consume(':'))
         return false;
 
-    // field 3: submit trigger
-    std::string sSubmitTrigger = pTokenizer.ReadQuotedString();
+    // field 3: cancel trigger
+    std::string sCancelTrigger = pTokenizer.ReadQuotedString();
     if (!pTokenizer.Consume(':'))
         return false;
 
-    // field 4: cancel trigger
-    std::string sCancelTrigger = pTokenizer.ReadQuotedString();
+    // field 4: submit trigger
+    std::string sSubmitTrigger = pTokenizer.ReadQuotedString();
     if (!pTokenizer.Consume(':'))
         return false;
 
diff --git a/src/data/models/TriggerValidation.cpp b/src/data/models/TriggerValidation.cpp
index 537741d3..39c8e014 100644
--- a/src/data/models/TriggerValidation.cpp
+++ b/src/data/models/TriggerValidation.cpp
@@ -44,6 +44,9 @@ MemSize TriggerValidation::MapRcheevosMemSize(char nSize) noexcept
 
 static bool ValidateLeaderboardCondSet(const rc_condset_t* pCondSet, std::wstring& sError)
 {
+    if (!pCondSet)
+        return true;
+
     const auto* pCondition = pCondSet->conditions;
     for (; pCondition; pCondition = pCondition->next)
     {
diff --git a/src/ui/viewmodels/AssetListViewModel.cpp b/src/ui/viewmodels/AssetListViewModel.cpp
index 678b8be5..ccc6e83c 100644
--- a/src/ui/viewmodels/AssetListViewModel.cpp
+++ b/src/ui/viewmodels/AssetListViewModel.cpp
@@ -1045,6 +1045,15 @@ void AssetListViewModel::SaveSelected()
         if (vSelectedAssets.empty())
             return;
 
+        for (const auto* pAsset : vSelectedAssets)
+        {
+            if (pAsset->GetType() == ra::data::models::AssetType::Leaderboard)
+            {
+                ra::ui::viewmodels::MessageBoxViewModel::ShowWarningMessage(L"Leaderboards cannot be demoted.");
+                return;
+            }
+        }
+
         ra::ui::viewmodels::MessageBoxViewModel vmMessageBox;
         vmMessageBox.SetIcon(ra::ui::viewmodels::MessageBoxViewModel::Icon::Warning);
         vmMessageBox.SetHeader(ra::StringPrintf(L"Are you sure you want to demote %d items to unofficial?", vSelectedAssets.size()));
@@ -1172,8 +1181,6 @@ void AssetListViewModel::ValidateLeaderboardForCore(std::wstring& sError, const
         if (!sValueError.empty())
             sError.append(ra::StringPrintf(L"\n* %s: %s: %s", pLeaderboard.GetName(), L"Value", sValueError));
     }
-
-    sError.append(ra::StringPrintf(L"\n* %s: %s", pLeaderboard.GetName(), L"Leaderboards cannot be published at this time."));
 }
 #else
 void AssetListViewModel::ValidateAchievementForCore(_UNUSED std::wstring& sError, _UNUSED const ra::data::models::AchievementModel& pAchievement) const
diff --git a/src/ui/viewmodels/AssetUploadViewModel.cpp b/src/ui/viewmodels/AssetUploadViewModel.cpp
index 47e11d10..e6f855b7 100644
--- a/src/ui/viewmodels/AssetUploadViewModel.cpp
+++ b/src/ui/viewmodels/AssetUploadViewModel.cpp
@@ -1,6 +1,7 @@
 #include "AssetUploadViewModel.hh"
 
 #include "api\UpdateAchievement.hh"
+#include "api\UpdateLeaderboard.hh"
 #include "api\UploadBadge.hh"
 
 #include "data\context\GameContext.hh"
@@ -27,6 +28,10 @@ void AssetUploadViewModel::QueueAsset(ra::data::models::AssetModelBase& pAsset)
             QueueAchievement(*(dynamic_cast<ra::data::models::AchievementModel*>(&pAsset)));
             break;
 
+        case ra::data::models::AssetType::Leaderboard:
+            QueueLeaderboard(*(dynamic_cast<ra::data::models::LeaderboardModel*>(&pAsset)));
+            break;
+
         default:
             Expects(!"Unsupported asset type");
             break;
@@ -67,6 +72,17 @@ void AssetUploadViewModel::QueueAchievement(ra::data::models::AchievementModel&
     });
 }
 
+void AssetUploadViewModel::QueueLeaderboard(ra::data::models::LeaderboardModel& pLeaderboard)
+{
+    auto& pItem = m_vUploadQueue.emplace_back();
+    pItem.pAsset = &pLeaderboard;
+
+    QueueTask([this, pLeaderboard = &pLeaderboard]()
+    {
+        UploadLeaderboard(*pLeaderboard);
+    });
+}
+
 void AssetUploadViewModel::OnBegin()
 {
     SetMessage(ra::StringPrintf(L"Uploading %d items...", TaskCount()));
@@ -170,6 +186,50 @@ void AssetUploadViewModel::UploadAchievement(ra::data::models::AchievementModel&
     }
 }
 
+void AssetUploadViewModel::UploadLeaderboard(ra::data::models::LeaderboardModel& pLeaderboard)
+{
+    ra::api::UpdateLeaderboard::Request request;
+    request.GameId = ra::services::ServiceLocator::Get<ra::data::context::GameContext>().GameId();
+    request.Title = pLeaderboard.GetName();
+    request.Description = pLeaderboard.GetDescription();
+    request.StartTrigger = pLeaderboard.GetStartTrigger();
+    request.SubmitTrigger = pLeaderboard.GetSubmitTrigger();
+    request.CancelTrigger = pLeaderboard.GetCancelTrigger();
+    request.ValueDefinition = pLeaderboard.GetValueDefinition();
+    request.Format = pLeaderboard.GetValueFormat();
+    request.LowerIsBetter = pLeaderboard.IsLowerBetter();
+
+    if (pLeaderboard.GetCategory() != ra::data::models::AssetCategory::Local)
+        request.LeaderboardId = pLeaderboard.GetID();
+
+    const auto& response = request.Call();
+
+    // update the achievement
+    if (response.Succeeded())
+    {
+        if (pLeaderboard.GetCategory() == ra::data::models::AssetCategory::Local)
+        {
+            pLeaderboard.SetCategory(ra::data::models::AssetCategory::Core);
+            pLeaderboard.SetID(response.LeaderboardId);
+        }
+
+        pLeaderboard.UpdateLocalCheckpoint();
+        pLeaderboard.UpdateServerCheckpoint();
+    }
+
+    // update the queue
+    std::lock_guard<std::mutex> pLock(m_pMutex);
+    for (auto& pScan : m_vUploadQueue)
+    {
+        if (pScan.pAsset == &pLeaderboard)
+        {
+            pScan.sErrorMessage = response.ErrorMessage;
+            pScan.nState = response.Succeeded() ? UploadState::Success : UploadState::Failed;
+            break;
+        }
+    }
+}
+
 void AssetUploadViewModel::ShowResults() const
 {
     int nSuccessful = 0;
diff --git a/src/ui/viewmodels/AssetUploadViewModel.hh b/src/ui/viewmodels/AssetUploadViewModel.hh
index 00bf4f86..6c8027dc 100644
--- a/src/ui/viewmodels/AssetUploadViewModel.hh
+++ b/src/ui/viewmodels/AssetUploadViewModel.hh
@@ -5,6 +5,7 @@
 #include "ProgressViewModel.hh"
 
 #include "data\models\AchievementModel.hh"
+#include "data\models\LeaderboardModel.hh"
 
 namespace ra {
 namespace ui {
@@ -17,6 +18,7 @@ public:
 
     void QueueAsset(ra::data::models::AssetModelBase& pAsset);
     void QueueAchievement(ra::data::models::AchievementModel& pAchievement);
+    void QueueLeaderboard(ra::data::models::LeaderboardModel& pLeaderboard);
 
     void ShowResults() const;
 
@@ -42,6 +44,7 @@ private:
 
     void UploadBadge(const std::wstring& sBadge);
     void UploadAchievement(ra::data::models::AchievementModel& pAchievement);
+    void UploadLeaderboard(ra::data::models::LeaderboardModel& pLeaderboard);
 
     std::vector<UploadItem> m_vUploadQueue;
     std::mutex m_pMutex;


Test patch:
diff --git a/tests/data/context/GameAssets_Tests.cpp b/tests/data/context/GameAssets_Tests.cpp
index 02e98f5e..554d0057 100644
--- a/tests/data/context/GameAssets_Tests.cpp
+++ b/tests/data/context/GameAssets_Tests.cpp
@@ -108,7 +108,7 @@ public:
 
         ra::data::models::LeaderboardModel& AddLeaderboard(AssetCategory nCategory,
             const std::wstring& sTitle, const std::wstring& sDescription, const std::string& sStart,
-            const std::string& sSubmit, const std::string& sCancel, const std::string& sValue, ValueFormat nFormat)
+            const std::string& sCancel, const std::string& sSubmit, const std::string& sValue, ValueFormat nFormat)
         {
             auto& pLeaderboard = NewLeaderboard();
             pLeaderboard.SetCategory(nCategory);
@@ -650,8 +650,8 @@ public:
         Assert::IsNotNull(pAsset2);
         Ensures(pAsset2 != nullptr);
         Assert::AreEqual(std::string("0xH2234=1"), pAsset2->GetStartTrigger());
-        Assert::AreEqual(std::string("0xH2234=2"), pAsset2->GetSubmitTrigger());
-        Assert::AreEqual(std::string("0xH2234=3"), pAsset2->GetCancelTrigger());
+        Assert::AreEqual(std::string("0xH2234=2"), pAsset2->GetCancelTrigger());
+        Assert::AreEqual(std::string("0xH2234=3"), pAsset2->GetSubmitTrigger());
         Assert::AreEqual(std::string("M:0xH2235"), pAsset2->GetValueDefinition());
         Assert::AreEqual(ValueFormat::Minutes, pAsset2->GetValueFormat());
         Assert::AreEqual(AssetCategory::Local, pAsset->GetCategory());
diff --git a/tests/data/models/LeaderboardModel_Tests.cpp b/tests/data/models/LeaderboardModel_Tests.cpp
index d7845fba..21917ea4 100644
--- a/tests/data/models/LeaderboardModel_Tests.cpp
+++ b/tests/data/models/LeaderboardModel_Tests.cpp
@@ -198,7 +198,7 @@ public:
         leaderboard.SetID(1U);
         leaderboard.SetName(L"Title");
         leaderboard.SetDescription(L"Desc");
-        leaderboard.SetDefinition("STA:0xH1234=1::SUB:0xH1234=2::CAN:0xH1234=3::VAL:0xH1234");
+        leaderboard.SetDefinition("STA:0xH1234=1::CAN:0xH1234=2::SUB:0xH1234=3::VAL:0xH1234");
         leaderboard.SetValueFormat(nFormat);
         leaderboard.CreateServerCheckpoint();
         leaderboard.CreateLocalCheckpoint();
diff --git a/tests/mocks/MockServer.hh b/tests/mocks/MockServer.hh
index 3ea1343c..41427806 100644
--- a/tests/mocks/MockServer.hh
+++ b/tests/mocks/MockServer.hh
@@ -134,6 +134,11 @@ public:
         return HandleRequest<ra::api::FetchAchievementInfo>(request);
     }
 
+    UpdateLeaderboard::Response UpdateLeaderboard(const UpdateLeaderboard::Request& request) override
+    {
+        return HandleRequest<ra::api::UpdateLeaderboard>(request);
+    }
+
     FetchLeaderboardInfo::Response FetchLeaderboardInfo(const FetchLeaderboardInfo::Request& request) override
     {
         return HandleRequest<ra::api::FetchLeaderboardInfo>(request);
diff --git a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
index c257d3d6..94b1b4f2 100644
--- a/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetListViewModel_Tests.cpp
@@ -2742,6 +2742,48 @@ public:
         vmAssetList.AssertButtonState(SaveButtonState::SaveAllDisabled);
     }
 
+    TEST_METHOD(TestSaveSelectedDemoteLeaderboardToUnofficial)
+    {
+        AssetListViewModelHarness vmAssetList;
+        vmAssetList.MockGameId(22U);
+        vmAssetList.AddAchievement(AssetCategory::Core, 5, L"Test1", L"Desc1", L"12345", "0xH1234=1");
+        vmAssetList.AddLeaderboard(ra::data::models::AssetCategory::Core, L"Leaderboard1");
+        vmAssetList.SetAssetTypeFilter(ra::data::models::AssetType::None);
+
+        bool bDialogSeen = false;
+        vmAssetList.mockDesktop.ExpectWindow<ra::ui::viewmodels::MessageBoxViewModel>([&bDialogSeen](ra::ui::viewmodels::MessageBoxViewModel& vmMessageBox)
+        {
+            bDialogSeen = true;
+            Assert::AreEqual(std::wstring(L"Leaderboards cannot be demoted."), vmMessageBox.GetMessage());
+            return DialogResult::OK;
+        });
+
+        const auto* pItem1 = vmAssetList.mockGameContext.Assets().GetItemAt(0);
+        Expects(pItem1 != nullptr);
+        Assert::AreEqual(AssetChanges::None, pItem1->GetChanges());
+
+        const auto* pItem2 = vmAssetList.mockGameContext.Assets().GetItemAt(1);
+        Expects(pItem2 != nullptr);
+        Assert::AreEqual(AssetChanges::None, pItem2->GetChanges());
+
+        vmAssetList.FilteredAssets().GetItemAt(0)->SetSelected(true);
+        vmAssetList.FilteredAssets().GetItemAt(1)->SetSelected(true);
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::Demote);
+        vmAssetList.SaveSelected();
+
+        Assert::IsTrue(bDialogSeen);
+
+        Assert::AreEqual(AssetChanges::None, pItem1->GetChanges());
+        Assert::AreEqual(AssetCategory::Core, pItem1->GetCategory());
+        Assert::AreEqual(AssetChanges::None, pItem2->GetChanges());
+        Assert::AreEqual(AssetCategory::Core, pItem2->GetCategory());
+
+        // item will have been moved to core, so nothing will be selected
+        vmAssetList.ForceUpdateButtons();
+        vmAssetList.AssertButtonState(SaveButtonState::Demote);
+    }
+
     TEST_METHOD(TestSaveSelectedPublishLocal)
     {
         AssetListViewModelHarness vmAssetList;
diff --git a/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp b/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp
index aec02b02..d736a1eb 100644
--- a/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp
+++ b/tests/ui/viewmodels/AssetUploadViewModel_Tests.cpp
@@ -4,6 +4,7 @@
 
 #include "data\context\GameAssets.hh"
 #include "data\models\AchievementModel.hh"
+#include "data\models\LeaderboardModel.hh"
 
 #include "tests\data\DataAsserts.hh"
 #include "tests\ui\UIAsserts.hh"
@@ -23,6 +24,7 @@ namespace tests {
 using ra::data::models::AchievementModel;
 using ra::data::models::AssetCategory;
 using ra::data::models::AssetChanges;
+using ra::data::models::LeaderboardModel;
 
 TEST_CLASS(AssetUploadViewModel_Tests)
 {
@@ -99,6 +101,7 @@ private:
             if (nCategory != AssetCategory::Local)
                 vmAchievement->CreateServerCheckpoint();
 
+            // set name after creating the server checkpoint so the model appears Unpublished
             vmAchievement->SetName(sTitle);
 
             vmAchievement->CreateLocalCheckpoint();
@@ -106,6 +109,38 @@ private:
             return dynamic_cast<AchievementModel&>(m_pAssets.Append(std::move(vmAchievement)));
         }
 
+        LeaderboardModel& AddLeaderboard(AssetCategory nCategory, const std::wstring& sTitle,
+            const std::wstring& sDescription, const std::string& sStartTrigger, const std::string& sSubmitTrigger,
+            const std::string& sCancelTrigger, const std::string& sValueDefinition, ra::data::ValueFormat nFormat)
+        {
+            auto vmLeaderboard = std::make_unique<LeaderboardModel>();
+            if (nCategory == AssetCategory::Local)
+            {
+                vmLeaderboard->SetID(gsl::narrow_cast<unsigned>(m_pAssets.Count()) + ra::data::context::GameAssets::FirstLocalId);
+                vmLeaderboard->CreateServerCheckpoint();
+            }
+            else
+            {
+                vmLeaderboard->SetID(gsl::narrow_cast<unsigned>(m_pAssets.Count()) + 1);
+            }
+
+            vmLeaderboard->SetCategory(nCategory);
+            vmLeaderboard->SetName(sTitle);
+            vmLeaderboard->SetDescription(sDescription);
+            vmLeaderboard->SetStartTrigger(sStartTrigger);
+            vmLeaderboard->SetSubmitTrigger(sSubmitTrigger);
+            vmLeaderboard->SetCancelTrigger(sCancelTrigger);
+            vmLeaderboard->SetValueDefinition(sValueDefinition);
+            vmLeaderboard->SetValueFormat(nFormat);
+
+            if (nCategory != AssetCategory::Local)
+                vmLeaderboard->CreateServerCheckpoint();
+
+            vmLeaderboard->CreateLocalCheckpoint();
+
+            return dynamic_cast<LeaderboardModel&>(m_pAssets.Append(std::move(vmLeaderboard)));
+        }
+
         void AssertSuccess(int nItems)
         {
             bool bDialogSeen = false;
@@ -776,6 +811,48 @@ public:
 
         vmUpload.AssertFailed(1, 1, L"* Title2: Timeout");
     }
+
+    TEST_METHOD(TestSingleLocalLeaderboard)
+    {
+        AssetUploadViewModelHarness vmUpload;
+        auto& pLeaderboard = vmUpload.AddLeaderboard(AssetCategory::Local, L"Title1", L"Desc1", "0xH1234=1", "0xH1234=2", "0xH1234=3", "0xH2345", ra::data::ValueFormat::Score);
+        Assert::AreEqual(AssetChanges::Unpublished, pLeaderboard.GetChanges());
+
+        vmUpload.QueueAsset(pLeaderboard);
+        Assert::AreEqual({ 1U }, vmUpload.TaskCount());
+
+        bool bApiCalled = false;
+        vmUpload.mockServer.HandleRequest<ra::api::UpdateLeaderboard>([&bApiCalled]
+        (const ra::api::UpdateLeaderboard::Request& pRequest, ra::api::UpdateLeaderboard::Response& pResponse)
+        {
+            bApiCalled = true;
+            Assert::AreEqual(AssetUploadViewModelHarness::GameId, pRequest.GameId);
+            Assert::AreEqual(std::wstring(L"Title1"), pRequest.Title);
+            Assert::AreEqual(std::wstring(L"Desc1"), pRequest.Description);
+            Assert::AreEqual(std::string("0xH1234=1"), pRequest.StartTrigger);
+            Assert::AreEqual(std::string("0xH1234=2"), pRequest.SubmitTrigger);
+            Assert::AreEqual(std::string("0xH1234=3"), pRequest.CancelTrigger);
+            Assert::AreEqual(std::string("0xH2345"), pRequest.ValueDefinition);
+            Assert::AreEqual(ra::data::ValueFormat::Score, pRequest.Format);
+            Assert::IsFalse(pRequest.LowerIsBetter);
+            Assert::AreEqual(0U, pRequest.LeaderboardId);
+
+            pResponse.LeaderboardId = 7716U;
+            pResponse.Result = ra::api::ApiResult::Success;
+            return true;
+        });
+
+        vmUpload.DoUpload();
+
+        Assert::IsTrue(bApiCalled);
+
+        // published local leaderboard should be changed to core and have it's ID updated
+        Assert::AreEqual(AssetCategory::Core, pLeaderboard.GetCategory());
+        Assert::AreEqual(7716U, pLeaderboard.GetID());
+        Assert::AreEqual(AssetChanges::None, pLeaderboard.GetChanges());
+
+        vmUpload.AssertSuccess(1);
+    }
 };
 
 } // namespace tests