// 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.Collections.Generic; using System.Linq; using System.Linq.Expressions; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Extensions; using osu.Framework.Platform; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.IO.Archives; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Rulesets.Scoring; namespace osu.Game.Scoring { public class ScoreManager : ModelManager, IModelImporter { private readonly Scheduler scheduler; private readonly BeatmapDifficultyCache difficultyCache; private readonly OsuConfigManager configManager; private readonly ScoreImporter scoreImporter; public ScoreManager(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm, Scheduler scheduler, BeatmapDifficultyCache difficultyCache = null, OsuConfigManager configManager = null) : base(storage, realm) { this.scheduler = scheduler; this.difficultyCache = difficultyCache; this.configManager = configManager; scoreImporter = new ScoreImporter(rulesets, beatmaps, storage, realm) { PostNotification = obj => PostNotification?.Invoke(obj) }; } public Score GetScore(ScoreInfo score) => scoreImporter.GetScore(score); /// /// Perform a lookup query on available s. /// /// The query. /// The first result for the provided query, or null if no results were found. public ScoreInfo Query(Expression> query) { return Realm.Run(r => r.All().FirstOrDefault(query)?.Detach()); } /// /// Orders an array of s by total score. /// /// The array of s to reorder. /// A to cancel the process. /// The given ordered by decreasing total score. public async Task OrderByTotalScoreAsync(ScoreInfo[] scores, CancellationToken cancellationToken = default) { if (difficultyCache != null) { // Compute difficulties asynchronously first to prevent blocking via the GetTotalScore() call below. foreach (var s in scores) { await difficultyCache.GetDifficultyAsync(s.BeatmapInfo, s.Ruleset, s.Mods, cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); } } long[] totalScores = await Task.WhenAll(scores.Select(s => GetTotalScoreAsync(s, cancellationToken: cancellationToken))).ConfigureAwait(false); return scores.Select((score, index) => (score, totalScore: totalScores[index])) .OrderByDescending(g => g.totalScore) .ThenBy(g => g.score.OnlineID) .Select(g => g.score) .ToArray(); } /// /// Retrieves a bindable that represents the total score of a . /// /// /// Responds to changes in the currently-selected . /// /// The to retrieve the bindable for. /// The bindable containing the total score. public Bindable GetBindableTotalScore([NotNull] ScoreInfo score) => new TotalScoreBindable(score, this, configManager); /// /// Retrieves a bindable that represents the formatted total score string of a . /// /// /// Responds to changes in the currently-selected . /// /// The to retrieve the bindable for. /// The bindable containing the formatted total score string. public Bindable GetBindableTotalScoreString([NotNull] ScoreInfo score) => new TotalScoreStringBindable(GetBindableTotalScore(score)); /// /// Retrieves the total score of a in the given . /// The score is returned in a callback that is run on the update thread. /// /// The to calculate the total score of. /// The callback to be invoked with the total score. /// The to return the total score as. /// A to cancel the process. public void GetTotalScore([NotNull] ScoreInfo score, [NotNull] Action callback, ScoringMode mode = ScoringMode.Standardised, CancellationToken cancellationToken = default) { GetTotalScoreAsync(score, mode, cancellationToken) .ContinueWith(task => scheduler.Add(() => { if (!cancellationToken.IsCancellationRequested) callback(task.GetResultSafely()); }), TaskContinuationOptions.OnlyOnRanToCompletion); } /// /// Retrieves the total score of a in the given . /// /// The to calculate the total score of. /// The to return the total score as. /// A to cancel the process. /// The total score. public async Task GetTotalScoreAsync([NotNull] ScoreInfo score, ScoringMode mode = ScoringMode.Standardised, CancellationToken cancellationToken = default) { // TODO: This is required for playlist aggregate scores. They should likely not be getting here in the first place. if (string.IsNullOrEmpty(score.BeatmapInfo.MD5Hash)) return score.TotalScore; int? beatmapMaxCombo = await GetMaximumAchievableComboAsync(score, cancellationToken).ConfigureAwait(false); if (beatmapMaxCombo == null) return score.TotalScore; if (beatmapMaxCombo == 0) return 0; var ruleset = score.Ruleset.CreateInstance(); var scoreProcessor = ruleset.CreateScoreProcessor(); scoreProcessor.Mods.Value = score.Mods; return (long)Math.Round(scoreProcessor.ComputeFinalLegacyScore(mode, score, beatmapMaxCombo.Value)); } /// /// Retrieves the maximum achievable combo for the provided score. /// /// The to compute the maximum achievable combo for. /// A to cancel the process. /// The maximum achievable combo. A return value indicates the difficulty cache has failed to retrieve the combo. public async Task GetMaximumAchievableComboAsync([NotNull] ScoreInfo score, CancellationToken cancellationToken = default) { if (score.IsLegacyScore) { // This score is guaranteed to be an osu!stable score. // The combo must be determined through either the beatmap's max combo value or the difficulty calculator, as lazer's scoring has changed and the score statistics cannot be used. #pragma warning disable CS0618 if (score.BeatmapInfo.MaxCombo != null) return score.BeatmapInfo.MaxCombo.Value; #pragma warning restore CS0618 if (difficultyCache == null) return null; // We can compute the max combo locally after the async beatmap difficulty computation. var difficulty = await difficultyCache.GetDifficultyAsync(score.BeatmapInfo, score.Ruleset, score.Mods, cancellationToken).ConfigureAwait(false); return difficulty?.MaxCombo; } // This is guaranteed to be a non-legacy score. // The combo must be determined through the score's statistics, as both the beatmap's max combo and the difficulty calculator will provide osu!stable combo values. return Enum.GetValues(typeof(HitResult)).OfType().Where(r => r.AffectsCombo()).Select(r => score.Statistics.GetValueOrDefault(r)).Sum(); } /// /// Provides the total score of a . Responds to changes in the currently-selected . /// private class TotalScoreBindable : Bindable { private readonly Bindable scoringMode = new Bindable(); private readonly ScoreInfo score; private readonly ScoreManager scoreManager; private CancellationTokenSource difficultyCalculationCancellationSource; /// /// Creates a new . /// /// The to provide the total score of. /// The . /// The config. public TotalScoreBindable(ScoreInfo score, ScoreManager scoreManager, OsuConfigManager configManager) { this.score = score; this.scoreManager = scoreManager; configManager?.BindWith(OsuSetting.ScoreDisplayMode, scoringMode); scoringMode.BindValueChanged(onScoringModeChanged, true); } private void onScoringModeChanged(ValueChangedEvent mode) { difficultyCalculationCancellationSource?.Cancel(); difficultyCalculationCancellationSource = new CancellationTokenSource(); scoreManager.GetTotalScore(score, s => Value = s, mode.NewValue, difficultyCalculationCancellationSource.Token); } } /// /// Provides the total score of a as a formatted string. Responds to changes in the currently-selected . /// private class TotalScoreStringBindable : Bindable { // ReSharper disable once PrivateFieldCanBeConvertedToLocalVariable (need to hold a reference) private readonly IBindable totalScore; public TotalScoreStringBindable(IBindable totalScore) { this.totalScore = totalScore; this.totalScore.BindValueChanged(v => Value = v.NewValue.ToString("N0"), true); } } public void Delete([CanBeNull] Expression> filter = null, bool silent = false) { Realm.Run(r => { var items = r.All() .Where(s => !s.DeletePending); if (filter != null) items = items.Where(filter); Delete(items.ToList(), silent); }); } public void Delete(BeatmapInfo beatmap, bool silent = false) { Realm.Run(r => { var beatmapScores = r.Find(beatmap.ID).Scores.ToList(); Delete(beatmapScores, silent); }); } public Task Import(params string[] paths) => scoreImporter.Import(paths); public Task Import(params ImportTask[] tasks) => scoreImporter.Import(tasks); public override bool IsAvailableLocally(ScoreInfo model) => Realm.Run(realm => realm.All().Any(s => s.OnlineID == model.OnlineID)); public IEnumerable HandledExtensions => scoreImporter.HandledExtensions; public Task>> Import(ProgressNotification notification, params ImportTask[] tasks) => scoreImporter.Import(notification, tasks); public Live Import(ScoreInfo item, ArchiveReader archive = null, bool batchImport = false, CancellationToken cancellationToken = default) => scoreImporter.ImportModel(item, archive, batchImport, cancellationToken); #region Implementation of IPresentImports public Action>> PresentImport { set => scoreImporter.PresentImport = value; } #endregion } }