mirror of
https://github.com/ppy/osu.git
synced 2025-03-18 06:27:18 +08:00
Merge pull request #8759 from LittleEndu/present-recommended
This commit is contained in:
commit
fa94703b9b
@ -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]
|
||||
|
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
});
|
@ -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}";
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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,
|
||||
|
Loading…
x
Reference in New Issue
Block a user