From c92caa2d18c76f5423b6544db634493e37a2e85d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 14 Feb 2025 11:49:50 +0100 Subject: [PATCH] Extract leaderboard fetch logic from song select beatmap leaderboard drawable --- .../Mods/TestSceneManiaModNoRelease.cs | 2 +- .../TestSceneHoldNoteInput.cs | 2 +- .../TestSceneMaximumScore.cs | 2 +- .../TestSceneOutOfOrderHits.cs | 2 +- .../Mods/TestSceneOsuModTouchDevice.cs | 2 +- .../TestSceneLegacyHitPolicy.cs | 2 +- .../TestSceneSliderEarlyHitJudgement.cs | 2 +- .../TestSceneSliderFollowCircleInput.cs | 2 +- .../TestSceneSliderInput.cs | 2 +- .../TestSceneSliderLateHitJudgement.cs | 2 +- .../TestSceneSpinnerInput.cs | 2 +- .../TestSceneSpinnerJudgement.cs | 2 +- .../TestSceneStartTimeOrderedHitPolicy.cs | 2 +- .../Judgements/JudgementTest.cs | 2 +- osu.Game/OsuGameBase.cs | 5 + .../Overlays/SkinEditor/SkinEditorOverlay.cs | 2 +- osu.Game/Screens/Play/ReplayPlayer.cs | 15 +- osu.Game/Screens/Play/SoloPlayer.cs | 32 +--- .../Select/Leaderboards/BeatmapLeaderboard.cs | 147 +++--------------- .../Leaderboards/LeaderboardProvider.cs | 85 ++++++++++ .../StateTrackingLeaderboardProvider.cs | 137 ++++++++++++++++ .../Screens/Select/PlayBeatmapDetailArea.cs | 2 +- osu.Game/Screens/Select/PlaySongSelect.cs | 10 +- osu.Game/Screens/SelectV2/SongSelectV2.cs | 2 +- osu.Game/Tests/Visual/TestPlayer.cs | 2 +- osu.Game/Tests/Visual/TestReplayPlayer.cs | 4 +- 26 files changed, 289 insertions(+), 182 deletions(-) create mode 100644 osu.Game/Screens/Select/Leaderboards/LeaderboardProvider.cs create mode 100644 osu.Game/Screens/Select/Leaderboards/StateTrackingLeaderboardProvider.cs diff --git a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModNoRelease.cs b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModNoRelease.cs index f6e79114de..84a945fa4a 100644 --- a/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModNoRelease.cs +++ b/osu.Game.Rulesets.Mania.Tests/Mods/TestSceneManiaModNoRelease.cs @@ -631,7 +631,7 @@ namespace osu.Game.Rulesets.Mania.Tests.Mods protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) - : base(score, new PlayerConfiguration + : base(score, null, new PlayerConfiguration { AllowPause = false, ShowResults = false, diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs index e328d23ed4..b23567a11d 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneHoldNoteInput.cs @@ -578,7 +578,7 @@ namespace osu.Game.Rulesets.Mania.Tests protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) - : base(score, new PlayerConfiguration + : base(score, null, new PlayerConfiguration { AllowPause = false, ShowResults = false, diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs index ee6d999932..e70f683a5c 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs @@ -135,7 +135,7 @@ namespace osu.Game.Rulesets.Mania.Tests protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) - : base(score, new PlayerConfiguration + : base(score, null, new PlayerConfiguration { AllowPause = false, ShowResults = false, diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs index e49b259615..b1946809f1 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneOutOfOrderHits.cs @@ -178,7 +178,7 @@ namespace osu.Game.Rulesets.Mania.Tests protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) - : base(score, new PlayerConfiguration + : base(score, null, new PlayerConfiguration { AllowPause = false, ShowResults = false, diff --git a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModTouchDevice.cs b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModTouchDevice.cs index 8c81431770..4eea699eb4 100644 --- a/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModTouchDevice.cs +++ b/osu.Game.Rulesets.Osu.Tests/Mods/TestSceneOsuModTouchDevice.cs @@ -199,7 +199,7 @@ namespace osu.Game.Rulesets.Osu.Tests.Mods protected override bool PauseOnFocusLost => false; public ScoreAccessibleSoloPlayer() - : base(new PlayerConfiguration + : base(null, new PlayerConfiguration { AllowPause = false, ShowResults = false, diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs index e460da9bd5..9f4e81720a 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyHitPolicy.cs @@ -860,7 +860,7 @@ namespace osu.Game.Rulesets.Osu.Tests protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) - : base(score, new PlayerConfiguration + : base(score, null, new PlayerConfiguration { AllowPause = false, ShowResults = false, diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderEarlyHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderEarlyHitJudgement.cs index 19883060a0..4eabe01ba3 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderEarlyHitJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderEarlyHitJudgement.cs @@ -217,7 +217,7 @@ namespace osu.Game.Rulesets.Osu.Tests protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) - : base(score, new PlayerConfiguration + : base(score, null, new PlayerConfiguration { AllowPause = false, ShowResults = false, diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderFollowCircleInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderFollowCircleInput.cs index fc9bb16cb7..9648ae6330 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderFollowCircleInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderFollowCircleInput.cs @@ -106,7 +106,7 @@ namespace osu.Game.Rulesets.Osu.Tests protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) - : base(score, new PlayerConfiguration + : base(score, null, new PlayerConfiguration { AllowPause = false, ShowResults = false, diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs index 286e4bd775..f8129682cf 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderInput.cs @@ -570,7 +570,7 @@ namespace osu.Game.Rulesets.Osu.Tests protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) - : base(score, new PlayerConfiguration + : base(score, null, new PlayerConfiguration { AllowPause = false, ShowResults = false, diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs index d089e924ca..cd93b699ce 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderLateHitJudgement.cs @@ -516,7 +516,7 @@ namespace osu.Game.Rulesets.Osu.Tests protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) - : base(score, new PlayerConfiguration + : base(score, null, new PlayerConfiguration { AllowPause = false, ShowResults = false, diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs index 75bcd809c8..0f41f0e6db 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerInput.cs @@ -338,7 +338,7 @@ namespace osu.Game.Rulesets.Osu.Tests protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) - : base(score, new PlayerConfiguration + : base(score, null, new PlayerConfiguration { AllowPause = false, ShowResults = false, diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs index 8d8c2e9639..d86639c50c 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSpinnerJudgement.cs @@ -115,7 +115,7 @@ namespace osu.Game.Rulesets.Osu.Tests protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) - : base(score, new PlayerConfiguration + : base(score, null, new PlayerConfiguration { AllowPause = false, ShowResults = false, diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs index 895e9bbdee..85f1942ec8 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneStartTimeOrderedHitPolicy.cs @@ -494,7 +494,7 @@ namespace osu.Game.Rulesets.Osu.Tests protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) - : base(score, new PlayerConfiguration + : base(score, null, new PlayerConfiguration { AllowPause = false, ShowResults = false, diff --git a/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs b/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs index 30ecec2366..85f446ed30 100644 --- a/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs +++ b/osu.Game.Rulesets.Taiko.Tests/Judgements/JudgementTest.cs @@ -88,7 +88,7 @@ namespace osu.Game.Rulesets.Taiko.Tests.Judgements protected override bool PauseOnFocusLost => false; public ScoreAccessibleReplayPlayer(Score score) - : base(score, new PlayerConfiguration + : base(score, null, new PlayerConfiguration { AllowPause = false, ShowResults = false, diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 7d35207bbe..f1b618db07 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -60,6 +60,7 @@ using osu.Game.Resources; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; +using osu.Game.Screens.Select.Leaderboards; using osu.Game.Skinning; using osu.Game.Utils; using RuntimeInfo = osu.Framework.RuntimeInfo; @@ -339,6 +340,10 @@ namespace osu.Game dependencies.Cache(beatmapCache = new BeatmapLookupCache()); base.Content.Add(beatmapCache); + var leaderboardProvider = new LeaderboardProvider(); + dependencies.Cache(leaderboardProvider); + base.Content.Add(leaderboardProvider); + dependencies.CacheAs(rulesetConfigCache = new RulesetConfigCache(realm, RulesetStore)); var powerStatus = CreateBatteryInfo(); diff --git a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs index 571f99bd08..b912198cb4 100644 --- a/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs +++ b/osu.Game/Overlays/SkinEditor/SkinEditorOverlay.cs @@ -343,7 +343,7 @@ namespace osu.Game.Overlays.SkinEditor public override bool? AllowGlobalTrackControl => false; public EndlessPlayer(Func, Score> createScore) - : base(createScore, new PlayerConfiguration + : base(createScore, null, new PlayerConfiguration { ShowResults = false, AutomaticallySkipIntro = true, diff --git a/osu.Game/Screens/Play/ReplayPlayer.cs b/osu.Game/Screens/Play/ReplayPlayer.cs index ba572f6014..bc26895e09 100644 --- a/osu.Game/Screens/Play/ReplayPlayer.cs +++ b/osu.Game/Screens/Play/ReplayPlayer.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Input.Bindings; @@ -19,6 +20,7 @@ using osu.Game.Scoring; using osu.Game.Screens.Play.HUD; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Ranking; +using osu.Game.Screens.Select.Leaderboards; using osu.Game.Users; namespace osu.Game.Screens.Play @@ -38,6 +40,8 @@ namespace osu.Game.Screens.Play private bool isAutoplayPlayback => GameplayState.Mods.OfType().Any(); + private readonly BindableList leaderboardScores = new BindableList(); + // Disallow replays from failing. (see https://github.com/ppy/osu/issues/6108) protected override bool CheckModsAllowFailure() { @@ -47,16 +51,17 @@ namespace osu.Game.Screens.Play return base.CheckModsAllowFailure(); } - public ReplayPlayer(Score score, PlayerConfiguration configuration = null) - : this((_, _) => score, configuration) + public ReplayPlayer(Score score, StateTrackingLeaderboardProvider leaderboardScores = null, PlayerConfiguration configuration = null) + : this((_, _) => score, leaderboardScores, configuration) { replayIsFailedScore = score.ScoreInfo.Rank == ScoreRank.F; } - public ReplayPlayer(Func, Score> createScore, PlayerConfiguration configuration = null) + public ReplayPlayer(Func, Score> createScore, [CanBeNull] StateTrackingLeaderboardProvider leaderboardScores = null, PlayerConfiguration configuration = null) : base(configuration) { this.createScore = createScore; + this.leaderboardScores.AddRange(leaderboardScores?.Scores.Value?.best ?? []); } /// @@ -97,13 +102,11 @@ namespace osu.Game.Screens.Play // Don't re-import replay scores as they're already present in the database. protected override Task ImportScore(Score score) => Task.CompletedTask; - public readonly BindableList LeaderboardScores = new BindableList(); - protected override GameplayLeaderboard CreateGameplayLeaderboard() => new SoloGameplayLeaderboard(Score.ScoreInfo.User) { AlwaysVisible = { Value = true }, - Scores = { BindTarget = LeaderboardScores } + Scores = { BindTarget = leaderboardScores } }; protected override ResultsScreen CreateResults(ScoreInfo score) => new SoloResultsScreen(score) diff --git a/osu.Game/Screens/Play/SoloPlayer.cs b/osu.Game/Screens/Play/SoloPlayer.cs index f4cf2da364..dfb365c081 100644 --- a/osu.Game/Screens/Play/SoloPlayer.cs +++ b/osu.Game/Screens/Play/SoloPlayer.cs @@ -1,11 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -#nullable disable - using System; using System.Diagnostics; -using System.Threading.Tasks; using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Extensions; @@ -14,22 +11,19 @@ using osu.Game.Online.Rooms; using osu.Game.Online.Solo; using osu.Game.Scoring; using osu.Game.Screens.Play.HUD; +using osu.Game.Screens.Select.Leaderboards; namespace osu.Game.Screens.Play { public partial class SoloPlayer : SubmittingPlayer { - public SoloPlayer() - : this(null) - { - } - - protected SoloPlayer(PlayerConfiguration configuration = null) + public SoloPlayer(StateTrackingLeaderboardProvider? leaderboardScores, PlayerConfiguration? configuration = null) : base(configuration) { + this.leaderboardScores.AddRange(leaderboardScores?.Scores.Value?.best ?? []); } - protected override APIRequest CreateTokenRequest() + protected override APIRequest? CreateTokenRequest() { int beatmapId = Beatmap.Value.BeatmapInfo.OnlineID; int rulesetId = Ruleset.Value.OnlineID; @@ -43,32 +37,22 @@ namespace osu.Game.Screens.Play return new CreateSoloScoreRequest(Beatmap.Value.BeatmapInfo, rulesetId, Game.VersionHash); } - public readonly BindableList LeaderboardScores = new BindableList(); + private readonly BindableList leaderboardScores = new BindableList(); protected override GameplayLeaderboard CreateGameplayLeaderboard() => new SoloGameplayLeaderboard(Score.ScoreInfo.User) { AlwaysVisible = { Value = false }, - Scores = { BindTarget = LeaderboardScores } + Scores = { BindTarget = leaderboardScores } }; protected override bool ShouldExitOnTokenRetrievalFailure(Exception exception) => false; - protected override Task ImportScore(Score score) - { - // Before importing a score, stop binding the leaderboard with its score source. - // This avoids a case where the imported score may cause a leaderboard refresh - // (if the leaderboard's source is local). - LeaderboardScores.UnbindBindings(); - - return base.ImportScore(score); - } - protected override APIRequest CreateSubmissionRequest(Score score, long token) { - IBeatmapInfo beatmap = score.ScoreInfo.BeatmapInfo; + IBeatmapInfo beatmap = score.ScoreInfo.BeatmapInfo!; - Debug.Assert(beatmap!.OnlineID > 0); + Debug.Assert(beatmap.OnlineID > 0); return new SubmitSoloScoreRequest(score.ScoreInfo, token, beatmap.OnlineID); } diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 57fe22aa59..3799d4bcc7 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.Framework.Graphics; 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 { @@ -48,24 +44,10 @@ namespace osu.Game.Screens.Select.Leaderboards } } - private bool filterMods; - /// /// Whether to apply the game's currently selected mods as a filter when retrieving scores. /// - public bool FilterMods - { - get => filterMods; - set - { - if (value == filterMods) - return; - - filterMods = value; - - RefetchScores(); - } - } + public Bindable FilterMods { get; set; } = new Bindable(); [Resolved] private IBindable ruleset { get; set; } = null!; @@ -77,32 +59,16 @@ namespace osu.Game.Screens.Select.Leaderboards private IAPIProvider api { get; set; } = null!; [Resolved] - private RulesetStore rulesets { get; set; } = null!; + private LeaderboardProvider leaderboardProvider { get; set; } = null!; - [Resolved] - private RealmAccess realm { get; set; } = null!; - - private IDisposable? scoreSubscription; - - private GetScoresRequest? scoreRetrievalRequest; - - [BackgroundDependencyLoader] - private void load() - { - ruleset.ValueChanged += _ => RefetchScores(); - mods.ValueChanged += _ => - { - if (filterMods) - RefetchScores(); - }; - } + public StateTrackingLeaderboardProvider? LeaderboardProvider { get; private set; } protected override bool IsOnlineScope => Scope != BeatmapLeaderboardScope.Local; protected override APIRequest? FetchScores(CancellationToken cancellationToken) { - scoreRetrievalRequest?.Cancel(); - scoreRetrievalRequest = null; + LeaderboardProvider?.RemoveAndDisposeImmediately(); + LeaderboardProvider = null; var fetchBeatmapInfo = BeatmapInfo; @@ -114,12 +80,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,41 +92,34 @@ 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; } - if (!api.LocalUser.Value.IsSupporter && (Scope != BeatmapLeaderboardScope.Global || filterMods)) + if (!api.LocalUser.Value.IsSupporter && (Scope >= BeatmapLeaderboardScope.Country || FilterMods.Value)) { SetErrorState(LeaderboardState.NotSupporter); return null; } - IReadOnlyList? requestMods = null; - - 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(() => + LeaderboardProvider = new StateTrackingLeaderboardProvider(leaderboardProvider) { - // 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; + Beatmap = { Value = BeatmapInfo! }, + Ruleset = { BindTarget = ruleset }, + ModFilterActive = { BindTarget = FilterMods }, + Mods = { BindTarget = mods }, + Scope = { Value = Scope }, + }; + LeaderboardProvider.Scores.BindValueChanged(val => + { + if (val.NewValue != null) + SetScores(val.NewValue.Value.best, val.NewValue.Value.userScore); + }, true); + LeaderboardProvider.RetrievalFailed += _ => Schedule(() => SetErrorState(LeaderboardState.NetworkFailure)); + AddInternal(LeaderboardProvider); + return null; } protected override LeaderboardScore CreateDrawableScore(ScoreInfo model, int index) => new LeaderboardScore(model, index, IsOnlineScope, Scope != BeatmapLeaderboardScope.Friend) @@ -178,59 +131,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(); - } } } diff --git a/osu.Game/Screens/Select/Leaderboards/LeaderboardProvider.cs b/osu.Game/Screens/Select/Leaderboards/LeaderboardProvider.cs new file mode 100644 index 0000000000..6cd7c1985b --- /dev/null +++ b/osu.Game/Screens/Select/Leaderboards/LeaderboardProvider.cs @@ -0,0 +1,85 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +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 Realms; + +namespace osu.Game.Screens.Select.Leaderboards +{ + public partial class LeaderboardProvider : Component + { + [Resolved] + private RealmAccess realm { get; set; } = null!; + + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + private IQueryable getLocalScoresFor(Realm r, BeatmapInfo beatmap, RulesetInfo ruleset) + { + return 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", beatmap.ID, ruleset.ShortName); + } + + public IEnumerable GetLocalScoresFor(BeatmapInfo beatmap, RulesetInfo ruleset) => realm.Run(r => getLocalScoresFor(r, beatmap, ruleset)).AsEnumerable(); + + public IDisposable SubscribeToLocalScores(BeatmapInfo beatmap, RulesetInfo ruleset, NotificationCallbackDelegate onChange) + => realm.RegisterForNotifications(r => getLocalScoresFor(r, beatmap, ruleset), onChange); + + public Task<(IEnumerable best, ScoreInfo? userScore)> GetOnlineScoresAsync(BeatmapInfo beatmap, RulesetInfo ruleset, IReadOnlyList? mods, BeatmapLeaderboardScope scope, + CancellationToken cancellationToken = default) + { + IReadOnlyList? requestMods = mods; + + if (mods != null && !mods.Any()) + // add nomod for the request + requestMods = new Mod[] { new ModNoMod() }; + + var tcs = new TaskCompletionSource<(IEnumerable, ScoreInfo?)>(); + var newRequest = new GetScoresRequest(beatmap, ruleset, scope, requestMods); + newRequest.Success += response => + { + if (cancellationToken.IsCancellationRequested) + { + tcs.SetCanceled(cancellationToken); + return; + } + + // Request may have changed since fetch request. + IEnumerable newScores = response.Scores.Select(s => s.ToScoreInfo(rulesets, beatmap)).OrderByTotalScore().ToArray(); + var userScore = response.UserScore?.CreateScoreInfo(rulesets, beatmap); + + tcs.SetResult((newScores, userScore)); + }; + newRequest.Failure += ex => + { + if (cancellationToken.IsCancellationRequested) + { + tcs.SetCanceled(cancellationToken); + return; + } + + tcs.SetException(ex); + }; + api.Queue(newRequest); + return tcs.Task; + } + } +} diff --git a/osu.Game/Screens/Select/Leaderboards/StateTrackingLeaderboardProvider.cs b/osu.Game/Screens/Select/Leaderboards/StateTrackingLeaderboardProvider.cs new file mode 100644 index 0000000000..dd426d03c6 --- /dev/null +++ b/osu.Game/Screens/Select/Leaderboards/StateTrackingLeaderboardProvider.cs @@ -0,0 +1,137 @@ +// 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Bindables; +using osu.Framework.Extensions; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Scoring; +using Realms; + +namespace osu.Game.Screens.Select.Leaderboards +{ + public partial class StateTrackingLeaderboardProvider : Component + { + /// + /// List of all fetched scores. + /// if fetch is in progress. + /// Updates to this bindable may not be delivered on the update thread. Consumers are expected to schedule locally as required. + /// + public Bindable<(IEnumerable best, ScoreInfo? userScore)?> Scores => scores; + + private Bindable<(IEnumerable, ScoreInfo?)?> scores { get; } = new Bindable<(IEnumerable, ScoreInfo?)?>(); + + /// + /// Raised when fetching scores fails. + /// This event may not be invoked on the update thread. Consumers are expected to schedule locally as required. + /// + public event Action? RetrievalFailed; + + public Bindable Scope { get; } = new Bindable(); + public Bindable Beatmap { get; } = new Bindable(); + public Bindable Ruleset { get; } = new Bindable(); + public Bindable ModFilterActive { get; } = new BindableBool(); + public Bindable> Mods { get; } = new Bindable>([]); + + private readonly LeaderboardProvider leaderboardProvider; + + private IDisposable? localScoreSubscription; + private CancellationTokenSource? onlineLookupCancellationTokenSource; + + public StateTrackingLeaderboardProvider(LeaderboardProvider leaderboardProvider) + { + this.leaderboardProvider = leaderboardProvider; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Scope.BindValueChanged(_ => Scheduler.AddOnce(refetch)); + Beatmap.BindValueChanged(_ => Scheduler.AddOnce(refetch)); + Ruleset.BindValueChanged(_ => Scheduler.AddOnce(refetch)); + ModFilterActive.BindValueChanged(_ => Scheduler.AddOnce(refetch)); + Mods.BindValueChanged(_ => Scheduler.AddOnce(refetch)); + refetch(); + } + + private void refetch() + { + localScoreSubscription?.Dispose(); + localScoreSubscription = null; + + onlineLookupCancellationTokenSource?.Cancel(); + onlineLookupCancellationTokenSource = null; + + if (Scope.Value == BeatmapLeaderboardScope.Local) + { + localScoreSubscription = leaderboardProvider.SubscribeToLocalScores(Beatmap.Value, Ruleset.Value, localScoresChanged); + } + else + { + onlineLookupCancellationTokenSource = new CancellationTokenSource(); + scores.Value = null; + leaderboardProvider.GetOnlineScoresAsync(Beatmap.Value, Ruleset.Value, ModFilterActive.Value ? Mods.Value : null, Scope.Value, onlineLookupCancellationTokenSource.Token) + .ContinueWith(t => + { + switch (t.Status) + { + case TaskStatus.RanToCompletion: + scores.Value = t.GetResultSafely(); + break; + + case TaskStatus.Faulted: + RetrievalFailed?.Invoke(t.Exception!); + break; + } + }); + } + } + + private void localScoresChanged(IRealmCollection sender, ChangeSet? changes) + { + // 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 (ModFilterActive.Value && !Mods.Value.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 if (ModFilterActive.Value) + { + // 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(); + + newScores = newScores.Where(s => selectedMods.SetEquals(s.Mods.Select(m => m.Acronym))); + } + + newScores = newScores.Detach().OrderByTotalScore(); + scores.Value = (newScores, null); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + localScoreSubscription?.Dispose(); + localScoreSubscription = null; + + onlineLookupCancellationTokenSource?.Cancel(); + onlineLookupCancellationTokenSource = null; + } + } +} diff --git a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs index deb1100dfc..b3b694e0f5 100644 --- a/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs +++ b/osu.Game/Screens/Select/PlayBeatmapDetailArea.cs @@ -62,7 +62,7 @@ namespace osu.Game.Screens.Select { base.OnTabChanged(tab, selectedMods); - Leaderboard.FilterMods = selectedMods; + Leaderboard.FilterMods.Value = selectedMods; switch (tab) { diff --git a/osu.Game/Screens/Select/PlaySongSelect.cs b/osu.Game/Screens/Select/PlaySongSelect.cs index 7b1479f392..b9a821d8ae 100644 --- a/osu.Game/Screens/Select/PlaySongSelect.cs +++ b/osu.Game/Screens/Select/PlaySongSelect.cs @@ -129,17 +129,11 @@ namespace osu.Game.Screens.Select if (replayGeneratingMod != null) { - player = new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods)) - { - LeaderboardScores = { BindTarget = playBeatmapDetailArea.Leaderboard.Scores } - }; + player = new ReplayPlayer((beatmap, mods) => replayGeneratingMod.CreateScoreFromReplayData(beatmap, mods), playBeatmapDetailArea.Leaderboard.LeaderboardProvider); } else { - player = new SoloPlayer - { - LeaderboardScores = { BindTarget = playBeatmapDetailArea.Leaderboard.Scores } - }; + player = new SoloPlayer(playBeatmapDetailArea.Leaderboard.LeaderboardProvider); } return player; diff --git a/osu.Game/Screens/SelectV2/SongSelectV2.cs b/osu.Game/Screens/SelectV2/SongSelectV2.cs index 2f9667793f..808838f440 100644 --- a/osu.Game/Screens/SelectV2/SongSelectV2.cs +++ b/osu.Game/Screens/SelectV2/SongSelectV2.cs @@ -114,7 +114,7 @@ namespace osu.Game.Screens.SelectV2 logo.Action = () => { - this.Push(new PlayerLoaderV2(() => new SoloPlayer())); + this.Push(new PlayerLoaderV2(() => new SoloPlayer(null))); return false; }; } diff --git a/osu.Game/Tests/Visual/TestPlayer.cs b/osu.Game/Tests/Visual/TestPlayer.cs index 579a1934e0..0caafb623f 100644 --- a/osu.Game/Tests/Visual/TestPlayer.cs +++ b/osu.Game/Tests/Visual/TestPlayer.cs @@ -52,7 +52,7 @@ namespace osu.Game.Tests.Visual private SpectatorClient spectatorClient { get; set; } public TestPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) - : base(new PlayerConfiguration + : base(null, new PlayerConfiguration { AllowPause = allowPause, ShowResults = showResults diff --git a/osu.Game/Tests/Visual/TestReplayPlayer.cs b/osu.Game/Tests/Visual/TestReplayPlayer.cs index 0c9b466152..d1211ced09 100644 --- a/osu.Game/Tests/Visual/TestReplayPlayer.cs +++ b/osu.Game/Tests/Visual/TestReplayPlayer.cs @@ -40,7 +40,7 @@ namespace osu.Game.Tests.Visual /// Instantiate a replay player that renders an autoplay mod. /// public TestReplayPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) - : base((beatmap, mods) => mods.OfType().First().CreateScoreFromReplayData(beatmap, mods), new PlayerConfiguration + : base((beatmap, mods) => mods.OfType().First().CreateScoreFromReplayData(beatmap, mods), null, new PlayerConfiguration { AllowPause = allowPause, ShowResults = showResults @@ -53,7 +53,7 @@ namespace osu.Game.Tests.Visual /// Instantiate a replay player that renders the provided replay. /// public TestReplayPlayer(Score score, bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) - : base(score, new PlayerConfiguration + : base(score, null, new PlayerConfiguration { AllowPause = allowPause, ShowResults = showResults