diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs index a003b9ae4d..f0ddefa51d 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentBeatmap.cs @@ -55,8 +55,14 @@ namespace osu.Game.Tests.Visual.Navigation var secondimport = importBeatmap(3); presentAndConfirm(secondimport); + // Test presenting same beatmap more than once + presentAndConfirm(secondimport); + presentSecondDifficultyAndConfirm(firstImport, 1); presentSecondDifficultyAndConfirm(secondimport, 3); + + // Test presenting same beatmap more than once + presentSecondDifficultyAndConfirm(secondimport, 3); } [Test] diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs new file mode 100644 index 0000000000..53a956c77c --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapRecommendations.cs @@ -0,0 +1,211 @@ +// 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.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; +using osu.Game.Tests.Visual.Navigation; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.SongSelect +{ + public class TestSceneBeatmapRecommendations : OsuGameTestScene + { + [SetUpSteps] + public override void SetUpSteps() + { + AddStep("register request handling", () => + { + ((DummyAPIAccess)API).HandleRequest = req => + { + switch (req) + { + case GetUserRequest userRequest: + userRequest.TriggerSuccess(getUser(userRequest.Ruleset.ID)); + break; + } + }; + }); + + base.SetUpSteps(); + + User getUser(int? rulesetID) + { + return new User + { + Username = @"Dummy", + Id = 1001, + Statistics = new UserStatistics + { + PP = getNecessaryPP(rulesetID) + } + }; + } + + decimal getNecessaryPP(int? rulesetID) + { + switch (rulesetID) + { + case 0: + return 336; // recommended star rating of 2 + + case 1: + return 928; // SR 3 + + case 2: + return 1905; // SR 4 + + case 3: + return 3329; // SR 5 + + default: + return 0; + } + } + } + + [Test] + public void TestPresentedBeatmapIsRecommended() + { + List beatmapSets = null; + const int import_count = 5; + + AddStep("import 5 maps", () => + { + beatmapSets = new List(); + + for (int i = 0; i < import_count; ++i) + { + beatmapSets.Add(importBeatmapSet(i, Enumerable.Repeat(new OsuRuleset().RulesetInfo, 5))); + } + }); + + AddAssert("all sets imported", () => ensureAllBeatmapSetsImported(beatmapSets)); + + presentAndConfirm(() => beatmapSets[3], 2); + } + + [Test] + public void TestCurrentRulesetIsRecommended() + { + BeatmapSetInfo catchSet = null, mixedSet = null; + + AddStep("create catch beatmapset", () => catchSet = importBeatmapSet(0, new[] { new CatchRuleset().RulesetInfo })); + AddStep("create mixed beatmapset", () => mixedSet = importBeatmapSet(1, + new[] { new TaikoRuleset().RulesetInfo, new CatchRuleset().RulesetInfo, new ManiaRuleset().RulesetInfo })); + + AddAssert("all sets imported", () => ensureAllBeatmapSetsImported(new[] { catchSet, mixedSet })); + + // Switch to catch + presentAndConfirm(() => catchSet, 1); + + // Present mixed difficulty set, expect current ruleset to be selected + presentAndConfirm(() => mixedSet, 2); + } + + [Test] + public void TestBestRulesetIsRecommended() + { + BeatmapSetInfo osuSet = null, mixedSet = null; + + AddStep("create osu! beatmapset", () => osuSet = importBeatmapSet(0, new[] { new OsuRuleset().RulesetInfo })); + AddStep("create mixed beatmapset", () => mixedSet = importBeatmapSet(1, + new[] { new TaikoRuleset().RulesetInfo, new CatchRuleset().RulesetInfo, new ManiaRuleset().RulesetInfo })); + + AddAssert("all sets imported", () => ensureAllBeatmapSetsImported(new[] { osuSet, mixedSet })); + + // Make sure we are on standard ruleset + presentAndConfirm(() => osuSet, 1); + + // Present mixed difficulty set, expect ruleset with highest star difficulty + presentAndConfirm(() => mixedSet, 3); + } + + [Test] + public void TestSecondBestRulesetIsRecommended() + { + BeatmapSetInfo osuSet = null, mixedSet = null; + + AddStep("create osu! beatmapset", () => osuSet = importBeatmapSet(0, new[] { new OsuRuleset().RulesetInfo })); + AddStep("create mixed beatmapset", () => mixedSet = importBeatmapSet(1, + new[] { new TaikoRuleset().RulesetInfo, new CatchRuleset().RulesetInfo, new TaikoRuleset().RulesetInfo })); + + AddAssert("all sets imported", () => ensureAllBeatmapSetsImported(new[] { osuSet, mixedSet })); + + // Make sure we are on standard ruleset + presentAndConfirm(() => osuSet, 1); + + // Present mixed difficulty set, expect ruleset with second highest star difficulty + presentAndConfirm(() => mixedSet, 2); + } + + [Test] + public void TestCorrectStarRatingIsUsed() + { + BeatmapSetInfo osuSet = null, maniaSet = null; + + AddStep("create osu! beatmapset", () => osuSet = importBeatmapSet(0, new[] { new OsuRuleset().RulesetInfo })); + AddStep("create mania beatmapset", () => maniaSet = importBeatmapSet(1, Enumerable.Repeat(new ManiaRuleset().RulesetInfo, 10))); + + AddAssert("all sets imported", () => ensureAllBeatmapSetsImported(new[] { osuSet, maniaSet })); + + // Make sure we are on standard ruleset + presentAndConfirm(() => osuSet, 1); + + // Present mania set, expect the difficulty that matches recommended mania star rating + presentAndConfirm(() => maniaSet, 5); + } + + private BeatmapSetInfo importBeatmapSet(int importID, IEnumerable difficultyRulesets) + { + var metadata = new BeatmapMetadata + { + Artist = "SomeArtist", + AuthorString = "SomeAuthor", + Title = $"import {importID}" + }; + + var beatmapSet = new BeatmapSetInfo + { + Hash = Guid.NewGuid().ToString(), + OnlineBeatmapSetID = importID, + Metadata = metadata, + Beatmaps = difficultyRulesets.Select((ruleset, difficultyIndex) => new BeatmapInfo + { + OnlineBeatmapID = importID * 1024 + difficultyIndex, + Metadata = metadata, + BaseDifficulty = new BeatmapDifficulty(), + Ruleset = ruleset, + StarDifficulty = difficultyIndex + 1, + Version = $"SR{difficultyIndex + 1}" + }).ToList() + }; + + return Game.BeatmapManager.Import(beatmapSet).Result; + } + + private bool ensureAllBeatmapSetsImported(IEnumerable beatmapSets) => beatmapSets.All(set => set != null); + + private void presentAndConfirm(Func getImport, int expectedDiff) + { + AddStep("present beatmap", () => Game.PresentBeatmap(getImport())); + + AddUntilStep("wait for song select", () => Game.ScreenStack.CurrentScreen is Screens.Select.SongSelect); + AddUntilStep("recommended beatmap displayed", () => + { + int? expectedID = getImport().Beatmaps[expectedDiff - 1].OnlineBeatmapID; + return Game.Beatmap.Value.BeatmapInfo.OnlineBeatmapID == expectedID; + }); + } + } +} diff --git a/osu.Game/Screens/Select/DifficultyRecommender.cs b/osu.Game/Beatmaps/DifficultyRecommender.cs similarity index 56% rename from osu.Game/Screens/Select/DifficultyRecommender.cs rename to osu.Game/Beatmaps/DifficultyRecommender.cs index ff54e0a8df..340c47d89b 100644 --- a/osu.Game/Screens/Select/DifficultyRecommender.cs +++ b/osu.Game/Beatmaps/DifficultyRecommender.cs @@ -4,17 +4,21 @@ using System; using System.Collections.Generic; using System.Linq; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; -using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Rulesets; -namespace osu.Game.Screens.Select +namespace osu.Game.Beatmaps { + /// + /// A class which will recommend the most suitable difficulty for the local user from a beatmap set. + /// This requires the user to be logged in, as it sources from the user's online profile. + /// public class DifficultyRecommender : Component { [Resolved] @@ -26,7 +30,12 @@ namespace osu.Game.Screens.Select [Resolved] private Bindable ruleset { get; set; } - private readonly Dictionary recommendedStarDifficulty = new Dictionary(); + /// + /// The user for which the last requests were run. + /// + private int? requestedUserId; + + private readonly Dictionary recommendedDifficultyMapping = new Dictionary(); private readonly IBindable apiState = new Bindable(); @@ -45,42 +54,64 @@ namespace osu.Game.Screens.Select /// /// A collection of beatmaps to select a difficulty from. /// The recommended difficulty, or null if a recommendation could not be provided. + [CanBeNull] public BeatmapInfo GetRecommendedBeatmap(IEnumerable beatmaps) { - if (recommendedStarDifficulty.TryGetValue(ruleset.Value, out var stars)) + foreach (var r in orderedRulesets) { - return beatmaps.OrderBy(b => + if (!recommendedDifficultyMapping.TryGetValue(r, out var recommendation)) + continue; + + BeatmapInfo beatmap = beatmaps.Where(b => b.Ruleset.Equals(r)).OrderBy(b => { - var difference = b.StarDifficulty - stars; + var difference = b.StarDifficulty - recommendation; return difference >= 0 ? difference * 2 : difference * -1; // prefer easier over harder }).FirstOrDefault(); + + if (beatmap != null) + return beatmap; } return null; } - private void calculateRecommendedDifficulties() + private void fetchRecommendedValues() { - rulesets.AvailableRulesets.ForEach(rulesetInfo => + if (recommendedDifficultyMapping.Count > 0 && api.LocalUser.Value.Id == requestedUserId) + return; + + requestedUserId = api.LocalUser.Value.Id; + + // only query API for built-in rulesets + rulesets.AvailableRulesets.Where(ruleset => ruleset.ID <= ILegacyRuleset.MAX_LEGACY_RULESET_ID).ForEach(rulesetInfo => { var req = new GetUserRequest(api.LocalUser.Value.Id, rulesetInfo); req.Success += result => { // algorithm taken from https://github.com/ppy/osu-web/blob/e6e2825516449e3d0f3f5e1852c6bdd3428c3437/app/Models/User.php#L1505 - recommendedStarDifficulty[rulesetInfo] = Math.Pow((double)(result.Statistics.PP ?? 0), 0.4) * 0.195; + recommendedDifficultyMapping[rulesetInfo] = Math.Pow((double)(result.Statistics.PP ?? 0), 0.4) * 0.195; }; api.Queue(req); }); } + /// + /// Rulesets ordered descending by their respective recommended difficulties. + /// The currently selected ruleset will always be first. + /// + private IEnumerable orderedRulesets => + recommendedDifficultyMapping + .OrderByDescending(pair => pair.Value).Select(pair => pair.Key).Where(r => !r.Equals(ruleset.Value)) + .Prepend(ruleset.Value); + private void onlineStateChanged(ValueChangedEvent state) => Schedule(() => { switch (state.NewValue) { case APIState.Online: - calculateRecommendedDifficulties(); + fetchRecommendedValues(); break; } }); diff --git a/osu.Game/Online/API/Requests/GetUserRequest.cs b/osu.Game/Online/API/Requests/GetUserRequest.cs index 31b7e95b39..42aad6f9eb 100644 --- a/osu.Game/Online/API/Requests/GetUserRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserRequest.cs @@ -9,14 +9,14 @@ namespace osu.Game.Online.API.Requests public class GetUserRequest : APIRequest { private readonly long? userId; - private readonly RulesetInfo ruleset; + public readonly RulesetInfo Ruleset; public GetUserRequest(long? userId = null, RulesetInfo ruleset = null) { this.userId = userId; - this.ruleset = ruleset; + Ruleset = ruleset; } - protected override string Target => userId.HasValue ? $@"users/{userId}/{ruleset?.ShortName}" : $@"me/{ruleset?.ShortName}"; + protected override string Target => userId.HasValue ? $@"users/{userId}/{Ruleset?.ShortName}" : $@"me/{Ruleset?.ShortName}"; } } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 1e342fa464..bb51c55551 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -80,6 +80,9 @@ namespace osu.Game private BeatmapSetOverlay beatmapSetOverlay; + [Cached] + private readonly DifficultyRecommender difficultyRecommender = new DifficultyRecommender(); + [Cached] private readonly ScreenshotManager screenshotManager = new ScreenshotManager(); @@ -335,15 +338,17 @@ namespace osu.Game /// The user should have already requested this interactively. /// /// The beatmap to select. - /// - /// Optional predicate used to try and find a difficulty to select. - /// If omitted, this will try to present the first beatmap from the current ruleset. - /// In case of failure the first difficulty of the set will be presented, ignoring the predicate. - /// + /// Optional predicate used to narrow the set of difficulties to select from when presenting. + /// + /// Among items satisfying the predicate, the order of preference is: + /// + /// beatmap with recommended difficulty, as provided by , + /// first beatmap from the current ruleset, + /// first beatmap from any ruleset. + /// + /// public void PresentBeatmap(BeatmapSetInfo beatmap, Predicate difficultyCriteria = null) { - difficultyCriteria ??= b => b.Ruleset.Equals(Ruleset.Value); - var databasedSet = beatmap.OnlineBeatmapSetID != null ? BeatmapManager.QueryBeatmapSet(s => s.OnlineBeatmapSetID == beatmap.OnlineBeatmapSetID) : BeatmapManager.QueryBeatmapSet(s => s.Hash == beatmap.Hash); @@ -361,16 +366,23 @@ namespace osu.Game menuScreen.LoadToSolo(); // we might even already be at the song - if (Beatmap.Value.BeatmapSetInfo.Hash == databasedSet.Hash && difficultyCriteria(Beatmap.Value.BeatmapInfo)) - { + if (Beatmap.Value.BeatmapSetInfo.Hash == databasedSet.Hash && (difficultyCriteria?.Invoke(Beatmap.Value.BeatmapInfo) ?? true)) return; - } - // Find first beatmap that matches our predicate. - var first = databasedSet.Beatmaps.Find(difficultyCriteria) ?? databasedSet.Beatmaps.First(); + // Find beatmaps that match our predicate. + var beatmaps = databasedSet.Beatmaps.Where(b => difficultyCriteria?.Invoke(b) ?? true).ToList(); - Ruleset.Value = first.Ruleset; - Beatmap.Value = BeatmapManager.GetWorkingBeatmap(first); + // Use all beatmaps if predicate matched nothing + if (beatmaps.Count == 0) + beatmaps = databasedSet.Beatmaps; + + // Prefer recommended beatmap if recommendations are available, else fallback to a sane selection. + var selection = difficultyRecommender.GetRecommendedBeatmap(beatmaps) + ?? beatmaps.FirstOrDefault(b => b.Ruleset.Equals(Ruleset.Value)) + ?? beatmaps.First(); + + Ruleset.Value = selection.Ruleset; + Beatmap.Value = BeatmapManager.GetWorkingBeatmap(selection); }, validScreens: new[] { typeof(PlaySongSelect) }); } @@ -630,6 +642,8 @@ namespace osu.Game GetStableStorage = GetStorageForStableInstall }, Add, true); + loadComponentSingleFile(difficultyRecommender, Add); + loadComponentSingleFile(screenshotManager, Add); // dependency on notification overlay, dependent by settings overlay diff --git a/osu.Game/Rulesets/ILegacyRuleset.cs b/osu.Game/Rulesets/ILegacyRuleset.cs index 06a85b5261..f4b03baccd 100644 --- a/osu.Game/Rulesets/ILegacyRuleset.cs +++ b/osu.Game/Rulesets/ILegacyRuleset.cs @@ -5,6 +5,8 @@ namespace osu.Game.Rulesets { public interface ILegacyRuleset { + const int MAX_LEGACY_RULESET_ID = 3; + /// /// Identifies the server-side ID of a legacy ruleset. /// diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index f32011a27a..a5252fdc96 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -80,8 +80,6 @@ namespace osu.Game.Screens.Select protected BeatmapCarousel Carousel { get; private set; } - private readonly DifficultyRecommender recommender = new DifficultyRecommender(); - private BeatmapInfoWedge beatmapInfoWedge; private DialogOverlay dialogOverlay; @@ -105,7 +103,7 @@ namespace osu.Game.Screens.Select private MusicController music { get; set; } [BackgroundDependencyLoader(true)] - private void load(AudioManager audio, DialogOverlay dialog, OsuColour colours, SkinManager skins, ScoreManager scores, CollectionManager collections, ManageCollectionsDialog manageCollectionsDialog) + private void load(AudioManager audio, DialogOverlay dialog, OsuColour colours, SkinManager skins, ScoreManager scores, CollectionManager collections, ManageCollectionsDialog manageCollectionsDialog, DifficultyRecommender recommender) { // initial value transfer is required for FilterControl (it uses our re-cached bindables in its async load for the initial filter). transferRulesetValue(); @@ -120,12 +118,11 @@ namespace osu.Game.Screens.Select BleedBottom = Footer.HEIGHT, SelectionChanged = updateSelectedBeatmap, BeatmapSetsChanged = carouselBeatmapsLoaded, - GetRecommendedBeatmap = recommender.GetRecommendedBeatmap, + GetRecommendedBeatmap = s => recommender?.GetRecommendedBeatmap(s), }, c => carouselContainer.Child = c); AddRangeInternal(new Drawable[] { - recommender, new ResetScrollContainer(() => Carousel.ScrollToSelected()) { RelativeSizeAxes = Axes.Y,