mirror of
https://github.com/ppy/osu.git
synced 2026-05-28 03:53:45 +08:00
Extract leaderboard fetch logic from song select beatmap leaderboard drawable
RFC. Another attempt at this. - Supersedes https://github.com/ppy/osu/pull/31881 - Supersedes / closes https://github.com/ppy/osu/pull/31355 - Closes https://github.com/ppy/osu/issues/29861 This is a weird diff because I am feeling rather boxed in by all the constraints, namely that: - Leaderboard state should be global state - But the global state is essentially managed by song select and namely `BeatmapLeaderboard` itself. That's because trying to e.g. not have `BeatmapLeaderboard` pass the beatmap and the ruleset to the global leaderboard manager is worse, as it essentially introduces two parallel paths of execution that need to be somehow merged into one (as in I'd have to somehow sync `LeaderboardManager` responding to beatmap/ruleset changes with `BeatmapLeaderboard` which is inheritance hell) - Also local leaderboard fetching is data-push (as in the scores can change under the leaderboard manager), and online leaderboard fetching is data-pull (as in the scores do not change unless the leaderboard manager does something). Also online leaderboard fetching can fail. Which is why I need to still have the weird setup wherein there's a `FetchWithCriteriaAsync()` (because I need to be able to respond to online requests taking time, or failing), but also the `BeatmapLeaderboard` only uses the public `Scores` bindable to actually read the scores (because it needs to respond to new local scores arriving). - Another thing to think about here is what happens when a retrieval fails because e.g. the user requested friend leaderboards without having supporter. With how this diff is written, that special condition is handled to `BeatmapLeaderboard`, and `LeaderboardManager`'s state will remain as whatever it was before that scope change was requested, which may be considered good or it may not (I imagine it's better to show scores in gameplay than not in this case, but maybe I'm wrong?)
This commit is contained in:
@@ -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<Screens.Select.SongSelect>(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()
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<LeaderboardScores?> Scores => scores;
|
||||
private readonly Bindable<LeaderboardScores?> scores = new Bindable<LeaderboardScores?>();
|
||||
|
||||
private LeaderboardCriteria? criteria;
|
||||
|
||||
private IDisposable? localScoreSubscription;
|
||||
private TaskCompletionSource<LeaderboardScores?>? localFetchCompletionSource;
|
||||
private TaskCompletionSource<LeaderboardScores?>? 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<LeaderboardScores?> 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<LeaderboardScores?>();
|
||||
localScoreSubscription = realm.RegisterForNotifications(r =>
|
||||
r.All<ScoreInfo>().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<LeaderboardScores?>();
|
||||
lastFetchCompletionSource = onlineFetchCompletionSource;
|
||||
|
||||
IReadOnlyList<Mod>? 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<ScoreInfo> 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<ScoreInfo> TopScores, ScoreInfo? UserScore);
|
||||
}
|
||||
@@ -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<IBindable<WorkingBeatmap>>(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);
|
||||
|
||||
@@ -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<LeaderboardScores?> fetchedScores = new Bindable<LeaderboardScores?>();
|
||||
|
||||
[Resolved]
|
||||
private IBindable<RulesetInfo> 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<Mod>? 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<ScoreInfo>().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<ScoreInfo> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user