1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-21 11:30:08 +08:00

Merge branch 'master' into song-select-better-debounce

This commit is contained in:
Bartłomiej Dach
2025-09-03 12:42:21 +02:00
Unverified
29 changed files with 494 additions and 277 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.829.0" />
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.903.0" />
</ItemGroup>
<PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged.
+40
View File
@@ -294,6 +294,46 @@ namespace osu.Game.Tests.Skins.IO
#endregion
/// <remarks>
/// Note that this test passing / failing is platform / OS-specific (if it is to fail, it'll fail on windows).
/// </remarks>
[Test]
public async Task TestExternallyMountingImportWithInvalidFilename()
{
using (HeadlessGameHost host = new CleanRunHeadlessGameHost())
{
try
{
var osu = LoadOsuIntoHost(host);
var zipStream = new MemoryStream();
using var zip = ZipArchive.Create();
zip.AddEntry("test?.png", new MemoryStream(new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }));
zip.SaveTo(zipStream);
var import = await loadSkinIntoOsu(osu, new ImportTask(zipStream, "test skin.osk"));
var skinManager = osu.Dependencies.Get<SkinManager>();
var externalEdit = await skinManager.BeginExternalEditing(import.PerformRead(s => s.Detach())); // should not fail
Assert.That(Directory.Exists(externalEdit.MountedPath));
Assert.That(new DirectoryInfo(externalEdit.MountedPath).GetFiles().Select(f => f.Name), Is.EquivalentTo(new[]
{
"skin.ini",
"test.png"
}));
Task finishTask = Task.CompletedTask;
host.UpdateThread.Scheduler.Add(() => finishTask = externalEdit.Finish());
await finishTask;
}
finally
{
host.Exit();
}
}
}
private void assertCorrectMetadata(Live<SkinInfo> import1, string name, string creator, decimal version, OsuGameBase osu)
{
import1.PerformRead(i =>
@@ -325,7 +325,7 @@ namespace osu.Game.Tests.Visual.Background
private void setupUserSettings()
{
AddUntilStep("Song select is current", () => songSelect.IsCurrentScreen());
AddUntilStep("Song select has selection", () => songSelect.Carousel?.CurrentSelection != null);
AddUntilStep("Song select has selection", () => songSelect.Carousel?.CurrentGroupedBeatmap != null);
AddStep("Set default user settings", () =>
{
SelectedMods.Value = new[] { new OsuModNoFail() };
@@ -43,7 +43,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
var results = await runGrouping(GroupMode.None, beatmapSets);
Assert.That(results.Select(r => r.Model).OfType<GroupedBeatmapSet>().Select(groupedSet => groupedSet.BeatmapSet), Is.EquivalentTo(beatmapSets));
Assert.That(results.Select(r => r.Model).OfType<BeatmapInfo>(), Is.EquivalentTo(allBeatmaps));
Assert.That(results.Select(r => r.Model).OfType<GroupedBeatmap>().Select(groupedBeatmap => groupedBeatmap.Beatmap), Is.EquivalentTo(allBeatmaps));
assertTotal(results, beatmapSets.Count + allBeatmaps.Length);
}
@@ -391,7 +391,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
var groupModel = (GroupDefinition)groupItem.Model;
Assert.That(groupModel.Title, Is.EqualTo(expectedTitle));
Assert.That(itemsInGroup.Select(i => i.Model).OfType<BeatmapInfo>(), Is.EquivalentTo(expectedBeatmaps));
Assert.That(itemsInGroup.Select(i => i.Model).OfType<GroupedBeatmap>().Select(gb => gb.Beatmap), Is.EquivalentTo(expectedBeatmaps));
totalItems += itemsInGroup.Count() + 1;
}
@@ -16,11 +16,13 @@ using osu.Framework.Graphics.Shapes;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Database;
using osu.Game.Graphics;
using osu.Game.Graphics.Carousel;
using osu.Game.Graphics.Containers;
using osu.Game.Overlays;
using osu.Game.Scoring;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.SelectV2;
@@ -115,13 +117,15 @@ namespace osu.Game.Tests.Visual.SongSelectV2
NewItemsPresented = _ => NewItemsPresentedInvocationCount++,
RequestSelection = b =>
{
BeatmapRequestedSelections.Push(b);
Carousel.CurrentSelection = b;
BeatmapRequestedSelections.Push(b.Beatmap);
Carousel.CurrentGroupedBeatmap = b;
},
RequestRecommendedSelection = beatmaps =>
RequestRecommendedSelection = groupedBeatmaps =>
{
BeatmapSetRequestedSelections.Push(beatmaps.First().BeatmapSet!);
Carousel.CurrentSelection = BeatmapRecommendationFunction?.Invoke(beatmaps) ?? beatmaps.First();
var recommendedBeatmap = BeatmapRecommendationFunction?.Invoke(groupedBeatmaps.Select(gb => gb.Beatmap)) ?? groupedBeatmaps.First().Beatmap;
var recommendedGroupedBeatmap = groupedBeatmaps.First(gb => gb.Beatmap.Equals(recommendedBeatmap));
BeatmapSetRequestedSelections.Push(recommendedBeatmap.BeatmapSet!);
Carousel.CurrentGroupedBeatmap = recommendedGroupedBeatmap;
},
BleedTop = 50,
BleedBottom = 50,
@@ -215,8 +219,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2
protected void Select() => AddStep("select", () => InputManager.Key(Key.Enter));
protected void CheckNoSelection() => AddAssert("has no selection", () => Carousel.CurrentSelection, () => Is.Null);
protected void CheckHasSelection() => AddAssert("has selection", () => Carousel.CurrentSelection, () => Is.Not.Null);
protected void CheckNoSelection() => AddAssert("has no selection", () => Carousel.CurrentGroupedBeatmap, () => Is.Null);
protected void CheckHasSelection() => AddAssert("has selection", () => Carousel.CurrentGroupedBeatmap, () => Is.Not.Null);
protected void CheckRequestPresentCount(int expected) =>
AddAssert($"check present count is {expected}", () => Carousel.RequestPresentBeatmapCount, () => Is.EqualTo(expected));
@@ -281,8 +285,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
// offset by one because the group itself is included in the items list.
CarouselItem item = groupingFilter.GroupItems[groupDefinition].ElementAt(panel + 1);
return (Carousel.CurrentSelection as BeatmapInfo)?
.Equals(item.Model as BeatmapInfo) == true;
return Carousel.CurrentGroupedBeatmap?.Equals(item.Model as GroupedBeatmap) == true;
});
}
@@ -291,12 +294,12 @@ namespace osu.Game.Tests.Visual.SongSelectV2
if (diff != null)
{
AddUntilStep($"selected is set{set} diff{diff.Value}",
() => (Carousel.CurrentSelection as BeatmapInfo),
() => Carousel.CurrentBeatmap,
() => Is.EqualTo(BeatmapSets[set].Beatmaps[diff.Value]));
}
else
{
AddUntilStep($"selected is set{set}", () => BeatmapSets[set].Beatmaps.Contains(Carousel.CurrentSelection));
AddUntilStep($"selected is set{set}", () => BeatmapSets[set].Beatmaps.Contains(Carousel.CurrentBeatmap!));
}
}
@@ -415,7 +418,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
tracked: {Carousel.ItemsTracked}
displayable: {Carousel.DisplayableItems}
displayed: {Carousel.VisibleItems}
selected: {Carousel.CurrentSelection}
selected: {Carousel.CurrentGroupedBeatmap}
""");
void createHeader(string text)
@@ -437,12 +440,15 @@ namespace osu.Game.Tests.Visual.SongSelectV2
public IEnumerable<BeatmapInfo> PostFilterBeatmaps = null!;
public BeatmapInfo? SelectedBeatmapInfo => CurrentSelection as BeatmapInfo;
public BeatmapInfo? SelectedBeatmapInfo => (CurrentSelection as GroupedBeatmap)?.Beatmap;
public BeatmapSetInfo? SelectedBeatmapSet => SelectedBeatmapInfo?.BeatmapSet;
public new GroupedBeatmapSet? ExpandedBeatmapSet => base.ExpandedBeatmapSet;
public new GroupDefinition? ExpandedGroup => base.ExpandedGroup;
public Func<List<BeatmapCollection>> AllCollections { get; set; } = () => [];
public Func<FilterCriteria, Dictionary<Guid, ScoreRank>> BeatmapInfoGuidToTopRankMapping { get; set; } = _ => new Dictionary<Guid, ScoreRank>();
public TestBeatmapCarousel()
{
RequestPresentBeatmap = _ => RequestPresentBeatmapCount++;
@@ -461,9 +467,12 @@ namespace osu.Game.Tests.Visual.SongSelectV2
if (FilterDelay != 0)
await Task.Delay(FilterDelay).ConfigureAwait(true);
PostFilterBeatmaps = items.Select(i => i.Model).OfType<BeatmapInfo>();
PostFilterBeatmaps = items.Select(i => i.Model).OfType<GroupedBeatmap>().Select(i => i.Beatmap);
return items;
}
protected override List<BeatmapCollection> GetAllCollections() => AllCollections.Invoke();
protected override Dictionary<Guid, ScoreRank> GetBeatmapInfoGuidToTopRankMapping(FilterCriteria criteria) => BeatmapInfoGuidToTopRankMapping.Invoke(criteria);
}
}
}
@@ -4,7 +4,6 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Carousel;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.SelectV2;
@@ -81,7 +80,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
CheckHasSelection();
AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null);
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentGroupedBeatmap));
RemoveAllBeatmaps();
AddUntilStep("no drawable selection", GetSelectedPanel, () => Is.Null);
@@ -92,9 +91,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2
CheckHasSelection();
AddAssert("no drawable selection", GetSelectedPanel, () => Is.Null);
AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!));
AddStep("add previous selection", () => BeatmapSets.Add(((GroupedBeatmap)selection!).Beatmap.BeatmapSet!));
AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentGroupedBeatmap));
AddUntilStep("drawable selection restored", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection));
AddAssert("carousel item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True);
@@ -132,7 +131,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
WaitForBeatmapSelection(0, 1);
// Expanding a group will move keyboard selection to the selected beatmap if contained.
AddAssert("keyboard selected panel is expanded", () => groupPanel?.Expanded.Value, () => Is.True);
AddAssert("keyboard selected panel is beatmap", () => GetKeyboardSelectedPanel()?.Item?.Model, Is.TypeOf<BeatmapInfo>);
AddAssert("keyboard selected panel is beatmap", () => GetKeyboardSelectedPanel()?.Item?.Model, Is.TypeOf<GroupedBeatmap>);
}
[Test]
@@ -0,0 +1,66 @@
// 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.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Collections;
using osu.Game.Screens.Select.Filter;
namespace osu.Game.Tests.Visual.SongSelectV2
{
[TestFixture]
public partial class TestSceneBeatmapCarouselCollectionGrouping : BeatmapCarouselTestScene
{
[SetUpSteps]
public void SetUpSteps()
{
RemoveAllBeatmaps();
CreateCarousel();
AddBeatmaps(10, 3);
AddStep("set up collections", () =>
{
List<BeatmapCollection> collections =
[
new BeatmapCollection("collection one", [
..BeatmapSets[0].Beatmaps.Select(b => b.MD5Hash),
..BeatmapSets[1].Beatmaps.Select(b => b.MD5Hash),
..BeatmapSets[2].Beatmaps.Select(b => b.MD5Hash),
BeatmapSets[5].Beatmaps[1].MD5Hash,
BeatmapSets[8].Beatmaps[0].MD5Hash,
]),
new BeatmapCollection("collection two", [
BeatmapSets[0].Beatmaps[0].MD5Hash,
..BeatmapSets[1].Beatmaps.Select(b => b.MD5Hash),
..BeatmapSets[2].Beatmaps.Select(b => b.MD5Hash),
BeatmapSets[6].Beatmaps[2].MD5Hash,
BeatmapSets[8].Beatmaps[2].MD5Hash,
]),
new BeatmapCollection("collection one copy", [
..BeatmapSets[0].Beatmaps.Select(b => b.MD5Hash),
..BeatmapSets[1].Beatmaps.Select(b => b.MD5Hash),
..BeatmapSets[2].Beatmaps.Select(b => b.MD5Hash),
BeatmapSets[5].Beatmaps[1].MD5Hash,
BeatmapSets[8].Beatmaps[0].MD5Hash,
]),
];
Carousel.AllCollections = () => collections;
});
SortAndGroupBy(SortMode.Title, GroupMode.Collections);
WaitForDrawablePanels();
}
[Test]
public void TestMultipleCopiesOfBeatmapsPresent()
{
CheckDisplayedGroupsCount(4); // one for each collection, plus no collections
// all three collections have beatmaps from 5 beatmap sets
// 7 beatmap sets have beatmaps which belong to no collection
CheckDisplayedBeatmapSetsCount(5 + 5 + 5 + 7);
}
}
}
@@ -4,7 +4,6 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Carousel;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.SelectV2;
@@ -71,7 +70,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
CheckHasSelection();
AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null);
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentGroupedBeatmap));
RemoveAllBeatmaps();
AddUntilStep("no drawable selection", GetSelectedPanel, () => Is.Null);
@@ -82,9 +81,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2
CheckHasSelection();
AddAssert("no drawable selection", GetSelectedPanel, () => Is.Null);
AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!));
AddStep("add previous selection", () => BeatmapSets.Add(((GroupedBeatmap)selection!).Beatmap.BeatmapSet!));
AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentGroupedBeatmap));
AddUntilStep("drawable selection restored", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection));
AddAssert("carousel item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True);
@@ -121,7 +120,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
WaitForBeatmapSelection(0, 0);
// Expanding a group will move keyboard selection to the selected beatmap if contained.
AddAssert("keyboard selected panel is expanded", () => groupPanel?.Expanded.Value, () => Is.True);
AddAssert("keyboard selected panel is beatmap", () => GetKeyboardSelectedPanel()?.Item?.Model, Is.TypeOf<BeatmapInfo>);
AddAssert("keyboard selected panel is beatmap", () => GetKeyboardSelectedPanel()?.Item?.Model, Is.TypeOf<GroupedBeatmap>);
}
[Test]
@@ -199,7 +198,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
}
private void checkBeatmapIsKeyboardSelected() =>
AddUntilStep("check keyboard selected group is beatmap", () => GetKeyboardSelectedPanel()?.Item?.Model, () => Is.EqualTo(Carousel.CurrentSelection));
AddUntilStep("check keyboard selected group is beatmap", () => GetKeyboardSelectedPanel()?.Item?.Model, () => Is.EqualTo(Carousel.CurrentGroupedBeatmap));
private void checkGroupKeyboardSelected(int index) => AddUntilStep($"check keyboard selected group is {index}", () => GetKeyboardSelectedPanel()?.Item?.Model, () =>
{
@@ -130,14 +130,14 @@ namespace osu.Game.Tests.Visual.SongSelectV2
SelectNextPanel();
Select();
AddStep("record selection", () => selectedID = ((BeatmapInfo)Carousel.CurrentSelection!).ID);
AddStep("record selection", () => selectedID = Carousel.CurrentBeatmap!.ID);
for (int i = 0; i < 5; i++)
{
ApplyToFilterAndWaitForFilter("filter all", c => c.SearchText = Guid.NewGuid().ToString());
AddAssert("selection not changed", () => ((BeatmapInfo)Carousel.CurrentSelection!).ID == selectedID);
AddAssert("selection not changed", () => Carousel.CurrentBeatmap!.ID == selectedID);
ApplyToFilterAndWaitForFilter("remove filter", c => c.SearchText = string.Empty);
AddAssert("selection not changed", () => ((BeatmapInfo)Carousel.CurrentSelection!).ID == selectedID);
AddAssert("selection not changed", () => Carousel.CurrentBeatmap!.ID == selectedID);
}
}
@@ -177,14 +177,14 @@ namespace osu.Game.Tests.Visual.SongSelectV2
SelectNextSet();
AddStep("record selection", () => selectedID = ((BeatmapInfo)Carousel.CurrentSelection!).ID);
AddStep("record selection", () => selectedID = Carousel.CurrentBeatmap!.ID);
for (int i = 0; i < 5; i++)
{
ApplyToFilterAndWaitForFilter("filter all", c => c.SearchText = Guid.NewGuid().ToString());
AddAssert("selection not changed", () => ((BeatmapInfo)Carousel.CurrentSelection!).ID == selectedID);
AddAssert("selection not changed", () => Carousel.CurrentBeatmap!.ID == selectedID);
ApplyToFilterAndWaitForFilter("remove filter", c => c.SearchText = string.Empty);
AddAssert("selection not changed", () => ((BeatmapInfo)Carousel.CurrentSelection!).ID == selectedID);
AddAssert("selection not changed", () => Carousel.CurrentBeatmap!.ID == selectedID);
}
}
@@ -200,14 +200,14 @@ namespace osu.Game.Tests.Visual.SongSelectV2
{
int diff = i;
AddStep($"select diff {diff}", () => Carousel.CurrentSelection = chosenBeatmap = BeatmapSets[20].Beatmaps[diff]);
AddUntilStep("selection changed", () => Carousel.CurrentSelection, () => Is.EqualTo(chosenBeatmap));
AddStep($"select diff {diff}", () => Carousel.CurrentBeatmap = chosenBeatmap = BeatmapSets[20].Beatmaps[diff]);
AddUntilStep("selection changed", () => Carousel.CurrentBeatmap, () => Is.EqualTo(chosenBeatmap));
SortBy(SortMode.Difficulty);
AddAssert("selection retained", () => Carousel.CurrentSelection, () => Is.EqualTo(chosenBeatmap));
AddAssert("selection retained", () => Carousel.CurrentBeatmap, () => Is.EqualTo(chosenBeatmap));
SortBy(SortMode.Title);
AddAssert("selection retained", () => Carousel.CurrentSelection, () => Is.EqualTo(chosenBeatmap));
AddAssert("selection retained", () => Carousel.CurrentBeatmap, () => Is.EqualTo(chosenBeatmap));
}
}
@@ -239,7 +239,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
var visibleBeatmapPanels = GetVisiblePanels<PanelBeatmap>();
return visibleBeatmapPanels.Count() == 1
&& visibleBeatmapPanels.Count(p => ((BeatmapInfo)p.Item!.Model).Ruleset.OnlineID == 0) == 1;
&& visibleBeatmapPanels.Count(p => ((GroupedBeatmap)p.Item!.Model).Beatmap.Ruleset.OnlineID == 0) == 1;
});
ApplyToFilterAndWaitForFilter("filter to taiko", c => c.Ruleset = rulesets.AvailableRulesets.ElementAt(1));
@@ -249,8 +249,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2
var visibleBeatmapPanels = GetVisiblePanels<PanelBeatmap>();
return visibleBeatmapPanels.Count() == 2
&& visibleBeatmapPanels.Count(p => ((BeatmapInfo)p.Item!.Model).Ruleset.OnlineID == 0) == 1
&& visibleBeatmapPanels.Count(p => ((BeatmapInfo)p.Item!.Model).Ruleset.OnlineID == 1) == 1;
&& visibleBeatmapPanels.Count(p => ((GroupedBeatmap)p.Item!.Model).Beatmap.Ruleset.OnlineID == 0) == 1
&& visibleBeatmapPanels.Count(p => ((GroupedBeatmap)p.Item!.Model).Beatmap.Ruleset.OnlineID == 1) == 1;
});
ApplyToFilterAndWaitForFilter("filter to catch", c => c.Ruleset = rulesets.AvailableRulesets.ElementAt(2));
@@ -260,8 +260,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2
var visibleBeatmapPanels = GetVisiblePanels<PanelBeatmap>();
return visibleBeatmapPanels.Count() == 2
&& visibleBeatmapPanels.Count(p => ((BeatmapInfo)p.Item!.Model).Ruleset.OnlineID == 0) == 1
&& visibleBeatmapPanels.Count(p => ((BeatmapInfo)p.Item!.Model).Ruleset.OnlineID == 2) == 1;
&& visibleBeatmapPanels.Count(p => ((GroupedBeatmap)p.Item!.Model).Beatmap.Ruleset.OnlineID == 0) == 1
&& visibleBeatmapPanels.Count(p => ((GroupedBeatmap)p.Item!.Model).Beatmap.Ruleset.OnlineID == 2) == 1;
});
}
@@ -4,7 +4,6 @@
using System.Linq;
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.SelectV2;
using osuTK;
@@ -90,7 +89,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
CheckHasSelection();
AddAssert("drawable selection non-null", () => selection, () => Is.Not.Null);
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
AddAssert("drawable selection matches carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentGroupedBeatmap));
RemoveAllBeatmaps();
AddUntilStep("no drawable selection", GetSelectedPanel, () => Is.Null);
@@ -101,9 +100,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2
CheckHasSelection();
AddAssert("no drawable selection", GetSelectedPanel, () => Is.Null);
AddStep("add previous selection", () => BeatmapSets.Add(((BeatmapInfo)selection!).BeatmapSet!));
AddStep("add previous selection", () => BeatmapSets.Add(((GroupedBeatmap)selection!).Beatmap.BeatmapSet!));
AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentSelection));
AddAssert("selection matches original carousel selection", () => selection, () => Is.EqualTo(Carousel.CurrentGroupedBeatmap));
AddUntilStep("drawable selection restored", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(selection));
AddAssert("carousel item is visible", () => GetSelectedPanel()?.Item?.IsVisible, () => Is.True);
}
@@ -390,15 +389,15 @@ namespace osu.Game.Tests.Visual.SongSelectV2
private void checkSelectionIterating(bool isIterating)
{
object? selection = null;
GroupedBeatmap? selection = null;
for (int i = 0; i < 3; i++)
{
AddStep("store selection", () => selection = Carousel.CurrentSelection);
AddStep("store selection", () => selection = Carousel.CurrentGroupedBeatmap);
if (isIterating)
AddUntilStep("selection changed", () => Carousel.CurrentSelection != selection);
AddUntilStep("selection changed", () => Carousel.CurrentGroupedBeatmap != selection);
else
AddUntilStep("selection not changed", () => Carousel.CurrentSelection == selection);
AddUntilStep("selection not changed", () => Carousel.CurrentGroupedBeatmap == selection);
}
}
}
@@ -50,12 +50,12 @@ namespace osu.Game.Tests.Visual.SongSelectV2
nextRandom();
ensureRandomDidNotRepeat();
AddStep("store selection", () => originalSelected = (BeatmapInfo)Carousel.CurrentSelection!);
AddStep("store selection", () => originalSelected = Carousel.CurrentBeatmap!);
SortAndGroupBy(SortMode.Artist, GroupMode.Difficulty);
WaitForFiltering();
AddAssert("selection not changed", () => Carousel.CurrentSelection, () => Is.EqualTo(originalSelected));
AddAssert("selection not changed", () => Carousel.CurrentBeatmap, () => Is.EqualTo(originalSelected));
storeExpandedGroup();
@@ -247,18 +247,42 @@ namespace osu.Game.Tests.Visual.SongSelectV2
}
}
[Test]
public void TestRewindOverGroupingModeChange()
{
const int local_set_count = 3;
SortAndGroupBy(SortMode.Artist, GroupMode.Artist);
AddBeatmaps(local_set_count, 3);
WaitForDrawablePanels();
SelectNextSet();
for (int i = 0; i < local_set_count; i++)
nextRandom();
SortAndGroupBy(SortMode.Title, GroupMode.LastPlayed);
WaitForDrawablePanels();
for (int i = 0; i < local_set_count; i++)
{
prevRandomSet();
checkRewindCorrectSet();
}
}
[Test]
public void TestRandomThenRewindSameFrame()
{
AddBeatmaps(10, 3, true);
WaitForDrawablePanels();
BeatmapInfo? originalSelected = null;
GroupedBeatmap? originalSelected = null;
nextRandom();
CheckHasSelection();
AddStep("store selection", () => originalSelected = (BeatmapInfo)Carousel.CurrentSelection!);
AddStep("store selection", () => originalSelected = Carousel.CurrentGroupedBeatmap!);
AddStep("random then rewind", () =>
{
@@ -266,7 +290,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
Carousel.PreviousRandom();
});
AddAssert("selection not changed", () => Carousel.CurrentSelection, () => Is.EqualTo(originalSelected));
AddAssert("selection not changed", () => Carousel.CurrentGroupedBeatmap, () => Is.EqualTo(originalSelected));
}
[Test]
@@ -275,26 +299,26 @@ namespace osu.Game.Tests.Visual.SongSelectV2
AddBeatmaps(10, 3, true);
WaitForDrawablePanels();
BeatmapInfo? originalSelected = null;
BeatmapInfo? postRandomSelection = null;
GroupedBeatmap? originalSelected = null;
GroupedBeatmap? postRandomSelection = null;
nextRandom();
CheckHasSelection();
AddStep("store selection", () => originalSelected = (BeatmapInfo)Carousel.CurrentSelection!);
AddStep("store selection", () => originalSelected = Carousel.CurrentGroupedBeatmap!);
nextRandom();
AddStep("store selection", () => postRandomSelection = (BeatmapInfo)Carousel.CurrentSelection!);
AddStep("store selection", () => postRandomSelection = Carousel.CurrentGroupedBeatmap!);
AddAssert("selection changed", () => originalSelected, () => Is.Not.SameAs(postRandomSelection));
AddStep("delete previous selection beatmaps", () => BeatmapSets.Remove(originalSelected!.BeatmapSet!));
AddStep("delete previous selection beatmaps", () => BeatmapSets.Remove(originalSelected!.Beatmap.BeatmapSet!));
WaitForFiltering();
AddAssert("selection not changed", () => Carousel.CurrentSelection, () => Is.EqualTo(postRandomSelection));
AddAssert("selection not changed", () => Carousel.CurrentGroupedBeatmap, () => Is.EqualTo(postRandomSelection));
prevRandomSet();
AddAssert("selection not changed", () => Carousel.CurrentSelection, () => Is.EqualTo(postRandomSelection));
AddAssert("selection not changed", () => Carousel.CurrentGroupedBeatmap, () => Is.EqualTo(postRandomSelection));
}
private void nextRandom() =>
@@ -27,7 +27,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
{
Quad positionBefore = default;
AddStep("select middle beatmap", () => Carousel.CurrentSelection = BeatmapSets.ElementAt(BeatmapSets.Count - 2).Beatmaps.First());
AddStep("select middle beatmap", () => Carousel.CurrentGroupedBeatmap = new GroupedBeatmap(null, BeatmapSets.ElementAt(BeatmapSets.Count - 2).Beatmaps.First()));
WaitForScrolling();
@@ -45,7 +45,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
{
Quad positionBefore = default;
AddStep("select middle beatmap", () => Carousel.CurrentSelection = BeatmapSets.ElementAt(BeatmapSets.Count - 2).Beatmaps.First());
AddStep("select middle beatmap", () => Carousel.CurrentGroupedBeatmap = new GroupedBeatmap(null, BeatmapSets.ElementAt(BeatmapSets.Count - 2).Beatmaps.First()));
WaitForScrolling();
AddStep("override scroll with user scroll", () =>
@@ -71,7 +71,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
AddStep("scroll to end", () => Scroll.ScrollToEnd(false));
AddStep("select last beatmap", () => Carousel.CurrentSelection = BeatmapSets.Last().Beatmaps.Last());
AddStep("select last beatmap", () => Carousel.CurrentGroupedBeatmap = new GroupedBeatmap(null, BeatmapSets.Last().Beatmaps.Last()));
WaitForScrolling();
@@ -88,7 +88,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
{
Quad positionBefore = default;
AddStep("select first beatmap", () => Carousel.CurrentSelection = BeatmapSets.First().Beatmaps.First());
AddStep("select first beatmap", () => Carousel.CurrentGroupedBeatmap = new GroupedBeatmap(null, BeatmapSets.First().Beatmaps.First()));
WaitForScrolling();
@@ -108,7 +108,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
{
Quad positionBefore = default;
AddStep("select first beatmap", () => Carousel.CurrentSelection = BeatmapSets.First().Beatmaps.First());
AddStep("select first beatmap", () => Carousel.CurrentGroupedBeatmap = new GroupedBeatmap(null, BeatmapSets.First().Beatmaps.First()));
WaitForScrolling();
AddStep("override scroll with user scroll", () =>
@@ -179,8 +179,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2
SelectNextSet();
WaitForSetSelection(1, 0);
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
updateBeatmap(b =>
{
@@ -195,8 +195,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2
WaitForFiltering();
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
}
[Test] // Checks that we keep selection based on online ID where possible.
@@ -205,15 +205,15 @@ namespace osu.Game.Tests.Visual.SongSelectV2
SelectNextSet();
WaitForSetSelection(1, 0);
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
updateBeatmap(b => b.DifficultyName = "new name");
assertDidFilter();
WaitForFiltering();
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
}
[Test] // Checks that we fallback to keeping selection based on difficulty name.
@@ -222,15 +222,15 @@ namespace osu.Game.Tests.Visual.SongSelectV2
SelectNextSet();
WaitForSetSelection(1, 0);
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
updateBeatmap(b => b.OnlineID = b.OnlineID + 1);
assertDidFilter();
WaitForFiltering();
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
}
[Test] // Checks that we don't crash if there exists a difficulty with the same online ID as the selected difficulty.
@@ -239,8 +239,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2
SelectNextSet();
WaitForSetSelection(1, 0);
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
// Add another difficulty with same online ID.
updateBeatmap(null, bs =>
@@ -252,8 +252,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2
WaitForFiltering();
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
}
[Test] // Checks that we don't crash if there exists a difficulty with the same name as the selected difficulty.
@@ -262,8 +262,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2
SelectNextSet();
WaitForSetSelection(1, 0);
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
// Remove original selected difficulty, and add two difficulties with same name as selection.
updateBeatmap(null, bs =>
@@ -284,8 +284,8 @@ namespace osu.Game.Tests.Visual.SongSelectV2
WaitForFiltering();
AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("selection is updateable beatmap", () => Carousel.CurrentBeatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
AddAssert("visible panel is updateable beatmap", () => (GetSelectedPanel()?.Item?.Model as GroupedBeatmap)?.Beatmap, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0]));
}
/// <summary>
@@ -104,21 +104,21 @@ namespace osu.Game.Tests.Visual.SongSelectV2
{
new PanelBeatmap
{
Item = new CarouselItem(beatmap)
Item = new CarouselItem(new GroupedBeatmap(null, beatmap))
},
new PanelBeatmap
{
Item = new CarouselItem(beatmap),
Item = new CarouselItem(new GroupedBeatmap(null, beatmap)),
KeyboardSelected = { Value = true }
},
new PanelBeatmap
{
Item = new CarouselItem(beatmap),
Item = new CarouselItem(new GroupedBeatmap(null, beatmap)),
Selected = { Value = true }
},
new PanelBeatmap
{
Item = new CarouselItem(beatmap),
Item = new CarouselItem(new GroupedBeatmap(null, beatmap)),
KeyboardSelected = { Value = true },
Selected = { Value = true }
},
@@ -104,21 +104,21 @@ namespace osu.Game.Tests.Visual.SongSelectV2
{
new PanelBeatmapStandalone
{
Item = new CarouselItem(beatmap)
Item = new CarouselItem(new GroupedBeatmap(null, beatmap))
},
new PanelBeatmapStandalone
{
Item = new CarouselItem(beatmap),
Item = new CarouselItem(new GroupedBeatmap(null, beatmap)),
KeyboardSelected = { Value = true }
},
new PanelBeatmapStandalone
{
Item = new CarouselItem(beatmap),
Item = new CarouselItem(new GroupedBeatmap(null, beatmap)),
Selected = { Value = true }
},
new PanelBeatmapStandalone
{
Item = new CarouselItem(beatmap),
Item = new CarouselItem(new GroupedBeatmap(null, beatmap)),
KeyboardSelected = { Value = true },
Selected = { Value = true }
},
@@ -320,9 +320,9 @@ namespace osu.Game.Tests.Visual.SongSelectV2
AddUntilStep("wait for player", () => Stack.CurrentScreen is Player);
AddUntilStep("wait for fail", () => ((Player)Stack.CurrentScreen).GameplayState.HasFailed);
AddStep("exit gameplay", () => InputManager.Key(Key.Escape));
AddStep("exit gameplay", () => InputManager.Key(Key.Escape));
AddStep("exit gameplay", () => Stack.CurrentScreen.Exit());
AddUntilStep("wait for song select", () => Stack.CurrentScreen is Screens.SelectV2.SongSelect);
AddUntilStep("wait for filtered", () => SongSelect.ChildrenOfType<BeatmapCarousel>().Single().FilterCount, () => Is.EqualTo(2));
}
@@ -27,7 +27,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
/// </summary>
public partial class TestSceneSongSelectCurrentSelectionInvalidated : SongSelectTestScene
{
private BeatmapInfo? selectedBeatmap => (BeatmapInfo?)Carousel.CurrentSelection;
private BeatmapInfo? selectedBeatmap => Carousel.CurrentBeatmap;
private BeatmapSetInfo? selectedBeatmapSet => selectedBeatmap?.BeatmapSet;
[SetUpSteps]
@@ -317,7 +317,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2
AddAssert($"\"{name}\" present", () =>
{
var group = grouping.GroupItems.Single(g => g.Key.Title == name);
var actualBeatmaps = group.Value.Select(i => i.Model).OfType<BeatmapInfo>().OrderBy(b => b.ID);
var actualBeatmaps = group.Value.Select(i => i.Model).OfType<GroupedBeatmap>().Select(gb => gb.Beatmap).OrderBy(b => b.ID);
var expectedBeatmaps = getBeatmaps().SelectMany(s => s.Beatmaps).OrderBy(b => b.ID);
return actualBeatmaps.SequenceEqual(expectedBeatmaps);
});
+5 -21
View File
@@ -20,6 +20,7 @@ using osu.Framework.Platform;
using osu.Framework.Statistics;
using osu.Game.Beatmaps.Formats;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.IO;
using osu.Game.Skinning;
using osu.Game.Storyboards;
@@ -341,27 +342,10 @@ namespace osu.Game.Beatmaps
{
// Matches stable implementation, because it's probably simpler than trying to do anything else.
// This may need to be reconsidered after we begin storing storyboards in the new editor.
return windowsFilenameStrip(
(metadata.Artist.Length > 0 ? metadata.Artist + @" - " + metadata.Title : Path.GetFileNameWithoutExtension(metadata.AudioFile))
+ (metadata.Author.Username.Length > 0 ? @" (" + metadata.Author.Username + @")" : string.Empty)
+ @".osb");
string windowsFilenameStrip(string entry)
{
// Inlined from Path.GetInvalidFilenameChars() to ensure the windows characters are used (to match stable).
char[] invalidCharacters =
{
'\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07',
'\x08', '\x09', '\x0A', '\x0B', '\x0C', '\x0D', '\x0E', '\x0F', '\x10', '\x11', '\x12',
'\x13', '\x14', '\x15', '\x16', '\x17', '\x18', '\x19', '\x1A', '\x1B', '\x1C', '\x1D',
'\x1E', '\x1F', '\x22', '\x3C', '\x3E', '\x7C', ':', '*', '?', '\\', '/'
};
foreach (char c in invalidCharacters)
entry = entry.Replace(c.ToString(), string.Empty);
return entry;
}
string baseFilename = (metadata.Artist.Length > 0 ? metadata.Artist + @" - " + metadata.Title : Path.GetFileNameWithoutExtension(metadata.AudioFile))
+ (metadata.Author.Username.Length > 0 ? @" (" + metadata.Author.Username + @")" : string.Empty)
+ @".osb";
return baseFilename.GetValidFilename();
}
}
}
+10 -1
View File
@@ -208,7 +208,16 @@ namespace osu.Game.Database
foreach (var realmFile in model.Files)
{
string sourcePath = Files.Storage.GetFullPath(realmFile.File.GetStoragePath());
string destinationPath = Path.Join(mountedPath, realmFile.Filename);
// there are edge cases where externalising an imported model to the filesystem could fail due to invalid filenames.
// one scenario where this happens goes something like this:
// - stable user exports an archive, which contains filenames that get mangled by stable's default zip encoding codepage (Shift-JIS)
// - said archive is imported to lazer, but the invalid filename is not actually an issue due to lazer file store structure
// (the file is stored under a filename correspondent to its SHA instead, and its real filename is only stored in realm)
// - however attempts to externally edit the model fail as the external edit attempts and fails to produce the file's "real" filename in the mounted path
// to prevent this bricking external edit, strip invalid characters on external edit.
// the presumption here is that whatever produced the mangled archive is primarily at fault here, and we're just trying to trudge on locally as best as possible.
// if there are further troubles related to similar issues, reevaluate moving this sort of check to the import side instead (sanitising filenames on import from archive).
string destinationPath = Path.Join(mountedPath, realmFile.Filename.GetValidFilename());
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
+1
View File
@@ -175,6 +175,7 @@ namespace osu.Game.Extensions
/// DO NOT CHANGE THE SEMANTICS OF THIS METHOD unless you know well what you are doing.
/// </para>
/// </remarks>
/// <seealso href="https://github.com/peppy/osu-stable-reference/blob/67795dba3c308e7d0493b296149dcb073ca47ecb/osu!common/Helpers/GeneralHelper.cs#L41-L46"/>
public static string GetValidFilename(this string filename)
{
foreach (char c in invalid_filename_chars)
+1 -1
View File
@@ -107,7 +107,7 @@ namespace osu.Game.Graphics.Carousel
/// The selection is never reset due to not existing. It can be set to anything.
/// If no matching carousel item exists, there will be no visually selected item while waiting for potential new item which matches.
/// </remarks>
public object? CurrentSelection
protected object? CurrentSelection
{
get => currentSelection.Model;
set
+141 -65
View File
@@ -41,12 +41,12 @@ namespace osu.Game.Screens.SelectV2
/// <summary>
/// From the provided beatmaps, select the most appropriate one for the user's skill.
/// </summary>
public required Action<IEnumerable<BeatmapInfo>> RequestRecommendedSelection { private get; init; }
public required Action<IEnumerable<GroupedBeatmap>> RequestRecommendedSelection { private get; init; }
/// <summary>
/// Selection requested for the provided beatmap.
/// </summary>
public required Action<BeatmapInfo> RequestSelection { private get; init; }
public required Action<GroupedBeatmap> RequestSelection { private get; init; }
public const float SPACING = 3f;
@@ -74,11 +74,11 @@ namespace osu.Game.Screens.SelectV2
return SPACING * 2;
// ..and the bottom.
if (top.Model is BeatmapInfo && bottom.Model is GroupedBeatmapSet)
if (top.Model is GroupedBeatmap && bottom.Model is GroupedBeatmapSet)
return SPACING * 2;
// Beatmap difficulty panels do not overlap with themselves or any other panel.
if (top.Model is BeatmapInfo || bottom.Model is BeatmapInfo)
if (top.Model is GroupedBeatmap || bottom.Model is GroupedBeatmap)
return SPACING;
}
else
@@ -103,14 +103,14 @@ namespace osu.Game.Screens.SelectV2
{
new BeatmapCarouselFilterMatching(() => Criteria!),
new BeatmapCarouselFilterSorting(() => Criteria!),
grouping = new BeatmapCarouselFilterGrouping(() => Criteria!, getDetachedCollections, getTopRanksMapping)
grouping = new BeatmapCarouselFilterGrouping(() => Criteria!, GetAllCollections, GetBeatmapInfoGuidToTopRankMapping)
};
AddInternal(loading = new LoadingLayer());
}
[BackgroundDependencyLoader]
private void load(BeatmapStore beatmapStore, RealmAccess realm, AudioManager audio, OsuConfigManager config, CancellationToken? cancellationToken)
private void load(BeatmapStore beatmapStore, AudioManager audio, OsuConfigManager config, CancellationToken? cancellationToken)
{
setupPools();
detachedBeatmaps = beatmapStore.GetBeatmapSets(cancellationToken);
@@ -158,7 +158,7 @@ namespace osu.Game.Screens.SelectV2
foreach (var beatmap in set.Beatmaps)
{
Items.RemoveAll(i => i is BeatmapInfo bi && beatmap.Equals(bi));
selectedSetDeleted |= CheckModelEquality(CurrentSelection, beatmap);
selectedSetDeleted |= CheckModelEquality((CurrentSelection as GroupedBeatmap)?.Beatmap, beatmap);
}
}
@@ -200,13 +200,13 @@ namespace osu.Game.Screens.SelectV2
{
if (CheckValidForSetSelection(item))
{
if (item.Model is BeatmapInfo beatmapInfo)
if (item.Model is GroupedBeatmap groupedBeatmap)
{
// check the new selection wasn't deleted above
if (!Items.Contains(beatmapInfo))
if (!Items.Contains(groupedBeatmap.Beatmap))
return false;
RequestSelection(beatmapInfo);
RequestSelection(groupedBeatmap);
return true;
}
@@ -215,7 +215,7 @@ namespace osu.Game.Screens.SelectV2
if (oldItems.Contains(groupedSet.BeatmapSet))
return false;
RequestRecommendedSelection(groupedSet.BeatmapSet.Beatmaps);
selectRecommendedDifficultyForBeatmapSet(groupedSet);
return true;
}
}
@@ -256,8 +256,12 @@ namespace osu.Game.Screens.SelectV2
{
// TODO: should this exist in song select instead of here?
// we need to ensure the global beatmap is also updated alongside changes.
if (CurrentSelection != null && CheckModelEquality(beatmap, CurrentSelection))
RequestSelection(matchingNewBeatmap);
if (CurrentSelection is GroupedBeatmap currentBeatmapUnderGrouping)
{
var candidateSelection = currentBeatmapUnderGrouping with { Beatmap = beatmap };
if (CheckModelEquality(candidateSelection, CurrentSelection))
RequestSelection(candidateSelection);
}
Items.ReplaceRange(previousIndex, 1, [matchingNewBeatmap]);
newSetBeatmaps.Remove(matchingNewBeatmap);
@@ -289,7 +293,51 @@ namespace osu.Game.Screens.SelectV2
protected GroupedBeatmapSet? ExpandedBeatmapSet { get; private set; }
protected override bool ShouldActivateOnKeyboardSelection(CarouselItem item) =>
grouping.BeatmapSetsGroupedTogether && item.Model is BeatmapInfo;
grouping.BeatmapSetsGroupedTogether && item.Model is GroupedBeatmap;
/// <summary>
/// The currently selected <see cref="GroupedBeatmap"/>.
/// </summary>
/// <remarks>
/// The selection is never reset due to not existing. It can be set to anything.
/// If no matching carousel item exists, there will be no visually selected item while waiting for potential new item which matches.
/// </remarks>
public GroupedBeatmap? CurrentGroupedBeatmap
{
get => CurrentSelection as GroupedBeatmap;
set => CurrentSelection = value;
}
/// <summary>
/// The currently selected <see cref="BeatmapInfo"/>.
/// </summary>
/// <remarks>
/// This is a property mostly dedicated to external consumers who only care about showing some particular copy of a beatmap
/// (there could be multiple panels for one beatmap due to grouping).
/// Through this property, the carousel basically figures out what group to use internally.
/// </remarks>
public BeatmapInfo? CurrentBeatmap
{
get => CurrentGroupedBeatmap?.Beatmap;
set
{
if (value == null)
{
CurrentGroupedBeatmap = null;
return;
}
if (CurrentGroupedBeatmap != null && value.Equals(CurrentGroupedBeatmap.Beatmap))
return;
// it is not universally guaranteed that the carousel items will be materialised at the time this is set.
// therefore, in cases where it is known that they will not be, default to a null group.
// even if grouping is active, this will be rectified to a correct group on the next invocation of `HandleFilterCompleted()`.
CurrentGroupedBeatmap = IsLoaded && !IsFiltering
? GetCarouselItems()?.Select(item => item.Model).OfType<GroupedBeatmap>().FirstOrDefault(gb => gb.Beatmap.Equals(value))
: new GroupedBeatmap(null, value);
}
}
protected override void HandleItemActivated(CarouselItem item)
{
@@ -309,8 +357,8 @@ namespace osu.Game.Screens.SelectV2
setExpandedGroup(group);
// If the active selection is within this group, it should get keyboard focus immediately.
if (CurrentSelectionItem?.IsVisible == true && CurrentSelection is BeatmapInfo info)
RequestSelection(info);
if (CurrentSelectionItem?.IsVisible == true && CurrentSelection is GroupedBeatmap gb)
RequestSelection(gb);
return;
@@ -318,14 +366,14 @@ namespace osu.Game.Screens.SelectV2
selectRecommendedDifficultyForBeatmapSet(groupedSet);
return;
case BeatmapInfo beatmapInfo:
if (CurrentSelection != null && CheckModelEquality(CurrentSelection, beatmapInfo))
case GroupedBeatmap groupedBeatmap:
if (CurrentSelection != null && CheckModelEquality(CurrentSelection, groupedBeatmap))
{
RequestPresentBeatmap?.Invoke(beatmapInfo);
RequestPresentBeatmap?.Invoke(groupedBeatmap.Beatmap);
return;
}
RequestSelection(beatmapInfo);
RequestSelection(groupedBeatmap);
return;
}
}
@@ -345,14 +393,11 @@ namespace osu.Game.Screens.SelectV2
case GroupDefinition:
throw new InvalidOperationException("Groups should never become selected");
case BeatmapInfo beatmapInfo:
// Find any containing group. There should never be too many groups so iterating is efficient enough.
GroupDefinition? containingGroup = grouping.GroupItems.SingleOrDefault(kvp => kvp.Value.Any(i => CheckModelEquality(i.Model, beatmapInfo))).Key;
setExpandedGroup(containingGroup);
case GroupedBeatmap groupedBeatmap:
setExpandedGroup(groupedBeatmap.Group);
if (grouping.BeatmapSetsGroupedTogether)
setExpandedSet(new GroupedBeatmapSet(containingGroup, beatmapInfo.BeatmapSet!));
setExpandedSet(new GroupedBeatmapSet(groupedBeatmap.Group, groupedBeatmap.Beatmap.BeatmapSet!));
break;
}
}
@@ -417,9 +462,27 @@ namespace osu.Game.Screens.SelectV2
// Store selected group before handling selection (it may implicitly change the expanded group).
var groupForReselection = ExpandedGroup;
// Ensure correct post-selection logic is handled on the new items list.
// This will update the visual state of the selected item.
HandleItemSelected(CurrentSelection);
var currentGroupedBeatmap = CurrentSelection as GroupedBeatmap;
// The filter might have changed the set of available groups, which means that the current selection may point to a stale group.
// Check whether that is the case.
bool groupingRemainsOff = currentGroupedBeatmap?.Group == null && grouping.GroupItems.Count == 0;
bool groupStillExists = currentGroupedBeatmap?.Group != null && grouping.GroupItems.ContainsKey(currentGroupedBeatmap.Group);
if (groupingRemainsOff || groupStillExists)
{
// Only update the visual state of the selected item.
HandleItemSelected(currentGroupedBeatmap);
}
else if (currentGroupedBeatmap != null)
{
// If the group no longer exists, grab an arbitrary other instance of the beatmap under the first group encountered.
var newSelection = GetCarouselItems()?.Select(i => i.Model).OfType<GroupedBeatmap>().FirstOrDefault(gb => gb.Beatmap.Equals(currentGroupedBeatmap.Beatmap));
// Only change the selection if we actually got a positive hit.
// This is necessary so that selection isn't lost if the panel reappears later due to e.g. unapplying some filter criteria that made it disappear in the first place.
if (newSelection != null)
CurrentSelection = newSelection;
}
// If a group was selected that is not the one containing the selection, attempt to reselect it.
// If the original group was not found, ExpandedGroup will already have been updated to a valid value in `HandleItemSelected` above.
@@ -432,7 +495,7 @@ namespace osu.Game.Screens.SelectV2
// Selecting a set isn't valid let's re-select the first visible difficulty.
if (grouping.SetItems.TryGetValue(set, out var items))
{
var beatmaps = items.Select(i => i.Model).OfType<BeatmapInfo>();
var beatmaps = items.Select(i => i.Model).OfType<GroupedBeatmap>();
RequestRecommendedSelection(beatmaps);
}
}
@@ -450,8 +513,10 @@ namespace osu.Game.Screens.SelectV2
foreach (var item in items)
{
if (item.Model is BeatmapInfo beatmapInfo)
if (item.Model is GroupedBeatmap groupedBeatmap)
{
var beatmapInfo = groupedBeatmap.Beatmap;
if (beatmapSetInfo == null)
{
beatmapSetInfo = beatmapInfo.BeatmapSet!;
@@ -464,9 +529,9 @@ namespace osu.Game.Screens.SelectV2
}
}
var beatmaps = items.Select(i => i.Model).OfType<BeatmapInfo>();
var beatmaps = items.Select(i => i.Model).OfType<GroupedBeatmap>();
if (beatmaps.Any(b => b.Equals(CurrentSelection as BeatmapInfo)))
if (beatmaps.Any(b => b.Equals(CurrentSelection as GroupedBeatmap)))
return;
RequestRecommendedSelection(beatmaps);
@@ -481,7 +546,7 @@ namespace osu.Game.Screens.SelectV2
case GroupedBeatmapSet:
return true;
case BeatmapInfo:
case GroupedBeatmap:
return !grouping.BeatmapSetsGroupedTogether;
case GroupDefinition:
@@ -492,7 +557,7 @@ namespace osu.Game.Screens.SelectV2
}
}
private void setExpandedGroup(GroupDefinition group)
private void setExpandedGroup(GroupDefinition? group)
{
if (ExpandedGroup != null)
setExpansionStateOfGroup(ExpandedGroup, false);
@@ -500,7 +565,7 @@ namespace osu.Game.Screens.SelectV2
ExpandedGroup = group;
if (ExpandedGroup != null)
setExpansionStateOfGroup(group, true);
setExpansionStateOfGroup(ExpandedGroup, true);
}
private void setExpansionStateOfGroup(GroupDefinition group, bool expanded)
@@ -607,7 +672,7 @@ namespace osu.Game.Screens.SelectV2
sampleChangeSet?.Play();
return;
case BeatmapInfo:
case GroupedBeatmap:
sampleChangeDifficulty?.Play();
return;
}
@@ -687,9 +752,9 @@ namespace osu.Game.Screens.SelectV2
[Resolved]
private RealmAccess realm { get; set; } = null!;
private List<BeatmapCollection> getDetachedCollections() => realm.Run(r => r.All<BeatmapCollection>().AsEnumerable().Detach());
protected virtual List<BeatmapCollection> GetAllCollections() => realm.Run(r => r.All<BeatmapCollection>().AsEnumerable().Detach());
private Dictionary<Guid, ScoreRank> getTopRanksMapping(FilterCriteria criteria) => realm.Run(r =>
protected virtual Dictionary<Guid, ScoreRank> GetBeatmapInfoGuidToTopRankMapping(FilterCriteria criteria) => realm.Run(r =>
{
var topRankMapping = new Dictionary<Guid, ScoreRank>();
@@ -745,8 +810,8 @@ namespace osu.Game.Screens.SelectV2
if (x is GroupedBeatmapSet groupedSetX && y is GroupedBeatmapSet groupedSetY)
return groupedSetX.Equals(groupedSetY);
if (x is BeatmapInfo beatmapX && y is BeatmapInfo beatmapY)
return beatmapX.Equals(beatmapY);
if (x is GroupedBeatmap groupedBeatmapX && y is GroupedBeatmap groupedBeatmapY)
return groupedBeatmapX.Equals(groupedBeatmapY);
if (x is GroupDefinition groupX && y is GroupDefinition groupY)
return groupX.Equals(groupY);
@@ -767,7 +832,7 @@ namespace osu.Game.Screens.SelectV2
case GroupDefinition:
return groupPanelPool.Get();
case BeatmapInfo:
case GroupedBeatmap:
if (!grouping.BeatmapSetsGroupedTogether)
return standalonePanelPool.Get();
@@ -785,8 +850,8 @@ namespace osu.Game.Screens.SelectV2
#region Random selection handling
private readonly Bindable<RandomSelectAlgorithm> randomAlgorithm = new Bindable<RandomSelectAlgorithm>();
private readonly List<BeatmapInfo> previouslyVisitedRandomBeatmaps = new List<BeatmapInfo>();
private readonly List<BeatmapInfo> randomHistory = new List<BeatmapInfo>();
private readonly HashSet<BeatmapInfo> previouslyVisitedRandomBeatmaps = new HashSet<BeatmapInfo>();
private readonly List<GroupedBeatmap> randomHistory = new List<GroupedBeatmap>();
private Sample? spinSample;
private Sample? randomSelectSample;
@@ -799,7 +864,7 @@ namespace osu.Game.Screens.SelectV2
return false;
var selectionBefore = CurrentSelectionItem;
var beatmapBefore = selectionBefore?.Model as BeatmapInfo;
var beatmapBefore = selectionBefore?.Model as GroupedBeatmap;
bool success;
@@ -809,7 +874,7 @@ namespace osu.Game.Screens.SelectV2
randomHistory.Add(beatmapBefore);
// keep track of visited beatmaps for "RandomPermutation" random tracking.
// note that this is reset when we run out of beatmaps, while `randomHistory` is not.
previouslyVisitedRandomBeatmaps.Add(beatmapBefore);
previouslyVisitedRandomBeatmaps.Add(beatmapBefore.Beatmap);
}
if (grouping.BeatmapSetsGroupedTogether)
@@ -837,29 +902,29 @@ namespace osu.Game.Screens.SelectV2
private bool nextRandomBeatmap()
{
ICollection<BeatmapInfo> visibleBeatmaps = ExpandedGroup != null
ICollection<GroupedBeatmap> visibleBeatmaps = ExpandedGroup != null
// In the case of grouping, users expect random to only operate on the expanded group.
// This is going to incur some overhead as we don't have a group-beatmapset mapping currently.
//
// If this becomes an issue, we could either store a mapping, or run the random algorithm many times
// using the `SetItems` method until we get a group HIT.
? grouping.GroupItems[ExpandedGroup].Select(i => i.Model).OfType<BeatmapInfo>().ToArray()
: GetCarouselItems()!.Select(i => i.Model).OfType<BeatmapInfo>().ToArray();
? grouping.GroupItems[ExpandedGroup].Select(i => i.Model).OfType<GroupedBeatmap>().ToArray()
: GetCarouselItems()!.Select(i => i.Model).OfType<GroupedBeatmap>().ToArray();
BeatmapInfo beatmap;
GroupedBeatmap beatmap;
switch (randomAlgorithm.Value)
{
case RandomSelectAlgorithm.RandomPermutation:
{
ICollection<BeatmapInfo> notYetVisitedBeatmaps = visibleBeatmaps.Except(previouslyVisitedRandomBeatmaps).ToList();
ICollection<GroupedBeatmap> notYetVisitedBeatmaps = visibleBeatmaps.ExceptBy(previouslyVisitedRandomBeatmaps, gb => gb.Beatmap).ToList();
if (!notYetVisitedBeatmaps.Any())
{
previouslyVisitedRandomBeatmaps.RemoveAll(b => visibleBeatmaps.Contains(b));
previouslyVisitedRandomBeatmaps.ExceptWith(visibleBeatmaps.Select(b => b.Beatmap));
notYetVisitedBeatmaps = visibleBeatmaps;
if (CurrentSelection is BeatmapInfo beatmapInfo)
notYetVisitedBeatmaps = notYetVisitedBeatmaps.Except([beatmapInfo]).ToList();
if (CurrentSelection is GroupedBeatmap groupedBeatmap)
notYetVisitedBeatmaps = notYetVisitedBeatmaps.Except([groupedBeatmap]).ToList();
}
if (notYetVisitedBeatmaps.Count == 0)
@@ -883,7 +948,7 @@ namespace osu.Game.Screens.SelectV2
private bool nextRandomSet()
{
ICollection<GroupedBeatmapSet> visibleSetsUnderGrouping = ExpandedGroup != null
ICollection<GroupedBeatmapSet> visibleGroupedSets = ExpandedGroup != null
// In the case of grouping, users expect random to only operate on the expanded group.
// This is going to incur some overhead as we don't have a group-beatmapset mapping currently.
//
@@ -900,14 +965,14 @@ namespace osu.Game.Screens.SelectV2
case RandomSelectAlgorithm.RandomPermutation:
{
ICollection<GroupedBeatmapSet> notYetVisitedSets =
visibleSetsUnderGrouping.ExceptBy(previouslyVisitedRandomBeatmaps.Select(b => b.BeatmapSet!), groupedSet => groupedSet.BeatmapSet).ToList();
visibleGroupedSets.ExceptBy(previouslyVisitedRandomBeatmaps.Select(b => b.BeatmapSet!), groupedSet => groupedSet.BeatmapSet).ToList();
if (!notYetVisitedSets.Any())
{
previouslyVisitedRandomBeatmaps.RemoveAll(b => visibleSetsUnderGrouping.Any(groupedSet => groupedSet.BeatmapSet.Equals(b.BeatmapSet!)));
notYetVisitedSets = visibleSetsUnderGrouping;
if (CurrentSelection is BeatmapInfo beatmapInfo)
notYetVisitedSets = notYetVisitedSets.ExceptBy([beatmapInfo.BeatmapSet!], groupedSet => groupedSet.BeatmapSet).ToList();
previouslyVisitedRandomBeatmaps.ExceptWith(visibleGroupedSets.SelectMany(setUnderGrouping => setUnderGrouping.BeatmapSet.Beatmaps));
notYetVisitedSets = visibleGroupedSets;
if (CurrentSelection is GroupedBeatmap groupedBeatmap)
notYetVisitedSets = notYetVisitedSets.ExceptBy([groupedBeatmap.Beatmap.BeatmapSet!], groupedSet => groupedSet.BeatmapSet).ToList();
}
if (notYetVisitedSets.Count == 0)
@@ -918,7 +983,7 @@ namespace osu.Game.Screens.SelectV2
}
case RandomSelectAlgorithm.Random:
set = visibleSetsUnderGrouping.ElementAt(RNG.Next(visibleSetsUnderGrouping.Count));
set = visibleGroupedSets.ElementAt(RNG.Next(visibleGroupedSets.Count));
break;
default:
@@ -941,15 +1006,19 @@ namespace osu.Game.Screens.SelectV2
var previousBeatmap = randomHistory[^1];
randomHistory.RemoveAt(randomHistory.Count - 1);
var previousBeatmapItem = carouselItems.FirstOrDefault(i => i.Model is BeatmapInfo b && b.Equals(previousBeatmap));
// when going back through rewind history, we may no longer be in the same grouping mode.
// the user wants to go back to the beatmap first and foremost, so the most important thing is to find a panel that corresponds to the beatmap.
// going back to the same group is a nice-to-have, but a secondary concern.
var previousBeatmapItem = carouselItems.Where(i => i.Model is GroupedBeatmap gb && gb.Beatmap.Equals(previousBeatmap.Beatmap))
.MaxBy(i => ((GroupedBeatmap)i.Model).Group == previousBeatmap.Group);
if (previousBeatmapItem == null)
return false;
if (CurrentSelection is BeatmapInfo beatmapInfo)
if (CurrentSelection is GroupedBeatmap groupedBeatmap)
{
if (randomAlgorithm.Value == RandomSelectAlgorithm.RandomPermutation)
previouslyVisitedRandomBeatmaps.Remove(beatmapInfo);
previouslyVisitedRandomBeatmaps.Remove(groupedBeatmap.Beatmap);
if (CurrentSelectionItem == null)
playSpinSample(0);
@@ -957,7 +1026,7 @@ namespace osu.Game.Screens.SelectV2
playSpinSample(visiblePanelCountBetweenItems(previousBeatmapItem, CurrentSelectionItem));
}
RequestSelection(previousBeatmap);
RequestSelection((GroupedBeatmap)previousBeatmapItem.Model);
return true;
}
@@ -1021,4 +1090,11 @@ namespace osu.Game.Screens.SelectV2
/// The purpose of this model is to support splitting beatmap sets apart when the active grouping mode demands it.
/// </summary>
public record GroupedBeatmapSet([UsedImplicitly] GroupDefinition? Group, BeatmapSetInfo BeatmapSet);
/// <summary>
/// Used to represent a <see cref="Beatmap"/> under a <see cref="GroupDefinition"/>.
/// The purpose of this model is to support showing multiple copies of a beatmap, which can occur if a beatmap appears in multiple groups
/// (most prominently, collections group mode).
/// </summary>
public record GroupedBeatmap(GroupDefinition? Group, BeatmapInfo Beatmap);
}
@@ -7,6 +7,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Extensions;
using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Beatmaps;
using osu.Game.Collections;
using osu.Game.Graphics.Carousel;
@@ -120,11 +121,12 @@ namespace osu.Game.Screens.SelectV2
{
if (groupItem != null)
groupItem.NestedItemCount++;
item.DrawHeight = PanelBeatmapStandalone.HEIGHT;
}
addItem(item);
addItem(new CarouselItem(new GroupedBeatmap(group, beatmap))
{
DrawHeight = BeatmapSetsGroupedTogether ? PanelBeatmap.HEIGHT : PanelBeatmapStandalone.HEIGHT,
});
lastBeatmap = beatmap;
displayedBeatmapsCount++;
}
@@ -192,7 +194,7 @@ namespace osu.Game.Screens.SelectV2
var date = b.LastPlayed;
if (date == null || date == DateTimeOffset.MinValue)
return new GroupDefinition(int.MaxValue, "Never");
return new GroupDefinition(int.MaxValue, "Never").Yield();
return defineGroupByDate(date.Value);
}, items);
@@ -236,184 +238,204 @@ namespace osu.Game.Screens.SelectV2
}
}
private List<GroupMapping> getGroupsBy(Func<BeatmapInfo, GroupDefinition?> getGroup, List<CarouselItem> items)
private List<GroupMapping> getGroupsBy(Func<BeatmapInfo, IEnumerable<GroupDefinition>> defineGroups, List<CarouselItem> items)
{
return items.GroupBy(i => getGroup((BeatmapInfo)i.Model))
.Where(g => g.Key != null)
.OrderBy(g => g.Key!.Order)
.ThenBy(g => g.Key!.Title)
.Select(g => new GroupMapping(g.Key, g.ToList()))
.ToList();
var groups = new Dictionary<GroupDefinition, GroupMapping>();
foreach (var item in items)
{
foreach (var groupDefinition in defineGroups((BeatmapInfo)item.Model))
{
if (!groups.TryGetValue(groupDefinition, out var group))
group = groups[groupDefinition] = new GroupMapping(groupDefinition, []);
group.ItemsInGroup.Add(item);
}
}
return groups.Values
.OrderBy(g => g.Group!.Order)
.ThenBy(g => g.Group!.Title)
.ToList();
}
private GroupDefinition defineGroupAlphabetically(string name)
private IEnumerable<GroupDefinition> defineGroupAlphabetically(string name)
{
char firstChar = name.FirstOrDefault();
if (char.IsAsciiDigit(firstChar))
return new GroupDefinition(int.MinValue, "0-9");
return new GroupDefinition(int.MinValue, "0-9").Yield();
if (char.IsAsciiLetter(firstChar))
return new GroupDefinition(char.ToUpperInvariant(firstChar) - 'A', char.ToUpperInvariant(firstChar).ToString());
return new GroupDefinition(char.ToUpperInvariant(firstChar) - 'A', char.ToUpperInvariant(firstChar).ToString()).Yield();
return new GroupDefinition(int.MaxValue, "Other");
return new GroupDefinition(int.MaxValue, "Other").Yield();
}
private GroupDefinition defineGroupByDate(DateTimeOffset date)
private IEnumerable<GroupDefinition> defineGroupByDate(DateTimeOffset date)
{
var now = DateTimeOffset.Now;
var elapsed = now - date;
if (elapsed.TotalDays < 1)
return new GroupDefinition(0, "Today");
return new GroupDefinition(0, "Today").Yield();
if (elapsed.TotalDays < 2)
return new GroupDefinition(1, "Yesterday");
return new GroupDefinition(1, "Yesterday").Yield();
if (elapsed.TotalDays < 7)
return new GroupDefinition(2, "Last week");
return new GroupDefinition(2, "Last week").Yield();
if (elapsed.TotalDays < 30)
return new GroupDefinition(3, "Last month");
return new GroupDefinition(3, "Last month").Yield();
if (elapsed.TotalDays < 60)
return new GroupDefinition(4, "1 month ago");
return new GroupDefinition(4, "1 month ago").Yield();
for (int i = 90; i <= 150; i += 30)
{
if (elapsed.TotalDays < i)
return new GroupDefinition(i, $"{i / 30 - 1} months ago");
return new GroupDefinition(i, $"{i / 30 - 1} months ago").Yield();
}
return new GroupDefinition(151, "Over 5 months ago");
return new GroupDefinition(151, "Over 5 months ago").Yield();
}
private GroupDefinition defineGroupByRankedDate(DateTimeOffset? date)
private IEnumerable<GroupDefinition> defineGroupByRankedDate(DateTimeOffset? date)
{
if (date == null)
return new GroupDefinition(0, "Unranked");
return new GroupDefinition(0, "Unranked").Yield();
return new GroupDefinition(-date.Value.Year, $"{date.Value.Year}");
return new GroupDefinition(-date.Value.Year, $"{date.Value.Year}").Yield();
}
private GroupDefinition defineGroupByStatus(BeatmapOnlineStatus status)
private IEnumerable<GroupDefinition> defineGroupByStatus(BeatmapOnlineStatus status)
{
switch (status)
{
case BeatmapOnlineStatus.Ranked:
case BeatmapOnlineStatus.Approved:
return new GroupDefinition(0, BeatmapOnlineStatus.Ranked.GetDescription());
return new GroupDefinition(0, BeatmapOnlineStatus.Ranked.GetDescription()).Yield();
case BeatmapOnlineStatus.Qualified:
return new GroupDefinition(1, status.GetDescription());
return new GroupDefinition(1, status.GetDescription()).Yield();
case BeatmapOnlineStatus.WIP:
return new GroupDefinition(2, status.GetDescription());
return new GroupDefinition(2, status.GetDescription()).Yield();
case BeatmapOnlineStatus.Pending:
return new GroupDefinition(3, status.GetDescription());
return new GroupDefinition(3, status.GetDescription()).Yield();
case BeatmapOnlineStatus.Graveyard:
return new GroupDefinition(4, status.GetDescription());
return new GroupDefinition(4, status.GetDescription()).Yield();
case BeatmapOnlineStatus.LocallyModified:
return new GroupDefinition(5, status.GetDescription());
return new GroupDefinition(5, status.GetDescription()).Yield();
case BeatmapOnlineStatus.None:
return new GroupDefinition(6, status.GetDescription());
return new GroupDefinition(6, status.GetDescription()).Yield();
case BeatmapOnlineStatus.Loved:
return new GroupDefinition(7, status.GetDescription());
return new GroupDefinition(7, status.GetDescription()).Yield();
default:
throw new ArgumentOutOfRangeException(nameof(status), status, null);
}
}
private GroupDefinition defineGroupByBPM(double bpm)
private IEnumerable<GroupDefinition> defineGroupByBPM(double bpm)
{
if (bpm < 60)
return new GroupDefinition(60, "Under 60 BPM");
return new GroupDefinition(60, "Under 60 BPM").Yield();
for (int i = 70; i <= 300; i += 10)
{
if (bpm < i)
return new GroupDefinition(i, $"{i - 10} - {i} BPM");
return new GroupDefinition(i, $"{i - 10} - {i} BPM").Yield();
}
return new GroupDefinition(301, "Over 300 BPM");
return new GroupDefinition(301, "Over 300 BPM").Yield();
}
private GroupDefinition defineGroupByStars(double stars)
private IEnumerable<GroupDefinition> defineGroupByStars(double stars)
{
// truncation is intentional - compare `FormatUtils.FormatStarRating()`
int starInt = (int)stars;
var starDifficulty = new StarDifficulty(starInt, 0);
if (starInt == 0)
return new StarDifficultyGroupDefinition(0, "Below 1 Star", starDifficulty);
return new StarDifficultyGroupDefinition(0, "Below 1 Star", starDifficulty).Yield();
if (starInt == 1)
return new StarDifficultyGroupDefinition(1, "1 Star", starDifficulty);
return new StarDifficultyGroupDefinition(1, "1 Star", starDifficulty).Yield();
return new StarDifficultyGroupDefinition(starInt, $"{starInt} Stars", starDifficulty);
return new StarDifficultyGroupDefinition(starInt, $"{starInt} Stars", starDifficulty).Yield();
}
private GroupDefinition defineGroupByLength(double length)
private IEnumerable<GroupDefinition> defineGroupByLength(double length)
{
for (int i = 1; i < 6; i++)
{
if (length <= i * 60_000)
{
if (i == 1)
return new GroupDefinition(1, "1 minute or less");
return new GroupDefinition(1, "1 minute or less").Yield();
return new GroupDefinition(i, $"{i} minutes or less");
return new GroupDefinition(i, $"{i} minutes or less").Yield();
}
}
if (length <= 10 * 60_000)
return new GroupDefinition(10, "10 minutes or less");
return new GroupDefinition(10, "10 minutes or less").Yield();
return new GroupDefinition(11, "Over 10 minutes");
return new GroupDefinition(11, "Over 10 minutes").Yield();
}
private GroupDefinition defineGroupBySource(string source)
private IEnumerable<GroupDefinition> defineGroupBySource(string source)
{
if (string.IsNullOrEmpty(source))
return new GroupDefinition(1, "Unsourced");
return new GroupDefinition(1, "Unsourced").Yield();
return new GroupDefinition(0, source);
return new GroupDefinition(0, source).Yield();
}
private GroupDefinition defineGroupByCollection(BeatmapInfo beatmap, IEnumerable<BeatmapCollection> collections)
private IEnumerable<GroupDefinition> defineGroupByCollection(BeatmapInfo beatmap, IEnumerable<BeatmapCollection> collections)
{
bool anyCollections = false;
foreach (var collection in collections)
{
if (collection.BeatmapMD5Hashes.Contains(beatmap.MD5Hash))
return new GroupDefinition(0, collection.Name);
{
yield return new GroupDefinition(0, collection.Name);
anyCollections = true;
}
}
return new GroupDefinition(1, "Not in collection");
if (anyCollections)
yield break;
yield return new GroupDefinition(1, "Not in collection");
}
private GroupDefinition? defineGroupByOwnMaps(BeatmapInfo beatmap, int? localUserId, string? localUserUsername)
private IEnumerable<GroupDefinition> defineGroupByOwnMaps(BeatmapInfo beatmap, int? localUserId, string? localUserUsername)
{
var author = beatmap.BeatmapSet!.Metadata.Author;
if (author.OnlineID == localUserId || (author.OnlineID <= 1 && author.Username == localUserUsername))
return new GroupDefinition(0, "My maps");
return new GroupDefinition(0, "My maps").Yield();
// discard beatmaps not owned by the user.
return null;
return [];
}
private GroupDefinition defineGroupByRankAchieved(BeatmapInfo beatmap, IReadOnlyDictionary<Guid, ScoreRank> topRankMapping)
private IEnumerable<GroupDefinition> defineGroupByRankAchieved(BeatmapInfo beatmap, IReadOnlyDictionary<Guid, ScoreRank> topRankMapping)
{
if (topRankMapping.TryGetValue(beatmap.ID, out var rank))
return new GroupDefinition(-(int)rank, rank.GetDescription());
return new GroupDefinition(-(int)rank, rank.GetDescription()).Yield();
return new GroupDefinition(int.MaxValue, "Unplayed");
return new GroupDefinition(int.MaxValue, "Unplayed").Yield();
}
private record GroupMapping(GroupDefinition? Group, List<CarouselItem> ItemsInGroup);
+3 -9
View File
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@@ -72,6 +71,8 @@ namespace osu.Game.Screens.SelectV2
[Resolved]
private ISongSelect? songSelect { get; set; }
private BeatmapInfo beatmap => ((GroupedBeatmap)Item!.Model).Beatmap;
public PanelBeatmap()
{
PanelXOffset = 60;
@@ -207,9 +208,6 @@ namespace osu.Game.Screens.SelectV2
{
base.PrepareForUse();
Debug.Assert(Item != null);
var beatmap = (BeatmapInfo)Item.Model;
difficultyIcon.Icon = getRulesetIcon(beatmap.Ruleset);
localRank.Beatmap = beatmap;
@@ -248,8 +246,6 @@ namespace osu.Game.Screens.SelectV2
if (Item == null)
return;
var beatmap = (BeatmapInfo)Item.Model;
starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token, SongSelect.DIFFICULTY_CALCULATION_DEBOUNCE);
starDifficultyBindable.BindValueChanged(starDifficulty =>
{
@@ -293,8 +289,6 @@ namespace osu.Game.Screens.SelectV2
if (Item == null)
return;
var beatmap = (BeatmapInfo)Item.Model;
if (ruleset.Value.OnlineID == 3)
{
// Account for mania differences locally for now.
@@ -319,7 +313,7 @@ namespace osu.Game.Screens.SelectV2
List<MenuItem> items = new List<MenuItem>();
if (songSelect != null)
items.AddRange(songSelect.GetForwardActions((BeatmapInfo)Item.Model));
items.AddRange(songSelect.GetForwardActions(beatmap));
return items.ToArray();
}
@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using osu.Framework.Allocation;
using osu.Framework.Bindables;
@@ -73,6 +72,8 @@ namespace osu.Game.Screens.SelectV2
private Box backgroundBorder = null!;
private BeatmapInfo beatmap => ((GroupedBeatmap)Item!.Model).Beatmap;
public PanelBeatmapStandalone()
{
PanelXOffset = 20;
@@ -219,9 +220,6 @@ namespace osu.Game.Screens.SelectV2
{
base.PrepareForUse();
Debug.Assert(Item != null);
var beatmap = (BeatmapInfo)Item.Model;
var beatmapSet = beatmap.BeatmapSet!;
beatmapBackground.Beatmap = beatmaps.GetWorkingBeatmap(beatmap);
@@ -262,8 +260,6 @@ namespace osu.Game.Screens.SelectV2
if (Item == null)
return;
var beatmap = (BeatmapInfo)Item.Model;
starDifficultyBindable = difficultyCache.GetBindableDifficulty(beatmap, starDifficultyCancellationSource.Token, SongSelect.DIFFICULTY_CALCULATION_DEBOUNCE);
starDifficultyBindable.BindValueChanged(starDifficulty =>
{
@@ -300,8 +296,6 @@ namespace osu.Game.Screens.SelectV2
if (Item == null)
return;
var beatmap = (BeatmapInfo)Item.Model;
if (ruleset.Value.OnlineID == 3)
{
// Account for mania differences locally for now.
@@ -326,7 +320,7 @@ namespace osu.Game.Screens.SelectV2
List<MenuItem> items = new List<MenuItem>();
if (songSelect != null)
items.AddRange(songSelect.GetForwardActions((BeatmapInfo)Item.Model));
items.AddRange(songSelect.GetForwardActions(beatmap));
return items.ToArray();
}
+9 -8
View File
@@ -300,9 +300,10 @@ namespace osu.Game.Screens.SelectV2
});
}
private void requestRecommendedSelection(IEnumerable<BeatmapInfo> b)
private void requestRecommendedSelection(IEnumerable<GroupedBeatmap> groupedBeatmaps)
{
queueBeatmapSelection(difficultyRecommender?.GetRecommendedBeatmap(b) ?? b.First());
var recommendedBeatmap = difficultyRecommender?.GetRecommendedBeatmap(groupedBeatmaps.Select(gb => gb.Beatmap)) ?? groupedBeatmaps.First().Beatmap;
queueBeatmapSelection(groupedBeatmaps.First(bug => bug.Beatmap.Equals(recommendedBeatmap)));
}
/// <summary>
@@ -530,16 +531,16 @@ namespace osu.Game.Screens.SelectV2
/// - After <see cref="SELECTION_DEBOUNCE"/>, update the global beatmap. This in turn causes song select visuals (title, details, leaderboard) to update.
/// This debounce is intended to avoid high overheads from churning lookups while a user is changing selection via rapid keyboard operations.
/// </remarks>
/// <param name="beatmap">The beatmap to be selected.</param>
private void queueBeatmapSelection(BeatmapInfo beatmap)
/// <param name="groupedBeatmap">The beatmap to be selected.</param>
private void queueBeatmapSelection(GroupedBeatmap groupedBeatmap)
{
if (!this.IsCurrentScreen())
return;
carousel.CurrentSelection = beatmap;
carousel.CurrentGroupedBeatmap = groupedBeatmap;
// Debounce consideration is to avoid beatmap churn on key repeat selection.
debounceQueueSelection(beatmap);
debounceQueueSelection(groupedBeatmap.Beatmap);
}
private bool ensureGlobalBeatmapValid()
@@ -560,7 +561,7 @@ namespace osu.Game.Screens.SelectV2
if (validSelection)
{
carousel.CurrentSelection = currentBeatmap.BeatmapInfo;
carousel.CurrentBeatmap = currentBeatmap.BeatmapInfo;
return true;
}
@@ -583,7 +584,7 @@ namespace osu.Game.Screens.SelectV2
if (validBeatmaps.Any())
{
requestRecommendedSelection(validBeatmaps);
carousel.CurrentBeatmap = difficultyRecommender?.GetRecommendedBeatmap(validBeatmaps) ?? validBeatmaps.First();
return true;
}
}
+1 -1
View File
@@ -35,7 +35,7 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Realm" Version="20.1.0" />
<PackageReference Include="ppy.osu.Framework" Version="2025.829.0" />
<PackageReference Include="ppy.osu.Framework" Version="2025.903.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2025.821.0" />
<PackageReference Include="Sentry" Version="5.1.1" />
<!-- Held back due to 0.34.0 failing AOT compilation on ZstdSharp.dll dependency. -->
+1 -1
View File
@@ -17,6 +17,6 @@
<MtouchInterpreter>-all</MtouchInterpreter>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="ppy.osu.Framework.iOS" Version="2025.829.0" />
<PackageReference Include="ppy.osu.Framework.iOS" Version="2025.903.0" />
</ItemGroup>
</Project>