1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-18 18:29:58 +08:00
Files
osu-lazer/osu.Game/Online/Leaderboards/LeaderboardManager.cs
T

258 lines
11 KiB
C#

// 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 osu.Framework.Allocation;
using osu.Framework.Bindables;
using osu.Framework.Development;
using osu.Framework.Graphics;
using osu.Framework.Logging;
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.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
{
/// <summary>
/// The latest leaderboard scores fetched by the criteria in <see cref="CurrentCriteria"/>.
/// </summary>
public IBindable<LeaderboardScores?> Scores => scores;
private readonly Bindable<LeaderboardScores?> scores = new Bindable<LeaderboardScores?>();
public LeaderboardCriteria? CurrentCriteria { get; private set; }
private IDisposable? localScoreSubscription;
private GetScoresRequest? inFlightOnlineRequest;
[Resolved]
private IAPIProvider api { get; set; } = null!;
[Resolved]
private RealmAccess realm { get; set; } = null!;
[Resolved]
private RulesetStore rulesets { get; set; } = null!;
/// <summary>
/// Fetch leaderboard content with the new criteria specified in the background.
/// On completion, <see cref="Scores"/> will be updated with the results from this call (unless a more recent call with a different criteria has completed).
/// </summary>
public void FetchWithCriteria(LeaderboardCriteria newCriteria, bool forceRefresh = false)
{
if (!ThreadSafety.IsUpdateThread)
throw new InvalidOperationException(@$"{nameof(FetchWithCriteria)} must be called from the update thread.");
if (!forceRefresh && CurrentCriteria?.Equals(newCriteria) == true && scores.Value?.FailState == null)
return;
CurrentCriteria = newCriteria;
localScoreSubscription?.Dispose();
inFlightOnlineRequest?.Cancel();
scores.Value = null;
if (newCriteria.Beatmap == null || newCriteria.Ruleset == null)
{
scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NoneSelected);
return;
}
switch (newCriteria.Scope)
{
case BeatmapLeaderboardScope.Local:
{
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;
}
default:
{
if (newCriteria.Sorting != LeaderboardSortMode.Score)
throw new NotSupportedException($@"Requesting online scores with a {nameof(LeaderboardSortMode)} other than {nameof(LeaderboardSortMode.Score)} is not supported");
if (!api.IsLoggedIn)
{
scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NotLoggedIn);
return;
}
if (!newCriteria.Ruleset.IsLegacyRuleset())
{
scores.Value = LeaderboardScores.Failure(LeaderboardFailState.RulesetUnavailable);
return;
}
if (newCriteria.Beatmap.OnlineID <= 0 || newCriteria.Beatmap.Status <= BeatmapOnlineStatus.Pending)
{
scores.Value = LeaderboardScores.Failure(LeaderboardFailState.BeatmapUnavailable);
return;
}
if ((newCriteria.Scope.RequiresSupporter(newCriteria.ExactMods != null)) && !api.LocalUser.Value.IsSupporter)
{
scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NotSupporter);
return;
}
if (newCriteria.Scope == BeatmapLeaderboardScope.Team && api.LocalUser.Value.Team == null)
{
scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NoTeam);
return;
}
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 = LeaderboardScores.Success
(
response.Scores.Select(s => s.ToScoreInfo(rulesets, newCriteria.Beatmap))
.OrderByTotalScore()
.Select((s, idx) =>
{
s.Position = idx + 1;
return s;
})
.ToArray(),
response.ScoresCount,
response.UserScore?.CreateScoreInfo(rulesets, newCriteria.Beatmap)
);
inFlightOnlineRequest = null;
scores.Value = result;
};
newRequest.Failure += ex =>
{
Logger.Log($@"Failed to fetch leaderboards when displaying results: {ex}", LoggingTarget.Network);
if (ex is not OperationCanceledException)
scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NetworkFailure);
};
api.Queue(inFlightOnlineRequest = newRequest);
break;
}
}
}
private void localScoresChanged(IRealmCollection<ScoreInfo> sender, ChangeSet? changes)
{
Debug.Assert(CurrentCriteria != 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 (CurrentCriteria.ExactMods != null)
{
if (!CurrentCriteria.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 = CurrentCriteria.ExactMods.Select(m => m.Acronym).ToHashSet();
newScores = newScores.Where(s => selectedMods.SetEquals(s.Mods.Select(m => m.Acronym)));
}
}
newScores = newScores.Detach().OrderByCriteria(CurrentCriteria.Sorting);
var newScoresArray = newScores.ToArray();
scores.Value = LeaderboardScores.Success(newScoresArray, newScoresArray.Length, null);
}
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
localScoreSubscription?.Dispose();
}
}
public record LeaderboardCriteria(
BeatmapInfo? Beatmap,
RulesetInfo? Ruleset,
BeatmapLeaderboardScope Scope,
Mod[]? ExactMods,
LeaderboardSortMode Sorting = LeaderboardSortMode.Score
);
public record LeaderboardScores
{
public ICollection<ScoreInfo> TopScores { get; }
public int TotalScores { get; }
public ScoreInfo? UserScore { get; }
public LeaderboardFailState? FailState { get; }
public IEnumerable<ScoreInfo> AllScores
{
get
{
foreach (var score in TopScores)
yield return score;
if (UserScore != null && TopScores.All(topScore => !topScore.Equals(UserScore) && !topScore.MatchesOnlineID(UserScore)))
yield return UserScore;
}
}
private LeaderboardScores(ICollection<ScoreInfo> topScores, int totalScores, ScoreInfo? userScore, LeaderboardFailState? failState)
{
TopScores = topScores;
TotalScores = totalScores;
UserScore = userScore;
FailState = failState;
}
public static LeaderboardScores Success(ICollection<ScoreInfo> topScores, int totalScores, ScoreInfo? userScore) => new LeaderboardScores(topScores, totalScores, userScore, null);
public static LeaderboardScores Failure(LeaderboardFailState failState) => new LeaderboardScores([], 0, null, failState);
}
public enum LeaderboardFailState
{
NetworkFailure = -1,
BeatmapUnavailable = -2,
RulesetUnavailable = -3,
NoneSelected = -4,
NotLoggedIn = -5,
NotSupporter = -6,
NoTeam = -7
}
}