diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs index 474d2ee6e3..ebeba23123 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapLeaderboard.cs @@ -42,6 +42,7 @@ namespace osu.Game.Tests.Visual.SongSelect private RulesetStore rulesetStore = null!; private BeatmapManager beatmapManager = null!; private PlaySongSelect songSelect = null!; + private LeaderboardManager leaderboardManager = null!; protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { @@ -52,6 +53,7 @@ namespace osu.Game.Tests.Visual.SongSelect dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, Realm, API)); dependencies.CacheAs(songSelect = new PlaySongSelect()); Dependencies.Cache(Realm); + dependencies.Cache(leaderboardManager = new LeaderboardManager()); return dependencies; } @@ -60,6 +62,7 @@ namespace osu.Game.Tests.Visual.SongSelect private void load() { LoadComponent(songSelect); + LoadComponent(leaderboardManager); } public TestSceneBeatmapLeaderboard() diff --git a/osu.Game/Online/Leaderboards/LeaderboardManager.cs b/osu.Game/Online/Leaderboards/LeaderboardManager.cs new file mode 100644 index 0000000000..9104c83c02 --- /dev/null +++ b/osu.Game/Online/Leaderboards/LeaderboardManager.cs @@ -0,0 +1,162 @@ +// 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 System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using osu.Game.Screens.Select.Leaderboards; +using Realms; + +namespace osu.Game.Online.Leaderboards +{ + public partial class LeaderboardManager : Component + { + public IBindable Scores => scores; + private readonly Bindable scores = new Bindable(); + + private LeaderboardCriteria? criteria; + + private IDisposable? localScoreSubscription; + private TaskCompletionSource? localFetchCompletionSource; + private TaskCompletionSource? lastFetchCompletionSource; + private GetScoresRequest? inFlightOnlineRequest; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + public LeaderboardManager() + { + scores.BindValueChanged(_ => + { + if (localFetchCompletionSource != null && localFetchCompletionSource == lastFetchCompletionSource && scores.Value != null) + { + localFetchCompletionSource.SetResult(scores.Value); + localFetchCompletionSource = null; + } + }); + } + + public Task FetchWithCriteriaAsync(LeaderboardCriteria newCriteria) + { + if (criteria?.Equals(newCriteria) == true && lastFetchCompletionSource?.Task.IsFaulted == false) + return lastFetchCompletionSource?.Task ?? Task.FromResult(Scores.Value); + + criteria = newCriteria; + localScoreSubscription?.Dispose(); + inFlightOnlineRequest?.Cancel(); + lastFetchCompletionSource?.TrySetCanceled(); + scores.Value = null; + + switch (newCriteria.Scope) + { + case BeatmapLeaderboardScope.Local: + { + lastFetchCompletionSource = localFetchCompletionSource = new TaskCompletionSource(); + localScoreSubscription = realm.RegisterForNotifications(r => + r.All().Filter($"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $0" + + $" AND {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}" + + $" AND {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1" + + $" AND {nameof(ScoreInfo.DeletePending)} == false" + , newCriteria.Beatmap.ID, newCriteria.Ruleset.ShortName), localScoresChanged); + return localFetchCompletionSource.Task; + } + + default: + { + var onlineFetchCompletionSource = new TaskCompletionSource(); + lastFetchCompletionSource = onlineFetchCompletionSource; + + IReadOnlyList? requestMods = null; + + if (newCriteria.ExactMods != null) + { + if (!newCriteria.ExactMods.Any()) + // add nomod for the request + requestMods = new Mod[] { new ModNoMod() }; + else + requestMods = newCriteria.ExactMods; + } + + var newRequest = new GetScoresRequest(newCriteria.Beatmap, newCriteria.Ruleset, newCriteria.Scope, requestMods); + newRequest.Success += response => + { + if (inFlightOnlineRequest != null && !newRequest.Equals(inFlightOnlineRequest)) + return; + + var result = new LeaderboardScores + ( + response.Scores.Select(s => s.ToScoreInfo(rulesets, newCriteria.Beatmap)).OrderByTotalScore(), + response.UserScore?.CreateScoreInfo(rulesets, newCriteria.Beatmap) + ); + inFlightOnlineRequest = null; + if (onlineFetchCompletionSource.TrySetResult(result)) + scores.Value = result; + }; + newRequest.Failure += ex => onlineFetchCompletionSource.TrySetException(ex); + api.Queue(inFlightOnlineRequest = newRequest); + return onlineFetchCompletionSource.Task; + } + } + } + + private void localScoresChanged(IRealmCollection sender, ChangeSet? changes) + { + Debug.Assert(criteria != null); + + // This subscription may fire from changes to linked beatmaps, which we don't care about. + // It's currently not possible for a score to be modified after insertion, so we can safely ignore callbacks with only modifications. + if (changes?.HasCollectionChanges() == false) + return; + + var newScores = sender.AsEnumerable(); + + if (criteria.ExactMods != null) + { + if (!criteria.ExactMods.Any()) + { + // we need to filter out all scores that have any mods to get all local nomod scores + newScores = newScores.Where(s => !s.Mods.Any()); + } + else + { + // otherwise find all the scores that have all of the currently selected mods (similar to how web applies mod filters) + // we're creating and using a string HashSet representation of selected mods so that it can be translated into the DB query itself + var selectedMods = criteria.ExactMods.Select(m => m.Acronym).ToHashSet(); + + newScores = newScores.Where(s => selectedMods.SetEquals(s.Mods.Select(m => m.Acronym))); + } + } + + newScores = newScores.Detach().OrderByTotalScore(); + + scores.Value = new LeaderboardScores(newScores, null); + } + } + + public record LeaderboardCriteria( + BeatmapInfo Beatmap, + RulesetInfo Ruleset, + BeatmapLeaderboardScope Scope, + Mod[]? ExactMods + ); + + public record LeaderboardScores(IEnumerable TopScores, ScoreInfo? UserScore); +} diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 4087a8b71e..fb28b8c5a4 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -49,6 +49,7 @@ using osu.Game.Localisation; using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.Chat; +using osu.Game.Online.Leaderboards; using osu.Game.Online.Metadata; using osu.Game.Online.Multiplayer; using osu.Game.Online.Spectator; @@ -203,6 +204,7 @@ namespace osu.Game private UserLookupCache userCache; private BeatmapLookupCache beatmapCache; + private LeaderboardManager leaderboardManager; private RulesetConfigCache rulesetConfigCache; @@ -365,6 +367,9 @@ namespace osu.Game dependencies.CacheAs>(Beatmap); dependencies.CacheAs(Beatmap); + dependencies.Cache(leaderboardManager = new LeaderboardManager()); + base.Content.Add(leaderboardManager); + // add api components to hierarchy. if (API is APIAccess apiAccess) base.Content.Add(apiAccess); diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 46705aaa28..e435554b03 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -3,21 +3,17 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Game.Beatmaps; -using osu.Game.Database; using osu.Game.Extensions; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.Leaderboards; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; -using Realms; namespace osu.Game.Screens.Select.Leaderboards { @@ -67,6 +63,8 @@ namespace osu.Game.Screens.Select.Leaderboards } } + private readonly IBindable fetchedScores = new Bindable(); + [Resolved] private IBindable ruleset { get; set; } = null!; @@ -77,14 +75,7 @@ namespace osu.Game.Screens.Select.Leaderboards private IAPIProvider api { get; set; } = null!; [Resolved] - private RulesetStore rulesets { get; set; } = null!; - - [Resolved] - private RealmAccess realm { get; set; } = null!; - - private IDisposable? scoreSubscription; - - private GetScoresRequest? scoreRetrievalRequest; + private LeaderboardManager leaderboardManager { get; set; } = null!; [BackgroundDependencyLoader] private void load() @@ -95,15 +86,23 @@ namespace osu.Game.Screens.Select.Leaderboards if (filterMods) RefetchScores(); }; + fetchedScores.BindTo(leaderboardManager.Scores); + } + + protected override void LoadComplete() + { + base.LoadComplete(); + fetchedScores.BindValueChanged(_ => + { + if (fetchedScores.Value != null) + SetScores(fetchedScores.Value.TopScores, fetchedScores.Value.UserScore); + }); } protected override bool IsOnlineScope => Scope != BeatmapLeaderboardScope.Local; protected override APIRequest? FetchScores(CancellationToken cancellationToken) { - scoreRetrievalRequest?.Cancel(); - scoreRetrievalRequest = null; - var fetchBeatmapInfo = BeatmapInfo; if (fetchBeatmapInfo == null) @@ -114,12 +113,6 @@ namespace osu.Game.Screens.Select.Leaderboards var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo.Ruleset; - if (Scope == BeatmapLeaderboardScope.Local) - { - subscribeToLocalScores(fetchBeatmapInfo, cancellationToken); - return null; - } - if (!api.IsLoggedIn) { SetErrorState(LeaderboardState.NotLoggedIn); @@ -132,7 +125,7 @@ namespace osu.Game.Screens.Select.Leaderboards return null; } - if (fetchBeatmapInfo.OnlineID <= 0 || fetchBeatmapInfo.Status <= BeatmapOnlineStatus.Pending) + if ((fetchBeatmapInfo.OnlineID <= 0 || fetchBeatmapInfo.Status <= BeatmapOnlineStatus.Pending) && IsOnlineScope) { SetErrorState(LeaderboardState.BeatmapUnavailable); return null; @@ -150,29 +143,14 @@ namespace osu.Game.Screens.Select.Leaderboards return null; } - IReadOnlyList? requestMods = null; + leaderboardManager.FetchWithCriteriaAsync(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope, filterMods ? mods.Value.ToArray() : null)) + .ContinueWith(t => + { + if (t.Exception != null && !t.IsCanceled) + Schedule(() => SetErrorState(LeaderboardState.NetworkFailure)); + }, cancellationToken); - if (filterMods && !mods.Value.Any()) - // add nomod for the request - requestMods = new Mod[] { new ModNoMod() }; - else if (filterMods) - requestMods = mods.Value; - - var newRequest = new GetScoresRequest(fetchBeatmapInfo, fetchRuleset, Scope, requestMods); - newRequest.Success += response => Schedule(() => - { - // Request may have changed since fetch request. - // Can't rely on request cancellation due to Schedule inside SetScores so let's play it safe. - if (!newRequest.Equals(scoreRetrievalRequest)) - return; - - SetScores( - response.Scores.Select(s => s.ToScoreInfo(rulesets, fetchBeatmapInfo)).OrderByTotalScore(), - response.UserScore?.CreateScoreInfo(rulesets, fetchBeatmapInfo) - ); - }); - - return scoreRetrievalRequest = newRequest; + return null; } protected override LeaderboardScore CreateDrawableScore(ScoreInfo model, int index) => new LeaderboardScore(model, index, IsOnlineScope, Scope != BeatmapLeaderboardScope.Friend) @@ -184,59 +162,5 @@ namespace osu.Game.Screens.Select.Leaderboards { Action = () => ScoreSelected?.Invoke(model) }; - - private void subscribeToLocalScores(BeatmapInfo beatmapInfo, CancellationToken cancellationToken) - { - Debug.Assert(beatmapInfo != null); - - scoreSubscription?.Dispose(); - scoreSubscription = null; - - scoreSubscription = realm.RegisterForNotifications(r => - r.All().Filter($"{nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.ID)} == $0" - + $" AND {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}" - + $" AND {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1" - + $" AND {nameof(ScoreInfo.DeletePending)} == false" - , beatmapInfo.ID, ruleset.Value.ShortName), localScoresChanged); - - void localScoresChanged(IRealmCollection sender, ChangeSet? changes) - { - if (cancellationToken.IsCancellationRequested) - return; - - // This subscription may fire from changes to linked beatmaps, which we don't care about. - // It's currently not possible for a score to be modified after insertion, so we can safely ignore callbacks with only modifications. - if (changes?.HasCollectionChanges() == false) - return; - - var scores = sender.AsEnumerable(); - - if (filterMods && !mods.Value.Any()) - { - // we need to filter out all scores that have any mods to get all local nomod scores - scores = scores.Where(s => !s.Mods.Any()); - } - else if (filterMods) - { - // otherwise find all the scores that have all of the currently selected mods (similar to how web applies mod filters) - // we're creating and using a string HashSet representation of selected mods so that it can be translated into the DB query itself - var selectedMods = mods.Value.Select(m => m.Acronym).ToHashSet(); - - scores = scores.Where(s => selectedMods.SetEquals(s.Mods.Select(m => m.Acronym))); - } - - scores = scores.Detach().OrderByTotalScore(); - - SetScores(scores); - } - } - - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - scoreSubscription?.Dispose(); - scoreRetrievalRequest?.Cancel(); - } } }