From 3e9df00bdc9ad521e802d0e2122b9d1c6db945f4 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Thu, 7 Aug 2025 08:56:15 +0300 Subject: [PATCH 1/7] Add "Rank Achieved" grouping mode --- osu.Game/Beatmaps/BeatmapInfo.cs | 6 ++ osu.Game/Screens/Select/Filter/GroupMode.cs | 4 +- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 9 ++- .../SelectV2/BeatmapCarouselFilterGrouping.cs | 66 ++++++++++++++++--- 4 files changed, 70 insertions(+), 15 deletions(-) diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index a6b40a26de..1f4d370d13 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -157,6 +157,12 @@ namespace osu.Game.Beatmaps public bool Equals(IBeatmapInfo? other) => other is BeatmapInfo b && Equals(b); + public override int GetHashCode() + { + // ReSharper disable once NonReadonlyMemberInGetHashCode + return ID.GetHashCode(); + } + public bool AudioEquals(BeatmapInfo? other) => other != null && BeatmapSet != null && other.BeatmapSet != null diff --git a/osu.Game/Screens/Select/Filter/GroupMode.cs b/osu.Game/Screens/Select/Filter/GroupMode.cs index 9ce5b36202..06d3a71b0f 100644 --- a/osu.Game/Screens/Select/Filter/GroupMode.cs +++ b/osu.Game/Screens/Select/Filter/GroupMode.cs @@ -44,8 +44,8 @@ namespace osu.Game.Screens.Select.Filter [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.MyMaps))] MyMaps, - // [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.RankAchieved))] - // RankAchieved, + [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.RankAchieved))] + RankAchieved, [LocalisableDescription(typeof(SongSelectStrings), nameof(SongSelectStrings.RankedStatus))] RankedStatus, diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index d67fd5e23e..74a28f4352 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -18,7 +18,6 @@ using osu.Framework.Graphics.Pooling; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Beatmaps; -using osu.Game.Collections; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics; @@ -51,6 +50,9 @@ namespace osu.Game.Screens.SelectV2 private readonly BeatmapCarouselFilterGrouping grouping; + [Resolved] + private RealmAccess realm { get; set; } = null!; + /// /// Total number of beatmap difficulties displayed with the filter. /// @@ -98,7 +100,7 @@ namespace osu.Game.Screens.SelectV2 { new BeatmapCarouselFilterMatching(() => Criteria!), new BeatmapCarouselFilterSorting(() => Criteria!), - grouping = new BeatmapCarouselFilterGrouping(() => Criteria!, () => detachedCollections()) + grouping = new BeatmapCarouselFilterGrouping(() => Criteria!, () => realm) }; AddInternal(loading = new LoadingLayer()); @@ -109,7 +111,6 @@ namespace osu.Game.Screens.SelectV2 { setupPools(); detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken); - detachedCollections = () => realm.Run(r => r.All().AsEnumerable().Detach()); loadSamples(audio); config.BindWith(OsuSetting.RandomSelectAlgorithm, randomAlgorithm); @@ -697,8 +698,6 @@ namespace osu.Game.Screens.SelectV2 private Sample? spinSample; private Sample? randomSelectSample; - private Func> detachedCollections = null!; - public bool NextRandom() { var carouselItems = GetCarouselItems(); diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs index 5048e4a7b5..cdf5f3d07c 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -3,16 +3,22 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Collections; +using osu.Game.Database; using osu.Game.Graphics.Carousel; +using osu.Game.Models; +using osu.Game.Rulesets; +using osu.Game.Scoring; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; using osu.Game.Utils; +using Realms; namespace osu.Game.Screens.SelectV2 { @@ -39,12 +45,12 @@ namespace osu.Game.Screens.SelectV2 private Dictionary> groupMap = new Dictionary>(); private readonly Func getCriteria; - private readonly Func>? getCollections; + private readonly Func? getRealm; - public BeatmapCarouselFilterGrouping(Func getCriteria, Func>? getCollections) + public BeatmapCarouselFilterGrouping(Func getCriteria, Func? getRealm) { this.getCriteria = getCriteria; - this.getCollections = getCollections; + this.getRealm = getRealm; } public async Task> Run(IEnumerable items, CancellationToken cancellationToken) @@ -152,6 +158,8 @@ namespace osu.Game.Screens.SelectV2 return false; if (criteria.Sort == SortMode.LastPlayed && criteria.Group == GroupMode.LastPlayed) return false; + if (criteria.Group == GroupMode.RankAchieved) + return false; // In the majority case we group sets together for display. return true; @@ -225,18 +233,52 @@ namespace osu.Game.Screens.SelectV2 return getGroupsBy(b => defineGroupBySource(b.BeatmapSet!.Metadata.Source), items); case GroupMode.Collections: - var collections = getCollections?.Invoke() ?? Enumerable.Empty(); - return getGroupsBy(b => defineGroupByCollection(b, collections), items); + { + var realm = getRealm?.Invoke(); + + return realm?.Run(r => + { + var collections = r.All().AsEnumerable(); + return getGroupsBy(b => defineGroupByCollection(b, collections), items); + }) ?? new List(); + } case GroupMode.MyMaps: return getGroupsBy(b => defineGroupByOwnMaps(b, criteria.LocalUserId, criteria.LocalUserUsername), items); + case GroupMode.RankAchieved: + { + var realm = getRealm?.Invoke(); + + var topRankMapping = new Dictionary(items.Count); + + return realm?.Run(r => + { + var allLocalScores = r.All() + .Filter($"{nameof(ScoreInfo.User)}.{nameof(RealmUser.OnlineID)} == $0" + + $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}" + + $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1" + + $" && {nameof(ScoreInfo.DeletePending)} == false", criteria.LocalUserId, criteria.Ruleset?.ShortName) + .OrderByDescending(s => s.TotalScore) + .ThenBy(s => s.Date); + + foreach (var score in allLocalScores) + { + Debug.Assert(score.BeatmapInfo != null); + + if (topRankMapping.ContainsKey(score.BeatmapInfo)) + continue; + + topRankMapping[score.BeatmapInfo] = score.Rank; + } + + return getGroupsBy(b => defineGroupByRankAchieved(b, topRankMapping), items); + }) ?? new List(); + } + // TODO: need implementation // case GroupMode.Favourites: // goto case GroupMode.None; - // - // case GroupMode.RankAchieved: - // goto case GroupMode.None; default: throw new ArgumentOutOfRangeException(); @@ -415,6 +457,14 @@ namespace osu.Game.Screens.SelectV2 return null; } + private GroupDefinition defineGroupByRankAchieved(BeatmapInfo beatmap, Dictionary topRankMapping) + { + if (topRankMapping.TryGetValue(beatmap, out var rank)) + return new GroupDefinition(-(int)rank, rank.GetDescription()); + + return new GroupDefinition(int.MaxValue, "Unplayed"); + } + private static T? aggregateMax(BeatmapInfo b, Func func) { var beatmaps = b.BeatmapSet!.Beatmaps.Where(bb => !bb.Hidden); From c23856c54bcfaec99b93c602da21f9bc48d8c85b Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Thu, 7 Aug 2025 07:28:19 +0300 Subject: [PATCH 2/7] Add test and benchmark coverage --- .../BeatmapCarouselFilterGroupingTest.cs | 3 +- .../SongSelectV2/SongSelectTestScene.cs | 6 +- .../TestSceneSongSelectGrouping.cs | 217 ++++++++++++++---- 3 files changed, 170 insertions(+), 56 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs index a9f3e70e1d..f799efb463 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs @@ -9,7 +9,6 @@ using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Beatmaps; -using osu.Game.Collections; using osu.Game.Graphics.Carousel; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; @@ -364,7 +363,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 private static async Task> runGrouping(GroupMode group, List beatmapSets) { - var groupingFilter = new BeatmapCarouselFilterGrouping(() => new FilterCriteria { Group = group }, () => new List()); + var groupingFilter = new BeatmapCarouselFilterGrouping(() => new FilterCriteria { Group = group }, null); return await groupingFilter.Run(beatmapSets.SelectMany(s => s.Beatmaps.Select(b => new CarouselItem(b))).ToList(), CancellationToken.None); } diff --git a/osu.Game.Tests/Visual/SongSelectV2/SongSelectTestScene.cs b/osu.Game.Tests/Visual/SongSelectV2/SongSelectTestScene.cs index bbd5be3e3e..b1d1ed8c61 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/SongSelectTestScene.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/SongSelectTestScene.cs @@ -161,9 +161,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2 protected void WaitForFiltering() => AddUntilStep("wait for filtering", () => !SongSelect.IsFiltering); - protected void ImportBeatmapForRuleset(params int[] rulesetIds) => ImportBeatmapForRuleset(_ => { }, rulesetIds); + protected void ImportBeatmapForRuleset(params int[] rulesetIds) => ImportBeatmapForRuleset(_ => { }, 3, rulesetIds); - protected void ImportBeatmapForRuleset(Action applyToBeatmap, params int[] rulesetIds) + protected void ImportBeatmapForRuleset(Action applyToBeatmap, int difficultyCount, params int[] rulesetIds) { int beatmapsCount = 0; @@ -171,7 +171,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { beatmapsCount = SongSelect.IsNull() ? 0 : Carousel.Filters.OfType().Single().SetItems.Count; - var beatmapSet = TestResources.CreateTestBeatmapSetInfo(3, Rulesets.AvailableRulesets.Where(r => rulesetIds.Contains(r.OnlineID)).ToArray()); + var beatmapSet = TestResources.CreateTestBeatmapSetInfo(difficultyCount, Rulesets.AvailableRulesets.Where(r => rulesetIds.Contains(r.OnlineID)).ToArray()); applyToBeatmap(beatmapSet); Beatmaps.Import(beatmapSet); }); diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs index 38af74c4b9..6e81d0c3a9 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs @@ -1,20 +1,32 @@ // 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 System.Threading.Tasks; using NUnit.Framework; -using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Framework.Development; +using osu.Framework.Graphics.Containers; +using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Collections; using osu.Game.Extensions; using osu.Game.Models; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Scoring; using osu.Game.Screens.Select.Filter; using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.SongSelectV2 { + /// + /// Test suite for grouping modes which require the presence of API / realm. + /// All other grouping modes are tested separately in . + /// public partial class TestSceneSongSelectGrouping : SongSelectTestScene { private BeatmapCarouselFilterGrouping grouping => Carousel.Filters.OfType().Single(); @@ -50,25 +62,10 @@ namespace osu.Game.Tests.Visual.SongSelectV2 GroupBy(GroupMode.Collections); WaitForFiltering(); - AddAssert("first collection present", () => - { - var group = grouping.GroupItems.Single(g => g.Key.Title == "My Collection #1"); - return group.Value.Select(i => i.Model).OfType().Single().Equals(beatmapSets[0]); - }); - - AddAssert("second collection present", () => - { - var group = grouping.GroupItems.Single(g => g.Key.Title == "My Collection #2"); - return group.Value.Select(i => i.Model).OfType().Single().Equals(beatmapSets[1]); - }); - - AddAssert("third collection not present", () => grouping.GroupItems.All(g => g.Key.Title != "My Collection #3")); - - AddAssert("no-collection group present", () => - { - var group = grouping.GroupItems.Single(g => g.Key.Title == "Not in collection"); - return group.Value.Select(i => i.Model).OfType().Single().Equals(beatmapSets[2]); - }); + assertGroupPresent("My Collection #1", () => new[] { beatmapSets[0] }); + assertGroupPresent("My Collection #2", () => new[] { beatmapSets[1] }); + assertGroupPresent("Not in collection", () => new[] { beatmapSets[2] }); + assertGroupsCount(3); } [Test] @@ -112,13 +109,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 WaitForFiltering(); - AddAssert("collection present", () => - { - var group = grouping.GroupItems.Single(g => g.Key.Title == "My Collection #4"); - return group.Value.Select(i => i.Model).OfType().Single().Equals(beatmapSet); - }); - - AddAssert("no-collection group not present", () => grouping.GroupItems.All(g => g.Key.Title != "Not in collection")); + assertGroupPresent("My Collection #4", () => new[] { beatmapSet }); + assertGroupsCount(1); } #endregion @@ -128,9 +120,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2 [Test] public void TestMyMapsGrouping() { - ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = "user1", 0); - ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = "user2", 0); - ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = "user3", 0); + ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = "user1", 3, 0); + ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = "user2", 3, 0); + ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = "user3", 3, 0); BeatmapSetInfo[] beatmapSets = null!; @@ -146,11 +138,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2 GroupBy(GroupMode.MyMaps); WaitForFiltering(); - AddAssert("'my maps' present", () => - { - var group = grouping.GroupItems.Single(); - return group.Key.Title == "My maps" && group.Value.Select(i => i.Model).OfType().Single().Equals(beatmapSets[0]); - }); + assertGroupPresent("My maps", () => new[] { beatmapSets[0] }); + assertGroupsCount(1); } [Test] @@ -160,9 +149,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { ((RealmUser)s.Metadata.Author).Username = "user1_old"; ((RealmUser)s.Metadata.Author).OnlineID = DummyAPIAccess.DUMMY_USER_ID; - }, 0); - ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = "user2", 0); - ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = "user3", 0); + }, 3, 0); + ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = "user2", 3, 0); + ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = "user3", 3, 0); BeatmapSetInfo[] beatmapSets = null!; @@ -178,19 +167,16 @@ namespace osu.Game.Tests.Visual.SongSelectV2 GroupBy(GroupMode.MyMaps); WaitForFiltering(); - AddAssert("'my maps' present", () => - { - var group = grouping.GroupItems.Single(); - return group.Key.Title == "My maps" && group.Value.Select(i => i.Model).OfType().Single().Equals(beatmapSets[0]); - }); + assertGroupPresent("My maps", () => new[] { beatmapSets[0] }); + assertGroupsCount(1); } [Test] public void TestMyMapsGroupingUpdatesOnUserChange() { - ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = "user1", 0); - ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = "user2", 0); - ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = new GuestUser().Username, 0); + ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = "user1", 3, 0); + ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = "user2", 3, 0); + ImportBeatmapForRuleset(s => ((RealmUser)s.Metadata.Author).Username = new GuestUser().Username, 3, 0); BeatmapSetInfo[] beatmapSets = null!; @@ -213,17 +199,146 @@ namespace osu.Game.Tests.Visual.SongSelectV2 WaitForFiltering(); - AddAssert("'my maps' present", () => - { - var group = grouping.GroupItems.Single(); - return group.Key.Title == "My maps" && group.Value.Select(i => i.Model).OfType().Single().Equals(beatmapSets[1]); - }); + assertGroupPresent("My maps", () => new[] { beatmapSets[1] }); + assertGroupsCount(1); } #endregion + #region Rank Achieved grouping + + [Test] + public void TestRankAchievedGrouping() + { + ImportBeatmapForRuleset(_ => { }, 1, 0); + ImportBeatmapForRuleset(_ => { }, 1, 0); + ImportBeatmapForRuleset(_ => { }, 1, 0); + ImportBeatmapForRuleset(_ => { }, 1, 0); + ImportBeatmapForRuleset(_ => { }, 1, 0); + + AddStep("log in", () => + { + API.Login("user1", string.Empty); // match username in test scores. + API.AuthenticateSecondFactor("abcdefgh"); + }); + + BeatmapSetInfo[] beatmapSets = null!; + + AddStep("add scores", () => + { + beatmapSets = Beatmaps.GetAllUsableBeatmapSets().OrderBy(b => b.OnlineID).ToArray(); + + ScoreManager.Import(createTestScoreInfo(beatmapSets[0].Beatmaps[0], ScoreRank.SH)); + ScoreManager.Import(createTestScoreInfo(beatmapSets[1].Beatmaps[0], ScoreRank.A)); + ScoreManager.Import(createTestScoreInfo(beatmapSets[2].Beatmaps[0], ScoreRank.C)); + + // score belonging to another user on an unplayed beatmap. + ScoreManager.Import(createTestScoreInfo(beatmapSets[3].Beatmaps[0], ScoreRank.XH, s => s.User = new APIUser { Id = 1337, Username = "user2" })); + + // score belonging to another user on a played beatmap. + ScoreManager.Import(createTestScoreInfo(beatmapSets[0].Beatmaps[0], ScoreRank.XH, s => s.User = new APIUser { Id = 1337, Username = "user2" })); + + // score belonging to local user but with less rank. + ScoreManager.Import(createTestScoreInfo(beatmapSets[0].Beatmaps[0], ScoreRank.D)); + }); + + LoadSongSelect(); + GroupBy(GroupMode.RankAchieved); + WaitForFiltering(); + + assertGroupPresent("S+", () => new[] { beatmapSets[0] }); + assertGroupPresent("A", () => new[] { beatmapSets[1] }); + assertGroupPresent("C", () => new[] { beatmapSets[2] }); + assertGroupPresent("Unplayed", () => new[] { beatmapSets[3], beatmapSets[4] }); + assertGroupsCount(4); + } + + #endregion + + #region Benchmarks + + [Test] + public void TestPerformance() + { + const int sets_count = 100; + const int diffs_count = 100; + + if (DebugUtils.IsNUnitRunning) + Assert.Ignore("For benchmarking purposes only."); + + AddStep("log in", () => + { + API.Login("user1", string.Empty); // match username in test scores. + API.AuthenticateSecondFactor("abcdefgh"); + }); + + int count = 0; + + AddStep("populate database", () => + { + count = 0; + + Task.Factory.StartNew(() => + { + for (int i = 0; i < sets_count; i++) + { + var liveSet = Beatmaps.Import(TestResources.CreateTestBeatmapSetInfo(diffs_count, Rulesets.AvailableRulesets.ToArray()))!; + + liveSet.PerformRead(s => + { + foreach (var beatmap in s.Beatmaps + .GroupBy(b => b.Ruleset.OnlineID) + .Select(g => g.OrderBy(_ => RNG.Next()).Take(4)) // take 4 difficulties from each ruleset randomly + .SelectMany(g => g)) + { + for (int k = 0; k < 3; k++) // create 3 scores per difficulty + ScoreManager.Import(createTestScoreInfo(beatmap)); + } + }); + + count++; + } + }, TaskCreationOptions.LongRunning); + }); + + AddUntilStep("wait for population", () => count, () => Is.GreaterThan(sets_count / 3)); + AddUntilStep("this takes a while", () => count, () => Is.GreaterThan(sets_count / 3 * 2)); + AddUntilStep("maybe they are done now", () => count, () => Is.EqualTo(sets_count)); + + LoadSongSelect(); + } + + #endregion + + private void assertGroupsCount(int expected) + { + AddAssert($"groups = {expected}", () => grouping.GroupItems, () => Has.Count.EqualTo(expected)); + } + + private void assertGroupPresent(string name, Func> getBeatmaps) + { + AddAssert($"\"{name}\" present", () => + { + var group = grouping.GroupItems.Single(g => g.Key.Title == name); + var actualBeatmaps = group.Value.Select(i => i.Model).OfType().OrderBy(b => b.ID); + var expectedBeatmaps = getBeatmaps().SelectMany(s => s.Beatmaps).OrderBy(b => b.ID); + return actualBeatmaps.SequenceEqual(expectedBeatmaps); + }); + } + private NoResultsPlaceholder? getPlaceholder() => SongSelect.ChildrenOfType().FirstOrDefault(); private void checkMatchedBeatmaps(int expected) => AddUntilStep($"{expected} matching shown", () => Carousel.MatchedBeatmapsCount, () => Is.EqualTo(expected)); + + private ScoreInfo createTestScoreInfo(BeatmapInfo beatmap, ScoreRank? rank = null, Action? applyToScore = null) + { + var score = TestResources.CreateTestScoreInfo(beatmap); + score.User = API.LocalUser.Value; + score.Rank = rank ?? Enum.GetValues().OrderBy(_ => RNG.Next()).First(); + score.TotalScore = (long)(((double)score.Rank + 1) / (Enum.GetValues().Length + 1) * 1000000); + score.Date = DateTimeOffset.Now; + applyToScore?.Invoke(score); + return score; + } } } From 8d11f4df0c1fd2848e118d183eaec8194ca36887 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 7 Aug 2025 23:06:52 +0900 Subject: [PATCH 3/7] Simplify LINQ usage to appease inspectcode --- .../Visual/SongSelectV2/TestSceneSongSelectGrouping.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs index 6e81d0c3a9..114a79438c 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs @@ -334,7 +334,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { var score = TestResources.CreateTestScoreInfo(beatmap); score.User = API.LocalUser.Value; - score.Rank = rank ?? Enum.GetValues().OrderBy(_ => RNG.Next()).First(); + score.Rank = rank ?? Enum.GetValues().MinBy(_ => RNG.Next()); score.TotalScore = (long)(((double)score.Rank + 1) / (Enum.GetValues().Length + 1) * 1000000); score.Date = DateTimeOffset.Now; applyToScore?.Invoke(score); From f8049a8fed877b30cb059b23320adf209ee56248 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Mon, 11 Aug 2025 19:19:52 +0300 Subject: [PATCH 4/7] Replace realm access with delegates --- .../BeatmapCarouselFilterGroupingTest.cs | 8 ++- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 44 ++++++++++++-- .../SelectV2/BeatmapCarouselFilterGrouping.cs | 58 +++++-------------- 3 files changed, 62 insertions(+), 48 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs index f799efb463..1791d2a24c 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs @@ -9,7 +9,9 @@ using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Graphics.Carousel; +using osu.Game.Scoring; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; using osu.Game.Screens.SelectV2; @@ -363,7 +365,11 @@ namespace osu.Game.Tests.Visual.SongSelectV2 private static async Task> runGrouping(GroupMode group, List beatmapSets) { - var groupingFilter = new BeatmapCarouselFilterGrouping(() => new FilterCriteria { Group = group }, null); + var groupingFilter = new BeatmapCarouselFilterGrouping( + () => new FilterCriteria { Group = group }, + () => new List(), + (_, _) => new Dictionary()); + return await groupingFilter.Run(beatmapSets.SelectMany(s => s.Beatmaps.Select(b => new CarouselItem(b))).ToList(), CancellationToken.None); } diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 74a28f4352..e663002ff5 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -18,12 +18,17 @@ using osu.Framework.Graphics.Pooling; using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Collections; using osu.Game.Configuration; using osu.Game.Database; using osu.Game.Graphics; using osu.Game.Graphics.Carousel; using osu.Game.Graphics.UserInterface; +using osu.Game.Models; +using osu.Game.Rulesets; +using osu.Game.Scoring; using osu.Game.Screens.Select; +using Realms; namespace osu.Game.Screens.SelectV2 { @@ -50,9 +55,6 @@ namespace osu.Game.Screens.SelectV2 private readonly BeatmapCarouselFilterGrouping grouping; - [Resolved] - private RealmAccess realm { get; set; } = null!; - /// /// Total number of beatmap difficulties displayed with the filter. /// @@ -100,7 +102,7 @@ namespace osu.Game.Screens.SelectV2 { new BeatmapCarouselFilterMatching(() => Criteria!), new BeatmapCarouselFilterSorting(() => Criteria!), - grouping = new BeatmapCarouselFilterGrouping(() => Criteria!, () => realm) + grouping = new BeatmapCarouselFilterGrouping(() => Criteria!, getDetachedCollections, buildTopRankMapping) }; AddInternal(loading = new LoadingLayer()); @@ -623,6 +625,40 @@ namespace osu.Game.Screens.SelectV2 #endregion + #region Grouping + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + private List getDetachedCollections() => realm.Run(r => r.All().Detach()); + + private Dictionary buildTopRankMapping(int? localUserId, string? ruleset) => realm.Run(r => + { + var topRankMapping = new Dictionary(); + + var allLocalScores = r.All() + .Filter($"{nameof(ScoreInfo.User)}.{nameof(RealmUser.OnlineID)} == $0" + + $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}" + + $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1" + + $" && {nameof(ScoreInfo.DeletePending)} == false", localUserId, ruleset) + .OrderByDescending(s => s.TotalScore) + .ThenBy(s => s.Date); + + foreach (var score in allLocalScores) + { + Debug.Assert(score.BeatmapInfo != null); + + if (topRankMapping.ContainsKey(score.BeatmapInfo.ID)) + continue; + + topRankMapping[score.BeatmapInfo.ID] = score.Rank; + } + + return topRankMapping; + }); + + #endregion + #region Drawable pooling private readonly DrawablePool beatmapPanelPool = new DrawablePool(100); diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs index cdf5f3d07c..85a11b78bb 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -3,22 +3,17 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Collections; -using osu.Game.Database; using osu.Game.Graphics.Carousel; -using osu.Game.Models; -using osu.Game.Rulesets; using osu.Game.Scoring; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; using osu.Game.Utils; -using Realms; namespace osu.Game.Screens.SelectV2 { @@ -45,12 +40,14 @@ namespace osu.Game.Screens.SelectV2 private Dictionary> groupMap = new Dictionary>(); private readonly Func getCriteria; - private readonly Func? getRealm; + private readonly GetDetachedCollectionsDelegate getDetachedCollections; + private readonly BuildTopRankMappingDelegate buildTopRankMapping; - public BeatmapCarouselFilterGrouping(Func getCriteria, Func? getRealm) + public BeatmapCarouselFilterGrouping(Func getCriteria, GetDetachedCollectionsDelegate getDetachedCollections, BuildTopRankMappingDelegate buildTopRankMapping) { this.getCriteria = getCriteria; - this.getRealm = getRealm; + this.getDetachedCollections = getDetachedCollections; + this.buildTopRankMapping = buildTopRankMapping; } public async Task> Run(IEnumerable items, CancellationToken cancellationToken) @@ -234,13 +231,8 @@ namespace osu.Game.Screens.SelectV2 case GroupMode.Collections: { - var realm = getRealm?.Invoke(); - - return realm?.Run(r => - { - var collections = r.All().AsEnumerable(); - return getGroupsBy(b => defineGroupByCollection(b, collections), items); - }) ?? new List(); + var collections = getDetachedCollections(); + return getGroupsBy(b => defineGroupByCollection(b, collections), items); } case GroupMode.MyMaps: @@ -248,32 +240,8 @@ namespace osu.Game.Screens.SelectV2 case GroupMode.RankAchieved: { - var realm = getRealm?.Invoke(); - - var topRankMapping = new Dictionary(items.Count); - - return realm?.Run(r => - { - var allLocalScores = r.All() - .Filter($"{nameof(ScoreInfo.User)}.{nameof(RealmUser.OnlineID)} == $0" - + $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}" - + $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1" - + $" && {nameof(ScoreInfo.DeletePending)} == false", criteria.LocalUserId, criteria.Ruleset?.ShortName) - .OrderByDescending(s => s.TotalScore) - .ThenBy(s => s.Date); - - foreach (var score in allLocalScores) - { - Debug.Assert(score.BeatmapInfo != null); - - if (topRankMapping.ContainsKey(score.BeatmapInfo)) - continue; - - topRankMapping[score.BeatmapInfo] = score.Rank; - } - - return getGroupsBy(b => defineGroupByRankAchieved(b, topRankMapping), items); - }) ?? new List(); + var topRankMapping = buildTopRankMapping(criteria.LocalUserId, criteria.Ruleset?.ShortName); + return getGroupsBy(b => defineGroupByRankAchieved(b, topRankMapping), items); } // TODO: need implementation @@ -457,9 +425,9 @@ namespace osu.Game.Screens.SelectV2 return null; } - private GroupDefinition defineGroupByRankAchieved(BeatmapInfo beatmap, Dictionary topRankMapping) + private GroupDefinition defineGroupByRankAchieved(BeatmapInfo beatmap, IReadOnlyDictionary topRankMapping) { - if (topRankMapping.TryGetValue(beatmap, out var rank)) + if (topRankMapping.TryGetValue(beatmap.ID, out var rank)) return new GroupDefinition(-(int)rank, rank.GetDescription()); return new GroupDefinition(int.MaxValue, "Unplayed"); @@ -472,5 +440,9 @@ namespace osu.Game.Screens.SelectV2 } private record GroupMapping(GroupDefinition? Group, List ItemsInGroup); + + public delegate List GetDetachedCollectionsDelegate(); + + public delegate IReadOnlyDictionary BuildTopRankMappingDelegate(int? localUserId, string? ruleset); } } From 522f94277b7072ca060c3b4b637b77087b39d359 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Mon, 11 Aug 2025 19:21:19 +0300 Subject: [PATCH 5/7] Fix incorrect rank animation when beatmap panels retrieved from pool Rank animation is played for new panels when scrolling down, showing the previous rank belonging to the previous beatmap assigned to that specific panel instance. --- osu.Game/Online/Leaderboards/UpdateableRank.cs | 9 ++++++--- osu.Game/Screens/SelectV2/PanelLocalRankDisplay.cs | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/osu.Game/Online/Leaderboards/UpdateableRank.cs b/osu.Game/Online/Leaderboards/UpdateableRank.cs index b64fab6861..ea5a985ef7 100644 --- a/osu.Game/Online/Leaderboards/UpdateableRank.cs +++ b/osu.Game/Online/Leaderboards/UpdateableRank.cs @@ -11,7 +11,9 @@ namespace osu.Game.Online.Leaderboards { public partial class UpdateableRank : ModelBackedDrawable { - protected override double TransformDuration => 600; + private readonly bool animate; + + protected override double TransformDuration => animate ? 600 : 0; protected override bool TransformImmediately => true; public ScoreRank? Rank @@ -20,8 +22,10 @@ namespace osu.Game.Online.Leaderboards set => Model = value; } - public UpdateableRank(ScoreRank? rank = null) + public UpdateableRank(ScoreRank? rank = null, bool animate = true) { + this.animate = animate; + Rank = rank; } @@ -58,7 +62,6 @@ namespace osu.Game.Online.Leaderboards protected override TransformSequence ApplyHideTransforms(Drawable drawable) { drawable.ScaleTo(1.8f, TransformDuration, Easing.Out); - return base.ApplyHideTransforms(drawable); } } diff --git a/osu.Game/Screens/SelectV2/PanelLocalRankDisplay.cs b/osu.Game/Screens/SelectV2/PanelLocalRankDisplay.cs index 130c1cd05a..273f995794 100644 --- a/osu.Game/Screens/SelectV2/PanelLocalRankDisplay.cs +++ b/osu.Game/Screens/SelectV2/PanelLocalRankDisplay.cs @@ -54,7 +54,7 @@ namespace osu.Game.Screens.SelectV2 { AutoSizeAxes = Axes.Both; - InternalChild = updateable = new UpdateableRank + InternalChild = updateable = new UpdateableRank(animate: false) { Size = new Vector2(40, 20), Alpha = 0, From 8239294d108ff7b096b3f0ad14817f4e15afe189 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 12 Aug 2025 14:18:41 +0900 Subject: [PATCH 6/7] Revert unnecessary changes --- .../BeatmapCarouselFilterGroupingTest.cs | 2 +- osu.Game/Beatmaps/BeatmapInfo.cs | 6 ------ osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 10 +++++----- .../SelectV2/BeatmapCarouselFilterGrouping.cs | 20 ++++++++----------- 4 files changed, 14 insertions(+), 24 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs index 1791d2a24c..939a5e6e7c 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs @@ -368,7 +368,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 var groupingFilter = new BeatmapCarouselFilterGrouping( () => new FilterCriteria { Group = group }, () => new List(), - (_, _) => new Dictionary()); + _ => new Dictionary()); return await groupingFilter.Run(beatmapSets.SelectMany(s => s.Beatmaps.Select(b => new CarouselItem(b))).ToList(), CancellationToken.None); } diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 1f4d370d13..a6b40a26de 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -157,12 +157,6 @@ namespace osu.Game.Beatmaps public bool Equals(IBeatmapInfo? other) => other is BeatmapInfo b && Equals(b); - public override int GetHashCode() - { - // ReSharper disable once NonReadonlyMemberInGetHashCode - return ID.GetHashCode(); - } - public bool AudioEquals(BeatmapInfo? other) => other != null && BeatmapSet != null && other.BeatmapSet != null diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index e663002ff5..bdb0f86d85 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -102,7 +102,7 @@ namespace osu.Game.Screens.SelectV2 { new BeatmapCarouselFilterMatching(() => Criteria!), new BeatmapCarouselFilterSorting(() => Criteria!), - grouping = new BeatmapCarouselFilterGrouping(() => Criteria!, getDetachedCollections, buildTopRankMapping) + grouping = new BeatmapCarouselFilterGrouping(() => Criteria!, getDetachedCollections, getTopRanksMapping) }; AddInternal(loading = new LoadingLayer()); @@ -625,14 +625,14 @@ namespace osu.Game.Screens.SelectV2 #endregion - #region Grouping + #region Database fetches for grouping support [Resolved] private RealmAccess realm { get; set; } = null!; - private List getDetachedCollections() => realm.Run(r => r.All().Detach()); + private List getDetachedCollections() => realm.Run(r => r.All().AsEnumerable().Detach()); - private Dictionary buildTopRankMapping(int? localUserId, string? ruleset) => realm.Run(r => + private Dictionary getTopRanksMapping(FilterCriteria criteria) => realm.Run(r => { var topRankMapping = new Dictionary(); @@ -640,7 +640,7 @@ namespace osu.Game.Screens.SelectV2 .Filter($"{nameof(ScoreInfo.User)}.{nameof(RealmUser.OnlineID)} == $0" + $" && {nameof(ScoreInfo.BeatmapInfo)}.{nameof(BeatmapInfo.Hash)} == {nameof(ScoreInfo.BeatmapHash)}" + $" && {nameof(ScoreInfo.Ruleset)}.{nameof(RulesetInfo.ShortName)} == $1" - + $" && {nameof(ScoreInfo.DeletePending)} == false", localUserId, ruleset) + + $" && {nameof(ScoreInfo.DeletePending)} == false", criteria.LocalUserId, criteria.Ruleset?.ShortName) .OrderByDescending(s => s.TotalScore) .ThenBy(s => s.Date); diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs index 85a11b78bb..6be620899b 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -40,14 +40,14 @@ namespace osu.Game.Screens.SelectV2 private Dictionary> groupMap = new Dictionary>(); private readonly Func getCriteria; - private readonly GetDetachedCollectionsDelegate getDetachedCollections; - private readonly BuildTopRankMappingDelegate buildTopRankMapping; + private readonly Func> getCollections; + private readonly Func> getLocalUserTopRanks; - public BeatmapCarouselFilterGrouping(Func getCriteria, GetDetachedCollectionsDelegate getDetachedCollections, BuildTopRankMappingDelegate buildTopRankMapping) + public BeatmapCarouselFilterGrouping(Func getCriteria, Func> getCollections, Func> getLocalUserTopRanks) { this.getCriteria = getCriteria; - this.getDetachedCollections = getDetachedCollections; - this.buildTopRankMapping = buildTopRankMapping; + this.getCollections = getCollections; + this.getLocalUserTopRanks = getLocalUserTopRanks; } public async Task> Run(IEnumerable items, CancellationToken cancellationToken) @@ -190,7 +190,7 @@ namespace osu.Game.Screens.SelectV2 var date = b.LastPlayed; if (BeatmapSetsGroupedTogether) - date = aggregateMax(b, static b => (b.LastPlayed ?? DateTimeOffset.MinValue)); + date = aggregateMax(b, static b => b.LastPlayed ?? DateTimeOffset.MinValue); if (date == null || date == DateTimeOffset.MinValue) return new GroupDefinition(int.MaxValue, "Never"); @@ -231,7 +231,7 @@ namespace osu.Game.Screens.SelectV2 case GroupMode.Collections: { - var collections = getDetachedCollections(); + var collections = getCollections(); return getGroupsBy(b => defineGroupByCollection(b, collections), items); } @@ -240,7 +240,7 @@ namespace osu.Game.Screens.SelectV2 case GroupMode.RankAchieved: { - var topRankMapping = buildTopRankMapping(criteria.LocalUserId, criteria.Ruleset?.ShortName); + var topRankMapping = getLocalUserTopRanks(criteria); return getGroupsBy(b => defineGroupByRankAchieved(b, topRankMapping), items); } @@ -440,9 +440,5 @@ namespace osu.Game.Screens.SelectV2 } private record GroupMapping(GroupDefinition? Group, List ItemsInGroup); - - public delegate List GetDetachedCollectionsDelegate(); - - public delegate IReadOnlyDictionary BuildTopRankMappingDelegate(int? localUserId, string? ruleset); } } From ade7641c53b877d1140f967770cf2903cbcbbb5b Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 12 Aug 2025 15:14:35 +0900 Subject: [PATCH 7/7] Use `ExplicitAttribute` instead of manual nunit ignore --- .../Visual/SongSelectV2/TestSceneSongSelectGrouping.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs index 114a79438c..0f7c42946d 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneSongSelectGrouping.cs @@ -6,9 +6,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using NUnit.Framework; -using osu.Framework.Testing; -using osu.Framework.Development; using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Collections; @@ -258,14 +257,12 @@ namespace osu.Game.Tests.Visual.SongSelectV2 #region Benchmarks [Test] + [Explicit("Manual benchmark")] public void TestPerformance() { const int sets_count = 100; const int diffs_count = 100; - if (DebugUtils.IsNUnitRunning) - Assert.Ignore("For benchmarking purposes only."); - AddStep("log in", () => { API.Login("user1", string.Empty); // match username in test scores.