// 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.Concurrent; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics.Containers; using osu.Framework.Lists; using osu.Framework.Logging; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; namespace osu.Game.Beatmaps { public class BeatmapDifficultyManager : CompositeDrawable { // Too many simultaneous updates can lead to stutters. One thread seems to work fine for song select display purposes. private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler(1, nameof(BeatmapDifficultyManager)); // A permanent cache to prevent re-computations. private readonly ConcurrentDictionary difficultyCache = new ConcurrentDictionary(); // All bindables that should be updated along with the current ruleset + mods. private readonly LockedWeakList trackedBindables = new LockedWeakList(); [Resolved] private BeatmapManager beatmapManager { get; set; } [Resolved] private Bindable currentRuleset { get; set; } [Resolved] private Bindable> currentMods { get; set; } protected override void LoadComplete() { base.LoadComplete(); currentRuleset.BindValueChanged(_ => updateTrackedBindables()); currentMods.BindValueChanged(_ => updateTrackedBindables(), true); } /// /// Retrieves a bindable containing the star difficulty of a that follows the currently-selected ruleset and mods. /// /// The to get the difficulty of. /// An optional which stops updating the star difficulty for the given . /// A bindable that is updated to contain the star difficulty when it becomes available. public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, CancellationToken cancellationToken = default) { var bindable = createBindable(beatmapInfo, currentRuleset.Value, currentMods.Value, cancellationToken); trackedBindables.Add(bindable); return bindable; } /// /// Retrieves a bindable containing the star difficulty of a with a given and combination. /// /// /// The bindable will not update to follow the currently-selected ruleset and mods. /// /// The to get the difficulty of. /// The to get the difficulty with. If null, the 's ruleset is used. /// The s to get the difficulty with. If null, no mods will be assumed. /// An optional which stops updating the star difficulty for the given . /// A bindable that is updated to contain the star difficulty when it becomes available. public IBindable GetBindableDifficulty([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo, [CanBeNull] IEnumerable mods, CancellationToken cancellationToken = default) => createBindable(beatmapInfo, rulesetInfo, mods, cancellationToken); /// /// Retrieves the difficulty of a . /// /// The to get the difficulty of. /// The to get the difficulty with. /// The s to get the difficulty with. /// An optional which stops computing the star difficulty. /// The . public async Task GetDifficultyAsync([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo = null, [CanBeNull] IEnumerable mods = null, CancellationToken cancellationToken = default) { if (tryGetExisting(beatmapInfo, rulesetInfo, mods, out var existing, out var key)) return existing; return await Task.Factory.StartNew(() => { // Computation may have finished in a previous task. if (tryGetExisting(beatmapInfo, rulesetInfo, mods, out existing, out _)) return existing; return computeDifficulty(key, beatmapInfo, rulesetInfo); }, cancellationToken, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); } /// /// Retrieves the difficulty of a . /// /// The to get the difficulty of. /// The to get the difficulty with. /// The s to get the difficulty with. /// The . public StarDifficulty GetDifficulty([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo = null, [CanBeNull] IEnumerable mods = null) { if (tryGetExisting(beatmapInfo, rulesetInfo, mods, out var existing, out var key)) return existing; return computeDifficulty(key, beatmapInfo, rulesetInfo); } /// /// Retrieves the that describes a star rating. /// /// /// For more information, see: https://osu.ppy.sh/help/wiki/Difficulties /// /// The star rating. /// The that best describes . public static DifficultyRating GetDifficultyRating(double starRating) { if (Precision.AlmostBigger(starRating, 6.5, 0.005)) return DifficultyRating.ExpertPlus; if (Precision.AlmostBigger(starRating, 5.3, 0.005)) return DifficultyRating.Expert; if (Precision.AlmostBigger(starRating, 4.0, 0.005)) return DifficultyRating.Insane; if (Precision.AlmostBigger(starRating, 2.7, 0.005)) return DifficultyRating.Hard; if (Precision.AlmostBigger(starRating, 2.0, 0.005)) return DifficultyRating.Normal; return DifficultyRating.Easy; } private CancellationTokenSource trackedUpdateCancellationSource; private readonly List linkedCancellationSources = new List(); /// /// Updates all tracked using the current ruleset and mods. /// private void updateTrackedBindables() { cancelTrackedBindableUpdate(); trackedUpdateCancellationSource = new CancellationTokenSource(); foreach (var b in trackedBindables) { var linkedSource = CancellationTokenSource.CreateLinkedTokenSource(trackedUpdateCancellationSource.Token, b.CancellationToken); linkedCancellationSources.Add(linkedSource); updateBindable(b, currentRuleset.Value, currentMods.Value, linkedSource.Token); } } /// /// Cancels the existing update of all tracked via . /// private void cancelTrackedBindableUpdate() { trackedUpdateCancellationSource?.Cancel(); trackedUpdateCancellationSource = null; if (linkedCancellationSources != null) { foreach (var c in linkedCancellationSources) c.Dispose(); linkedCancellationSources.Clear(); } } /// /// Creates a new and triggers an initial value update. /// /// The that star difficulty should correspond to. /// The initial to get the difficulty with. /// The initial s to get the difficulty with. /// An optional which stops updating the star difficulty for the given . /// The . private BindableStarDifficulty createBindable([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo initialRulesetInfo, [CanBeNull] IEnumerable initialMods, CancellationToken cancellationToken) { var bindable = new BindableStarDifficulty(beatmapInfo, cancellationToken); updateBindable(bindable, initialRulesetInfo, initialMods, cancellationToken); return bindable; } /// /// Updates the value of a with a given ruleset + mods. /// /// The to update. /// The to update with. /// The s to update with. /// A token that may be used to cancel this update. private void updateBindable([NotNull] BindableStarDifficulty bindable, [CanBeNull] RulesetInfo rulesetInfo, [CanBeNull] IEnumerable mods, CancellationToken cancellationToken = default) { GetDifficultyAsync(bindable.Beatmap, rulesetInfo, mods, cancellationToken).ContinueWith(t => { // We're on a threadpool thread, but we should exit back to the update thread so consumers can safely handle value-changed events. Schedule(() => { if (!cancellationToken.IsCancellationRequested) bindable.Value = t.Result; }); }, cancellationToken); } /// /// Computes the difficulty defined by a key, and stores it to the timed cache. /// /// The that defines the computation parameters. /// The to compute the difficulty of. /// The to compute the difficulty with. /// The . private StarDifficulty computeDifficulty(in DifficultyCacheLookup key, BeatmapInfo beatmapInfo, RulesetInfo rulesetInfo) { // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. rulesetInfo ??= beatmapInfo.Ruleset; try { var ruleset = rulesetInfo.CreateInstance(); Debug.Assert(ruleset != null); var calculator = ruleset.CreateDifficultyCalculator(beatmapManager.GetWorkingBeatmap(beatmapInfo)); var attributes = calculator.Calculate(key.Mods); return difficultyCache[key] = new StarDifficulty(attributes); } catch (BeatmapInvalidForRulesetException e) { // Conversion has failed for the given ruleset, so return the difficulty in the beatmap's default ruleset. // Ensure the beatmap's default ruleset isn't the one already being converted to. // This shouldn't happen as it means something went seriously wrong, but if it does an endless loop should be avoided. if (rulesetInfo.Equals(beatmapInfo.Ruleset)) { Logger.Error(e, $"Failed to convert {beatmapInfo.OnlineBeatmapID} to the beatmap's default ruleset ({beatmapInfo.Ruleset})."); return difficultyCache[key] = new StarDifficulty(); } // Check the cache first because this is now a different ruleset than the one previously guarded against. if (tryGetExisting(beatmapInfo, beatmapInfo.Ruleset, Array.Empty(), out var existingDefault, out var existingDefaultKey)) return existingDefault; return computeDifficulty(existingDefaultKey, beatmapInfo, beatmapInfo.Ruleset); } catch { return difficultyCache[key] = new StarDifficulty(); } } /// /// Attempts to retrieve an existing difficulty for the combination. /// /// The . /// The . /// The s. /// The existing difficulty value, if present. /// The key that was used to perform this lookup. This can be further used to query . /// Whether an existing difficulty was found. private bool tryGetExisting(BeatmapInfo beatmapInfo, RulesetInfo rulesetInfo, IEnumerable mods, out StarDifficulty existingDifficulty, out DifficultyCacheLookup key) { // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. rulesetInfo ??= beatmapInfo.Ruleset; // Difficulty can only be computed if the beatmap and ruleset are locally available. if (beatmapInfo.ID == 0 || rulesetInfo.ID == null) { // If not, fall back to the existing star difficulty (e.g. from an online source). existingDifficulty = new StarDifficulty(beatmapInfo.StarDifficulty, beatmapInfo.MaxCombo ?? 0); key = default; return true; } key = new DifficultyCacheLookup(beatmapInfo.ID, rulesetInfo.ID.Value, mods); return difficultyCache.TryGetValue(key, out existingDifficulty); } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); cancelTrackedBindableUpdate(); updateScheduler?.Dispose(); } public readonly struct DifficultyCacheLookup : IEquatable { public readonly int BeatmapId; public readonly int RulesetId; public readonly Mod[] Mods; public DifficultyCacheLookup(int beatmapId, int rulesetId, IEnumerable mods) { BeatmapId = beatmapId; RulesetId = rulesetId; Mods = mods?.OrderBy(m => m.Acronym).ToArray() ?? Array.Empty(); } public bool Equals(DifficultyCacheLookup other) => BeatmapId == other.BeatmapId && RulesetId == other.RulesetId && Mods.Select(m => m.Acronym).SequenceEqual(other.Mods.Select(m => m.Acronym)); public override int GetHashCode() { var hashCode = new HashCode(); hashCode.Add(BeatmapId); hashCode.Add(RulesetId); foreach (var mod in Mods) hashCode.Add(mod.Acronym); return hashCode.ToHashCode(); } } private class BindableStarDifficulty : Bindable { public readonly BeatmapInfo Beatmap; public readonly CancellationToken CancellationToken; public BindableStarDifficulty(BeatmapInfo beatmap, CancellationToken cancellationToken) { Beatmap = beatmap; CancellationToken = cancellationToken; } } } public readonly struct StarDifficulty { /// /// The star difficulty rating for the given beatmap. /// public readonly double Stars; /// /// The maximum combo achievable on the given beatmap. /// public readonly int MaxCombo; /// /// The difficulty attributes computed for the given beatmap. /// Might not be available if the star difficulty is associated with a beatmap that's not locally available. /// [CanBeNull] public readonly DifficultyAttributes Attributes; /// /// Creates a structure based on computed /// by a . /// public StarDifficulty([NotNull] DifficultyAttributes attributes) { Stars = attributes.StarRating; MaxCombo = attributes.MaxCombo; Attributes = attributes; // Todo: Add more members (BeatmapInfo.DifficultyRating? Attributes? Etc...) } /// /// Creates a structure with a pre-populated star difficulty and max combo /// in scenarios where computing is not feasible (i.e. when working with online sources). /// public StarDifficulty(double starDifficulty, int maxCombo) { Stars = starDifficulty; MaxCombo = maxCombo; Attributes = null; } public DifficultyRating DifficultyRating => BeatmapDifficultyManager.GetDifficultyRating(Stars); } }