// 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.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.Lists; using osu.Framework.Logging; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Database; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.UI; namespace osu.Game.Beatmaps { /// /// A component which performs and acts as a central cache for difficulty calculations of beatmap/ruleset/mod combinations. /// Currently not persisted between game sessions. /// public class BeatmapDifficultyCache : MemoryCachingComponent { // 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(BeatmapDifficultyCache)); // All bindables that should be updated along with the current ruleset + mods. private readonly WeakList trackedBindables = new WeakList(); [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); lock (trackedBindables) 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 Task GetDifficultyAsync([NotNull] BeatmapInfo beatmapInfo, [CanBeNull] RulesetInfo rulesetInfo = null, [CanBeNull] IEnumerable mods = null, CancellationToken cancellationToken = default) { // 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). return Task.FromResult(new StarDifficulty(beatmapInfo.StarDifficulty, beatmapInfo.MaxCombo ?? 0)); } return GetAsync(new DifficultyCacheLookup(beatmapInfo, rulesetInfo, mods), cancellationToken); } protected override Task ComputeValueAsync(DifficultyCacheLookup lookup, CancellationToken token = default) { return Task.Factory.StartNew(() => { if (CheckExists(lookup, out var existing)) return existing; return computeDifficulty(lookup); }, token, TaskCreationOptions.HideScheduler | TaskCreationOptions.RunContinuationsAsynchronously, updateScheduler); } /// /// 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() { lock (trackedBindables) { 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() { lock (trackedBindables) { 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 will fall back to existing data from BeatmapInfo if not locally available // (contrary to GetAsync) 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 . private StarDifficulty computeDifficulty(in DifficultyCacheLookup key) { // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. var beatmapInfo = key.Beatmap; var rulesetInfo = key.Ruleset; try { var ruleset = rulesetInfo.CreateInstance(); Debug.Assert(ruleset != null); var calculator = ruleset.CreateDifficultyCalculator(beatmapManager.GetWorkingBeatmap(key.Beatmap)); var attributes = calculator.Calculate(key.OrderedMods); return 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 new StarDifficulty(); } return GetAsync(new DifficultyCacheLookup(key.Beatmap, key.Beatmap.Ruleset, key.OrderedMods)).Result; } catch { return new StarDifficulty(); } } protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); cancelTrackedBindableUpdate(); updateScheduler?.Dispose(); } public readonly struct DifficultyCacheLookup : IEquatable { public readonly BeatmapInfo Beatmap; public readonly RulesetInfo Ruleset; public readonly Mod[] OrderedMods; public DifficultyCacheLookup([NotNull] BeatmapInfo beatmap, [CanBeNull] RulesetInfo ruleset, IEnumerable mods) { Beatmap = beatmap; // In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset. Ruleset = ruleset ?? Beatmap.Ruleset; OrderedMods = mods?.OrderBy(m => m.Acronym).ToArray() ?? Array.Empty(); } public bool Equals(DifficultyCacheLookup other) => Beatmap.ID == other.Beatmap.ID && Ruleset.ID == other.Ruleset.ID && OrderedMods.Select(m => m.Acronym).SequenceEqual(other.OrderedMods.Select(m => m.Acronym)); public override int GetHashCode() { var hashCode = new HashCode(); hashCode.Add(Beatmap.ID); hashCode.Add(Ruleset.ID); foreach (var mod in OrderedMods) 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; } } } }