1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-23 00:07:24 +08:00

Merge pull request #8759 from LittleEndu/present-recommended

This commit is contained in:
Bartłomiej Dach 2020-12-23 00:09:47 +01:00 committed by GitHub
commit fa94703b9b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 293 additions and 32 deletions

View File

@ -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]

View File

@ -0,0 +1,211 @@
// 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.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<BeatmapSetInfo> beatmapSets = null;
const int import_count = 5;
AddStep("import 5 maps", () =>
{
beatmapSets = new List<BeatmapSetInfo>();
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<RulesetInfo> 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<BeatmapSetInfo> beatmapSets) => beatmapSets.All(set => set != null);
private void presentAndConfirm(Func<BeatmapSetInfo> 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;
});
}
}
}

View File

@ -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
{
/// <summary>
/// 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.
/// </summary>
public class DifficultyRecommender : Component
{
[Resolved]
@ -26,7 +30,12 @@ namespace osu.Game.Screens.Select
[Resolved]
private Bindable<RulesetInfo> ruleset { get; set; }
private readonly Dictionary<RulesetInfo, double> recommendedStarDifficulty = new Dictionary<RulesetInfo, double>();
/// <summary>
/// The user for which the last requests were run.
/// </summary>
private int? requestedUserId;
private readonly Dictionary<RulesetInfo, double> recommendedDifficultyMapping = new Dictionary<RulesetInfo, double>();
private readonly IBindable<APIState> apiState = new Bindable<APIState>();
@ -45,42 +54,64 @@ namespace osu.Game.Screens.Select
/// </remarks>
/// <param name="beatmaps">A collection of beatmaps to select a difficulty from.</param>
/// <returns>The recommended difficulty, or null if a recommendation could not be provided.</returns>
[CanBeNull]
public BeatmapInfo GetRecommendedBeatmap(IEnumerable<BeatmapInfo> 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);
});
}
/// <returns>
/// Rulesets ordered descending by their respective recommended difficulties.
/// The currently selected ruleset will always be first.
/// </returns>
private IEnumerable<RulesetInfo> orderedRulesets =>
recommendedDifficultyMapping
.OrderByDescending(pair => pair.Value).Select(pair => pair.Key).Where(r => !r.Equals(ruleset.Value))
.Prepend(ruleset.Value);
private void onlineStateChanged(ValueChangedEvent<APIState> state) => Schedule(() =>
{
switch (state.NewValue)
{
case APIState.Online:
calculateRecommendedDifficulties();
fetchRecommendedValues();
break;
}
});

View File

@ -9,14 +9,14 @@ namespace osu.Game.Online.API.Requests
public class GetUserRequest : APIRequest<User>
{
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}";
}
}

View File

@ -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.
/// </summary>
/// <param name="beatmap">The beatmap to select.</param>
/// <param name="difficultyCriteria">
/// 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.
/// </param>
/// <param name="difficultyCriteria">Optional predicate used to narrow the set of difficulties to select from when presenting.</param>
/// <remarks>
/// Among items satisfying the predicate, the order of preference is:
/// <list type="bullet">
/// <item>beatmap with recommended difficulty, as provided by <see cref="DifficultyRecommender"/>,</item>
/// <item>first beatmap from the current ruleset,</item>
/// <item>first beatmap from any ruleset.</item>
/// </list>
/// </remarks>
public void PresentBeatmap(BeatmapSetInfo beatmap, Predicate<BeatmapInfo> 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

View File

@ -5,6 +5,8 @@ namespace osu.Game.Rulesets
{
public interface ILegacyRuleset
{
const int MAX_LEGACY_RULESET_ID = 3;
/// <summary>
/// Identifies the server-side ID of a legacy ruleset.
/// </summary>

View File

@ -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,