From f5b3988dd2bd7ccc82437e191592d89650d34a43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 22 Dec 2022 08:01:52 +0100 Subject: [PATCH 01/11] Add data structure for delivering statistics updates --- osu.Game/Online/Solo/SoloStatisticsUpdate.cs | 42 ++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 osu.Game/Online/Solo/SoloStatisticsUpdate.cs diff --git a/osu.Game/Online/Solo/SoloStatisticsUpdate.cs b/osu.Game/Online/Solo/SoloStatisticsUpdate.cs new file mode 100644 index 0000000000..cb9dac97c7 --- /dev/null +++ b/osu.Game/Online/Solo/SoloStatisticsUpdate.cs @@ -0,0 +1,42 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Online.Solo +{ + /// + /// Contains data about the change in a user's profile statistics after completing a score. + /// + public class SoloStatisticsUpdate + { + /// + /// The score set by the user that triggered the update. + /// + public ScoreInfo Score { get; } + + /// + /// The user's profile statistics prior to the score being set. + /// + public UserStatistics Before { get; } + + /// + /// The user's profile statistics after the score was set. + /// + public UserStatistics After { get; } + + /// + /// Creates a new . + /// + /// The score set by the user that triggered the update. + /// The user's profile statistics prior to the score being set. + /// The user's profile statistics after the score was set. + public SoloStatisticsUpdate(ScoreInfo score, UserStatistics before, UserStatistics after) + { + Score = score; + Before = before; + After = after; + } + } +} From ac872fac9e562b4e4cba19ecf5a8bc31ba269f4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 22 Dec 2022 09:04:53 +0100 Subject: [PATCH 02/11] Implement solo statistics watcher --- osu.Game/Online/Solo/SoloStatisticsWatcher.cs | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 osu.Game/Online/Solo/SoloStatisticsWatcher.cs diff --git a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs new file mode 100644 index 0000000000..197ad410a9 --- /dev/null +++ b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs @@ -0,0 +1,140 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Graphics; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Spectator; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Online.Solo +{ + /// + /// A persistent component that binds to the spectator server and API in order to deliver updates about the logged in user's gameplay statistics. + /// + public partial class SoloStatisticsWatcher : Component + { + [Resolved] + private SpectatorClient spectatorClient { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + private readonly Dictionary callbacks = new Dictionary(); + private readonly HashSet scoresWithoutCallback = new HashSet(); + + private readonly Dictionary latestStatistics = new Dictionary(); + + protected override void LoadComplete() + { + base.LoadComplete(); + + api.LocalUser.BindValueChanged(user => onUserChanged(user.NewValue), true); + spectatorClient.OnUserScoreProcessed += userScoreProcessed; + } + + /// + /// Registers for a user statistics update after the given has been processed server-side. + /// + /// The score to listen for the statistics update for. + /// The callback to be invoked once the statistics update has been prepared. + public void RegisterForStatisticsUpdateAfter(ScoreInfo score, Action onUpdateReady) => Schedule(() => + { + if (!api.IsLoggedIn) + return; + + var callback = new StatisticsUpdateCallback(score, onUpdateReady); + + if (scoresWithoutCallback.Remove(score.OnlineID)) + { + requestStatisticsUpdate(api.LocalUser.Value.Id, callback); + return; + } + + callbacks[score.OnlineID] = callback; + }); + + private void onUserChanged(APIUser? localUser) => Schedule(() => + { + callbacks.Clear(); + scoresWithoutCallback.Clear(); + latestStatistics.Clear(); + + if (!api.IsLoggedIn) + return; + + Debug.Assert(localUser != null && localUser.OnlineID > 1); + + var userRequest = new GetUsersRequest(new[] { localUser.OnlineID }); + userRequest.Success += response => Schedule(() => + { + foreach (var rulesetStats in response.Users.Single().RulesetsStatistics) + latestStatistics.Add(rulesetStats.Key, rulesetStats.Value); + }); + api.Queue(userRequest); + }); + + private void userScoreProcessed(int userId, long scoreId) + { + if (userId != api.LocalUser.Value?.OnlineID) + return; + + if (!callbacks.TryGetValue(scoreId, out var callback)) + { + scoresWithoutCallback.Add(scoreId); + return; + } + + requestStatisticsUpdate(userId, callback); + callbacks.Remove(scoreId); + } + + private void requestStatisticsUpdate(int userId, StatisticsUpdateCallback callback) + { + var request = new GetUserRequest(userId, callback.Score.Ruleset); + request.Success += user => Schedule(() => dispatchStatisticsUpdate(callback, user.Statistics)); + api.Queue(request); + } + + private void dispatchStatisticsUpdate(StatisticsUpdateCallback callback, UserStatistics updatedStatistics) + { + string rulesetName = callback.Score.Ruleset.ShortName; + + if (!latestStatistics.TryGetValue(rulesetName, out var latestRulesetStatistics)) + return; + + var update = new SoloStatisticsUpdate(callback.Score, latestRulesetStatistics, updatedStatistics); + callback.OnUpdateReady.Invoke(update); + + latestStatistics[rulesetName] = updatedStatistics; + } + + protected override void Dispose(bool isDisposing) + { + if (spectatorClient.IsNotNull()) + spectatorClient.OnUserScoreProcessed -= userScoreProcessed; + + base.Dispose(isDisposing); + } + + private class StatisticsUpdateCallback + { + public ScoreInfo Score { get; } + public Action OnUpdateReady { get; } + + public StatisticsUpdateCallback(ScoreInfo score, Action onUpdateReady) + { + Score = score; + OnUpdateReady = onUpdateReady; + } + } + } +} From 722cf48614fb696a8f9f8ff01512da826edeac90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 22 Dec 2022 10:14:37 +0100 Subject: [PATCH 03/11] Add test coverage for statistics watcher --- .../Online/TestSceneSoloStatisticsWatcher.cs | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs diff --git a/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs b/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs new file mode 100644 index 0000000000..0797113ca1 --- /dev/null +++ b/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs @@ -0,0 +1,241 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Models; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Solo; +using osu.Game.Online.Spectator; +using osu.Game.Rulesets.Osu; +using osu.Game.Scoring; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Online +{ + [HeadlessTest] + public partial class TestSceneSoloStatisticsWatcher : OsuTestScene + { + protected override bool UseOnlineAPI => false; + + private SoloStatisticsWatcher watcher = null!; + + [Resolved] + private SpectatorClient spectatorClient { get; set; } = null!; + + private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; + + private Action? handleGetUsersRequest; + private Action? handleGetUserRequest; + + [SetUpSteps] + public void SetUpSteps() + { + AddStep("set up request handling", () => + { + handleGetUserRequest = null; + handleGetUsersRequest = null; + + dummyAPI.HandleRequest = request => + { + switch (request) + { + case GetUsersRequest getUsersRequest: + handleGetUsersRequest?.Invoke(getUsersRequest); + return true; + + case GetUserRequest getUserRequest: + handleGetUserRequest?.Invoke(getUserRequest); + return true; + + default: + return false; + } + }; + }); + + AddStep("create watcher", () => + { + Child = watcher = new SoloStatisticsWatcher(); + }); + } + + [Test] + public void TestStatisticsUpdateFiredAfterRegistrationAddedAndScoreProcessed() + { + AddStep("fetch initial stats", () => + { + handleGetUsersRequest = req => req.TriggerSuccess(createInitialUserResponse(1234)); + dummyAPI.LocalUser.Value = new APIUser { Id = 1234 }; + }); + + SoloStatisticsUpdate? update = null; + + AddStep("register for updates", () => watcher.RegisterForStatisticsUpdateAfter( + new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser()) + { + Ruleset = new OsuRuleset().RulesetInfo, + OnlineID = 5678 + }, + receivedUpdate => update = receivedUpdate)); + + AddStep("feign score processing", + () => handleGetUserRequest = + req => req.TriggerSuccess(createIncrementalUserResponse(1234, 5_000_000))); + + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(1234, 5678)); + AddUntilStep("update received", () => update != null); + AddAssert("values before are correct", () => update?.Before.TotalScore, () => Is.EqualTo(4_000_000)); + AddAssert("values after are correct", () => update?.After.TotalScore, () => Is.EqualTo(5_000_000)); + } + + [Test] + public void TestStatisticsUpdateFiredAfterScoreProcessedAndRegistrationAdded() + { + AddStep("fetch initial stats", () => + { + handleGetUsersRequest = req => req.TriggerSuccess(createInitialUserResponse(1235)); + dummyAPI.LocalUser.Value = new APIUser { Id = 1235 }; + }); + + AddStep("feign score processing", + () => handleGetUserRequest = + req => req.TriggerSuccess(createIncrementalUserResponse(1235, 5_000_000))); + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(1235, 5678)); + + SoloStatisticsUpdate? update = null; + + // note ordering - this test checks that even if the registration is late, it will receive data. + AddStep("register for updates", () => watcher.RegisterForStatisticsUpdateAfter( + new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser()) + { + Ruleset = new OsuRuleset().RulesetInfo, + OnlineID = 5678 + }, + receivedUpdate => update = receivedUpdate)); + AddUntilStep("update received", () => update != null); + AddAssert("values before are correct", () => update?.Before.TotalScore, () => Is.EqualTo(4_000_000)); + AddAssert("values after are correct", () => update?.After.TotalScore, () => Is.EqualTo(5_000_000)); + } + + [Test] + public void TestStatisticsUpdateNotFiredIfUserLoggedOut() + { + AddStep("fetch initial stats", () => + { + handleGetUsersRequest = req => req.TriggerSuccess(createInitialUserResponse(1236)); + dummyAPI.LocalUser.Value = new APIUser { Id = 1236 }; + }); + + SoloStatisticsUpdate? update = null; + + AddStep("register for updates", () => watcher.RegisterForStatisticsUpdateAfter( + new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser()) + { + Ruleset = new OsuRuleset().RulesetInfo, + OnlineID = 5678 + }, + receivedUpdate => update = receivedUpdate)); + + AddStep("feign score processing", + () => handleGetUserRequest = + req => req.TriggerSuccess(createIncrementalUserResponse(1236, 5_000_000))); + + AddStep("log out user", () => dummyAPI.Logout()); + + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(1236, 5678)); + AddWaitStep("wait a bit", 5); + AddAssert("update not received", () => update == null); + } + + [Test] + public void TestStatisticsUpdateNotFiredIfAnotherUserLoggedIn() + { + AddStep("fetch initial stats", () => + { + handleGetUsersRequest = req => req.TriggerSuccess(createInitialUserResponse(1237)); + dummyAPI.LocalUser.Value = new APIUser { Id = 1237 }; + }); + + SoloStatisticsUpdate? update = null; + + AddStep("register for updates", () => watcher.RegisterForStatisticsUpdateAfter( + new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser()) + { + Ruleset = new OsuRuleset().RulesetInfo, + OnlineID = 5678 + }, + receivedUpdate => update = receivedUpdate)); + + AddStep("feign score processing", + () => handleGetUserRequest = + req => req.TriggerSuccess(createIncrementalUserResponse(1237, 5_000_000))); + + AddStep("log out user", () => dummyAPI.LocalUser.Value = new APIUser { Id = 5555 }); + + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(1237, 5678)); + AddWaitStep("wait a bit", 5); + AddAssert("update not received", () => update == null); + } + + [Test] + public void TestStatisticsUpdateNotFiredIfScoreIdDoesNotMatch() + { + AddStep("fetch initial stats", () => + { + handleGetUsersRequest = req => req.TriggerSuccess(createInitialUserResponse(1238)); + dummyAPI.LocalUser.Value = new APIUser { Id = 1238 }; + }); + + SoloStatisticsUpdate? update = null; + + AddStep("register for updates", () => watcher.RegisterForStatisticsUpdateAfter( + new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser()) + { + Ruleset = new OsuRuleset().RulesetInfo, + OnlineID = 5678 + }, + receivedUpdate => update = receivedUpdate)); + + AddStep("feign score processing", + () => handleGetUserRequest = + req => req.TriggerSuccess(createIncrementalUserResponse(1238, 5_000_000))); + + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(1238, 9012)); + AddWaitStep("wait a bit", 5); + AddAssert("update not received", () => update == null); + } + + private GetUsersResponse createInitialUserResponse(int userId) => new GetUsersResponse + { + Users = new List + { + new APIUser + { + Id = userId, + RulesetsStatistics = new Dictionary + { + ["osu"] = new UserStatistics { TotalScore = 4_000_000 }, + ["taiko"] = new UserStatistics { TotalScore = 3_000_000 }, + ["fruits"] = new UserStatistics { TotalScore = 2_000_000 }, + ["mania"] = new UserStatistics { TotalScore = 1_000_000 } + } + } + } + }; + + private APIUser createIncrementalUserResponse(int userId, long totalScore) => new APIUser + { + Id = userId, + Statistics = new UserStatistics + { + TotalScore = totalScore + } + }; + } +} From 48dc2332fd5bc60d977897e3a4d6477dc8b0deec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 22 Dec 2022 11:10:33 +0100 Subject: [PATCH 04/11] Refactor test to be easier to work with --- .../Online/TestSceneSoloStatisticsWatcher.cs | 241 +++++++++--------- .../Online/API/Requests/GetUsersRequest.cs | 6 +- osu.Game/Online/Solo/SoloStatisticsWatcher.cs | 2 +- 3 files changed, 130 insertions(+), 119 deletions(-) diff --git a/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs b/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs index 0797113ca1..008d54be63 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Testing; @@ -12,6 +13,7 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Solo; using osu.Game.Online.Spectator; +using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Scoring; using osu.Game.Users; @@ -33,9 +35,12 @@ namespace osu.Game.Tests.Visual.Online private Action? handleGetUsersRequest; private Action? handleGetUserRequest; + private readonly Dictionary<(int userId, string rulesetName), UserStatistics> serverSideStatistics = new Dictionary<(int userId, string rulesetName), UserStatistics>(); + [SetUpSteps] public void SetUpSteps() { + AddStep("clear server-side stats", () => serverSideStatistics.Clear()); AddStep("set up request handling", () => { handleGetUserRequest = null; @@ -46,11 +51,52 @@ namespace osu.Game.Tests.Visual.Online switch (request) { case GetUsersRequest getUsersRequest: - handleGetUsersRequest?.Invoke(getUsersRequest); + if (handleGetUsersRequest != null) + { + handleGetUsersRequest?.Invoke(getUsersRequest); + } + else + { + int userId = getUsersRequest.UserIds.Single(); + var response = new GetUsersResponse + { + Users = new List + { + new APIUser + { + Id = userId, + RulesetsStatistics = new Dictionary + { + ["osu"] = tryGetStatistics(userId, "osu"), + ["taiko"] = tryGetStatistics(userId, "taiko"), + ["fruits"] = tryGetStatistics(userId, "fruits"), + ["mania"] = tryGetStatistics(userId, "mania"), + } + } + } + }; + getUsersRequest.TriggerSuccess(response); + } + return true; case GetUserRequest getUserRequest: - handleGetUserRequest?.Invoke(getUserRequest); + if (handleGetUserRequest != null) + { + handleGetUserRequest.Invoke(getUserRequest); + } + else + { + int userId = int.Parse(getUserRequest.Lookup); + string rulesetName = getUserRequest.Ruleset.ShortName; + var response = new APIUser + { + Id = userId, + Statistics = tryGetStatistics(userId, rulesetName) + }; + getUserRequest.TriggerSuccess(response); + } + return true; default: @@ -65,120 +111,90 @@ namespace osu.Game.Tests.Visual.Online }); } + private UserStatistics tryGetStatistics(int userId, string rulesetName) + => serverSideStatistics.TryGetValue((userId, rulesetName), out var stats) ? stats : new UserStatistics(); + [Test] public void TestStatisticsUpdateFiredAfterRegistrationAddedAndScoreProcessed() { - AddStep("fetch initial stats", () => - { - handleGetUsersRequest = req => req.TriggerSuccess(createInitialUserResponse(1234)); - dummyAPI.LocalUser.Value = new APIUser { Id = 1234 }; - }); + int userId = getUserId(); + long scoreId = getScoreId(); + setUpUser(userId); + + var ruleset = new OsuRuleset().RulesetInfo; SoloStatisticsUpdate? update = null; + registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); - AddStep("register for updates", () => watcher.RegisterForStatisticsUpdateAfter( - new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser()) - { - Ruleset = new OsuRuleset().RulesetInfo, - OnlineID = 5678 - }, - receivedUpdate => update = receivedUpdate)); + feignScoreProcessing(userId, ruleset, 5_000_000); - AddStep("feign score processing", - () => handleGetUserRequest = - req => req.TriggerSuccess(createIncrementalUserResponse(1234, 5_000_000))); - - AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(1234, 5678)); + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, scoreId)); AddUntilStep("update received", () => update != null); - AddAssert("values before are correct", () => update?.Before.TotalScore, () => Is.EqualTo(4_000_000)); - AddAssert("values after are correct", () => update?.After.TotalScore, () => Is.EqualTo(5_000_000)); + AddAssert("values before are correct", () => update!.Before.TotalScore, () => Is.EqualTo(4_000_000)); + AddAssert("values after are correct", () => update!.After.TotalScore, () => Is.EqualTo(5_000_000)); } [Test] public void TestStatisticsUpdateFiredAfterScoreProcessedAndRegistrationAdded() { - AddStep("fetch initial stats", () => - { - handleGetUsersRequest = req => req.TriggerSuccess(createInitialUserResponse(1235)); - dummyAPI.LocalUser.Value = new APIUser { Id = 1235 }; - }); + int userId = getUserId(); + setUpUser(userId); - AddStep("feign score processing", - () => handleGetUserRequest = - req => req.TriggerSuccess(createIncrementalUserResponse(1235, 5_000_000))); - AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(1235, 5678)); + long scoreId = getScoreId(); + var ruleset = new OsuRuleset().RulesetInfo; + + // note ordering - in this test processing completes *before* the registration is added. + feignScoreProcessing(userId, ruleset, 5_000_000); SoloStatisticsUpdate? update = null; + registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); - // note ordering - this test checks that even if the registration is late, it will receive data. - AddStep("register for updates", () => watcher.RegisterForStatisticsUpdateAfter( - new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser()) - { - Ruleset = new OsuRuleset().RulesetInfo, - OnlineID = 5678 - }, - receivedUpdate => update = receivedUpdate)); + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, scoreId)); AddUntilStep("update received", () => update != null); - AddAssert("values before are correct", () => update?.Before.TotalScore, () => Is.EqualTo(4_000_000)); - AddAssert("values after are correct", () => update?.After.TotalScore, () => Is.EqualTo(5_000_000)); + AddAssert("values before are correct", () => update!.Before.TotalScore, () => Is.EqualTo(4_000_000)); + AddAssert("values after are correct", () => update!.After.TotalScore, () => Is.EqualTo(5_000_000)); } [Test] public void TestStatisticsUpdateNotFiredIfUserLoggedOut() { - AddStep("fetch initial stats", () => - { - handleGetUsersRequest = req => req.TriggerSuccess(createInitialUserResponse(1236)); - dummyAPI.LocalUser.Value = new APIUser { Id = 1236 }; - }); + int userId = getUserId(); + setUpUser(userId); + + long scoreId = getScoreId(); + var ruleset = new OsuRuleset().RulesetInfo; SoloStatisticsUpdate? update = null; + registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); - AddStep("register for updates", () => watcher.RegisterForStatisticsUpdateAfter( - new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser()) - { - Ruleset = new OsuRuleset().RulesetInfo, - OnlineID = 5678 - }, - receivedUpdate => update = receivedUpdate)); - - AddStep("feign score processing", - () => handleGetUserRequest = - req => req.TriggerSuccess(createIncrementalUserResponse(1236, 5_000_000))); + feignScoreProcessing(userId, ruleset, 5_000_000); AddStep("log out user", () => dummyAPI.Logout()); - AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(1236, 5678)); + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, scoreId)); AddWaitStep("wait a bit", 5); AddAssert("update not received", () => update == null); + + AddStep("log in user", () => dummyAPI.Login("user", "password")); } [Test] public void TestStatisticsUpdateNotFiredIfAnotherUserLoggedIn() { - AddStep("fetch initial stats", () => - { - handleGetUsersRequest = req => req.TriggerSuccess(createInitialUserResponse(1237)); - dummyAPI.LocalUser.Value = new APIUser { Id = 1237 }; - }); + int userId = getUserId(); + setUpUser(userId); + + long scoreId = getScoreId(); + var ruleset = new OsuRuleset().RulesetInfo; SoloStatisticsUpdate? update = null; + registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); - AddStep("register for updates", () => watcher.RegisterForStatisticsUpdateAfter( - new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser()) - { - Ruleset = new OsuRuleset().RulesetInfo, - OnlineID = 5678 - }, - receivedUpdate => update = receivedUpdate)); + feignScoreProcessing(userId, ruleset, 5_000_000); - AddStep("feign score processing", - () => handleGetUserRequest = - req => req.TriggerSuccess(createIncrementalUserResponse(1237, 5_000_000))); + AddStep("change user", () => dummyAPI.LocalUser.Value = new APIUser { Id = getUserId() }); - AddStep("log out user", () => dummyAPI.LocalUser.Value = new APIUser { Id = 5555 }); - - AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(1237, 5678)); + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, scoreId)); AddWaitStep("wait a bit", 5); AddAssert("update not received", () => update == null); } @@ -186,56 +202,51 @@ namespace osu.Game.Tests.Visual.Online [Test] public void TestStatisticsUpdateNotFiredIfScoreIdDoesNotMatch() { - AddStep("fetch initial stats", () => - { - handleGetUsersRequest = req => req.TriggerSuccess(createInitialUserResponse(1238)); - dummyAPI.LocalUser.Value = new APIUser { Id = 1238 }; - }); + int userId = getUserId(); + setUpUser(userId); + + long scoreId = getScoreId(); + var ruleset = new OsuRuleset().RulesetInfo; SoloStatisticsUpdate? update = null; + registerForUpdates(scoreId, ruleset, receivedUpdate => update = receivedUpdate); - AddStep("register for updates", () => watcher.RegisterForStatisticsUpdateAfter( - new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser()) - { - Ruleset = new OsuRuleset().RulesetInfo, - OnlineID = 5678 - }, - receivedUpdate => update = receivedUpdate)); + feignScoreProcessing(userId, ruleset, 5_000_000); - AddStep("feign score processing", - () => handleGetUserRequest = - req => req.TriggerSuccess(createIncrementalUserResponse(1238, 5_000_000))); - - AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(1238, 9012)); + AddStep("signal another score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, getScoreId())); AddWaitStep("wait a bit", 5); AddAssert("update not received", () => update == null); } - private GetUsersResponse createInitialUserResponse(int userId) => new GetUsersResponse - { - Users = new List - { - new APIUser - { - Id = userId, - RulesetsStatistics = new Dictionary - { - ["osu"] = new UserStatistics { TotalScore = 4_000_000 }, - ["taiko"] = new UserStatistics { TotalScore = 3_000_000 }, - ["fruits"] = new UserStatistics { TotalScore = 2_000_000 }, - ["mania"] = new UserStatistics { TotalScore = 1_000_000 } - } - } - } - }; + private int nextUserId = 2000; + private long nextScoreId = 50000; - private APIUser createIncrementalUserResponse(int userId, long totalScore) => new APIUser + private int getUserId() => ++nextUserId; + private long getScoreId() => ++nextScoreId; + + private void setUpUser(int userId) { - Id = userId, - Statistics = new UserStatistics + AddStep("fetch initial stats", () => { - TotalScore = totalScore - } - }; + serverSideStatistics[(userId, "osu")] = new UserStatistics { TotalScore = 4_000_000 }; + serverSideStatistics[(userId, "taiko")] = new UserStatistics { TotalScore = 3_000_000 }; + serverSideStatistics[(userId, "fruits")] = new UserStatistics { TotalScore = 2_000_000 }; + serverSideStatistics[(userId, "mania")] = new UserStatistics { TotalScore = 1_000_000 }; + + dummyAPI.LocalUser.Value = new APIUser { Id = userId }; + }); + } + + private void registerForUpdates(long scoreId, RulesetInfo rulesetInfo, Action onUpdateReady) => + AddStep("register for updates", () => watcher.RegisterForStatisticsUpdateAfter( + new ScoreInfo(Beatmap.Value.BeatmapInfo, new OsuRuleset().RulesetInfo, new RealmUser()) + { + Ruleset = rulesetInfo, + OnlineID = scoreId + }, + onUpdateReady)); + + private void feignScoreProcessing(int userId, RulesetInfo rulesetInfo, long newTotalScore) + => AddStep("feign score processing", () => serverSideStatistics[(userId, rulesetInfo.ShortName)] = new UserStatistics { TotalScore = newTotalScore }); } } diff --git a/osu.Game/Online/API/Requests/GetUsersRequest.cs b/osu.Game/Online/API/Requests/GetUsersRequest.cs index bbaf241384..b57bb215aa 100644 --- a/osu.Game/Online/API/Requests/GetUsersRequest.cs +++ b/osu.Game/Online/API/Requests/GetUsersRequest.cs @@ -9,7 +9,7 @@ namespace osu.Game.Online.API.Requests { public class GetUsersRequest : APIRequest { - private readonly int[] userIds; + public readonly int[] UserIds; private const int max_ids_per_request = 50; @@ -18,9 +18,9 @@ namespace osu.Game.Online.API.Requests if (userIds.Length > max_ids_per_request) throw new ArgumentException($"{nameof(GetUsersRequest)} calls only support up to {max_ids_per_request} IDs at once"); - this.userIds = userIds; + UserIds = userIds; } - protected override string Target => "users/?ids[]=" + string.Join("&ids[]=", userIds); + protected override string Target => "users/?ids[]=" + string.Join("&ids[]=", UserIds); } } diff --git a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs index 197ad410a9..1befbe2af0 100644 --- a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs +++ b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs @@ -71,7 +71,7 @@ namespace osu.Game.Online.Solo if (!api.IsLoggedIn) return; - Debug.Assert(localUser != null && localUser.OnlineID > 1); + Debug.Assert(localUser != null); var userRequest = new GetUsersRequest(new[] { localUser.OnlineID }); userRequest.Success += response => Schedule(() => From fa2d50fe3164612e17bedb6d1892294cc9af0a0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 22 Dec 2022 19:29:51 +0100 Subject: [PATCH 05/11] Limit tracking unhandled scores to just the last one --- osu.Game/Online/Solo/SoloStatisticsWatcher.cs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs index 1befbe2af0..48f39504a3 100644 --- a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs +++ b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs @@ -29,7 +29,7 @@ namespace osu.Game.Online.Solo private IAPIProvider api { get; set; } = null!; private readonly Dictionary callbacks = new Dictionary(); - private readonly HashSet scoresWithoutCallback = new HashSet(); + private long? lastProcessedScoreId; private readonly Dictionary latestStatistics = new Dictionary(); @@ -53,7 +53,7 @@ namespace osu.Game.Online.Solo var callback = new StatisticsUpdateCallback(score, onUpdateReady); - if (scoresWithoutCallback.Remove(score.OnlineID)) + if (lastProcessedScoreId == score.OnlineID) { requestStatisticsUpdate(api.LocalUser.Value.Id, callback); return; @@ -65,7 +65,7 @@ namespace osu.Game.Online.Solo private void onUserChanged(APIUser? localUser) => Schedule(() => { callbacks.Clear(); - scoresWithoutCallback.Clear(); + lastProcessedScoreId = null; latestStatistics.Clear(); if (!api.IsLoggedIn) @@ -87,11 +87,10 @@ namespace osu.Game.Online.Solo if (userId != api.LocalUser.Value?.OnlineID) return; + lastProcessedScoreId = scoreId; + if (!callbacks.TryGetValue(scoreId, out var callback)) - { - scoresWithoutCallback.Add(scoreId); return; - } requestStatisticsUpdate(userId, callback); callbacks.Remove(scoreId); From 27afeb9e301d0392be3a22155643389468c04448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 22 Dec 2022 19:46:41 +0100 Subject: [PATCH 06/11] Add test coverage of merging ignored score updates --- .../Online/TestSceneSoloStatisticsWatcher.cs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs b/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs index 008d54be63..b1badc6282 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneSoloStatisticsWatcher.cs @@ -218,6 +218,34 @@ namespace osu.Game.Tests.Visual.Online AddAssert("update not received", () => update == null); } + // the behaviour exercised in this test may not be final, it is mostly assumed for simplicity. + // in the long run we may want each score's update to be entirely isolated from others, rather than have prior unobserved updates merge into the latest. + [Test] + public void TestIgnoredScoreUpdateIsMergedIntoNextOne() + { + int userId = getUserId(); + setUpUser(userId); + + long firstScoreId = getScoreId(); + var ruleset = new OsuRuleset().RulesetInfo; + + feignScoreProcessing(userId, ruleset, 5_000_000); + + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, firstScoreId)); + + long secondScoreId = getScoreId(); + + feignScoreProcessing(userId, ruleset, 6_000_000); + + SoloStatisticsUpdate? update = null; + registerForUpdates(secondScoreId, ruleset, receivedUpdate => update = receivedUpdate); + + AddStep("signal score processed", () => ((ISpectatorClient)spectatorClient).UserScoreProcessed(userId, secondScoreId)); + AddUntilStep("update received", () => update != null); + AddAssert("values before are correct", () => update!.Before.TotalScore, () => Is.EqualTo(4_000_000)); + AddAssert("values after are correct", () => update!.After.TotalScore, () => Is.EqualTo(6_000_000)); + } + private int nextUserId = 2000; private long nextScoreId = 50000; From d6e079a2b4e09cb80a30189c84a57c9eb3605f83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Sat, 24 Dec 2022 11:28:46 +0100 Subject: [PATCH 07/11] Ignore statistics update requests from third-party rulesets for now --- osu.Game/Online/Solo/SoloStatisticsWatcher.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs index 48f39504a3..1209747132 100644 --- a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs +++ b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs @@ -8,6 +8,7 @@ using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; +using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; @@ -51,6 +52,9 @@ namespace osu.Game.Online.Solo if (!api.IsLoggedIn) return; + if (!score.Ruleset.IsLegacyRuleset()) + return; + var callback = new StatisticsUpdateCallback(score, onUpdateReady); if (lastProcessedScoreId == score.OnlineID) From fd9110a61e8424384ad90fbea0def6101e3ed430 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Sat, 24 Dec 2022 13:28:25 +0100 Subject: [PATCH 08/11] Fix solo statistics watcher firing requests for invalid user with id 1 Can happen during login flow (see `APIAccess.attemptConnect()`). --- osu.Game/Online/Solo/SoloStatisticsWatcher.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs index 1209747132..0dfe8ebb8d 100644 --- a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs +++ b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs @@ -3,7 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Extensions.ObjectExtensions; @@ -72,11 +71,9 @@ namespace osu.Game.Online.Solo lastProcessedScoreId = null; latestStatistics.Clear(); - if (!api.IsLoggedIn) + if (localUser == null || localUser.OnlineID <= 1) return; - Debug.Assert(localUser != null); - var userRequest = new GetUsersRequest(new[] { localUser.OnlineID }); userRequest.Success += response => Schedule(() => { From 3c26016b61bd43e85c386c69e447665aece584e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Sat, 24 Dec 2022 13:30:54 +0100 Subject: [PATCH 09/11] Ensure latest stats are cleared on successful profile fetch --- osu.Game/Online/Solo/SoloStatisticsWatcher.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs index 0dfe8ebb8d..268483ab91 100644 --- a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs +++ b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs @@ -77,6 +77,7 @@ namespace osu.Game.Online.Solo var userRequest = new GetUsersRequest(new[] { localUser.OnlineID }); userRequest.Success += response => Schedule(() => { + latestStatistics.Clear(); foreach (var rulesetStats in response.Users.Single().RulesetsStatistics) latestStatistics.Add(rulesetStats.Key, rulesetStats.Value); }); From 6c4ca387e01fcbbe82b1b29c49e0b0011f2cb1d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Sat, 24 Dec 2022 13:42:32 +0100 Subject: [PATCH 10/11] Fix wrong handling of missing ruleset statistics --- osu.Game/Online/Solo/SoloStatisticsWatcher.cs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs index 268483ab91..383f6a202d 100644 --- a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs +++ b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs @@ -31,7 +31,7 @@ namespace osu.Game.Online.Solo private readonly Dictionary callbacks = new Dictionary(); private long? lastProcessedScoreId; - private readonly Dictionary latestStatistics = new Dictionary(); + private Dictionary? latestStatistics; protected override void LoadComplete() { @@ -69,7 +69,7 @@ namespace osu.Game.Online.Solo { callbacks.Clear(); lastProcessedScoreId = null; - latestStatistics.Clear(); + latestStatistics = null; if (localUser == null || localUser.OnlineID <= 1) return; @@ -77,7 +77,7 @@ namespace osu.Game.Online.Solo var userRequest = new GetUsersRequest(new[] { localUser.OnlineID }); userRequest.Success += response => Schedule(() => { - latestStatistics.Clear(); + latestStatistics = new Dictionary(); foreach (var rulesetStats in response.Users.Single().RulesetsStatistics) latestStatistics.Add(rulesetStats.Key, rulesetStats.Value); }); @@ -109,9 +109,12 @@ namespace osu.Game.Online.Solo { string rulesetName = callback.Score.Ruleset.ShortName; - if (!latestStatistics.TryGetValue(rulesetName, out var latestRulesetStatistics)) + if (latestStatistics == null) return; + latestStatistics.TryGetValue(rulesetName, out UserStatistics? latestRulesetStatistics); + latestRulesetStatistics ??= new UserStatistics(); + var update = new SoloStatisticsUpdate(callback.Score, latestRulesetStatistics, updatedStatistics); callback.OnUpdateReady.Invoke(update); From 78c47a3695a9e8c1bbfa1ef8f07eb7ae2f3df5f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Sat, 24 Dec 2022 13:45:04 +0100 Subject: [PATCH 11/11] Add callback to dictionary rather than overwrite Attempting to overwrite will henceforth throw an exception. --- osu.Game/Online/Solo/SoloStatisticsWatcher.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs index 383f6a202d..33344044b9 100644 --- a/osu.Game/Online/Solo/SoloStatisticsWatcher.cs +++ b/osu.Game/Online/Solo/SoloStatisticsWatcher.cs @@ -62,7 +62,7 @@ namespace osu.Game.Online.Solo return; } - callbacks[score.OnlineID] = callback; + callbacks.Add(score.OnlineID, callback); }); private void onUserChanged(APIUser? localUser) => Schedule(() =>