1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-23 14:22:08 +08:00

Merge pull request #33871 from dani211e/local-score-sorting-v2

SSv2: Add ability to sort local scores by metrics other than total score
This commit is contained in:
Dean Herbert
2025-07-28 23:12:31 +09:00
committed by GitHub
Unverified
10 changed files with 273 additions and 46 deletions
@@ -0,0 +1,154 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Audio;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Platform;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Graphics.Cursor;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Leaderboards;
using osu.Game.Overlays;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.SelectV2;
using osu.Game.Tests.Resources;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.SongSelectV2
{
public partial class TestSceneBeatmapLeaderboardSorting : SongSelectComponentsTestScene
{
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine);
private BeatmapDetailsArea beatmapDetailsArea = null!;
private ScoreManager scoreManager = null!;
private RulesetStore rulesetStore = null!;
private BeatmapManager beatmapManager = null!;
private OsuContextMenuContainer contentContainer = null!;
private DialogOverlay dialogOverlay = null!;
private LeaderboardManager leaderboardManager = null!;
private RealmPopulatingOnlineLookupSource lookupSource = null!;
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
{
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
dependencies.Cache(rulesetStore = new RealmRulesetStore(Realm));
dependencies.Cache(beatmapManager = new BeatmapManager(LocalStorage, Realm, null, dependencies.Get<AudioManager>(), Resources, dependencies.Get<GameHost>(), Beatmap.Default));
dependencies.Cache(scoreManager = new ScoreManager(rulesetStore, () => beatmapManager, LocalStorage, Realm, API));
dependencies.Cache(leaderboardManager = new LeaderboardManager());
dependencies.Cache(lookupSource = new RealmPopulatingOnlineLookupSource());
Dependencies.Cache(Realm);
return dependencies;
}
[BackgroundDependencyLoader]
private void load()
{
LoadComponent(dialogOverlay = new DialogOverlay
{
Depth = -1
});
LoadComponent(leaderboardManager);
LoadComponent(lookupSource);
Child = contentContainer = new OsuContextMenuContainer
{
RelativeSizeAxes = Axes.X,
Height = 500,
Shear = OsuGame.SHEAR,
Children = new Drawable[]
{
dialogOverlay,
}
};
}
[SetUp]
public void SetUp() => Schedule(() =>
{
if (beatmapDetailsArea.IsNotNull())
contentContainer.Remove(beatmapDetailsArea, false);
contentContainer.Add(beatmapDetailsArea = new BeatmapDetailsArea
{
Origin = Anchor.Centre,
Anchor = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Padding = new MarginPadding { Left = 50 },
State = { Value = Visibility.Visible },
});
});
[SetUpSteps]
public override void SetUpSteps()
{
base.SetUpSteps();
}
[Test]
public void TestLocalScoresSorting()
{
BeatmapInfo beatmapInfo = null!;
AddStep(@"Set beatmap", () =>
{
beatmapManager.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely();
beatmapInfo = beatmapManager.GetAllUsableBeatmapSets().First().Beatmaps.First();
Beatmap.Value = beatmapManager.GetWorkingBeatmap(beatmapInfo);
});
AddStep(@"Import random scores", () =>
{
for (int i = 0; i < 10; ++i)
importRandomScore(beatmapInfo);
});
AddStep("Clear all scores", () => scoreManager.Delete());
}
private void importRandomScore(BeatmapInfo beatmapInfo)
{
scoreManager.Import(new ScoreInfo
{
Rank = ScoreRank.XH,
Accuracy = RNG.NextDouble(0, 1),
MaxCombo = RNG.Next(0, 1500),
TotalScore = RNG.Next(500000, 1200000),
Date = DateTime.Now.AddMinutes(RNG.Next(0, 1000) * -1),
Statistics = new Dictionary<HitResult, int>
{
{ HitResult.Miss, RNG.Next(0, 25) },
},
Ruleset = new OsuRuleset().RulesetInfo,
BeatmapInfo = beatmapInfo,
BeatmapHash = beatmapInfo.Hash,
User = new APIUser
{
Id = 2,
Username = @"peppy",
CountryCode = CountryCode.JP,
},
});
}
}
}
@@ -21,6 +21,7 @@ using osu.Game.Screens.Edit.Compose.Components;
using osu.Game.Screens.OnlinePlay.Lounge.Components;
using osu.Game.Screens.Select;
using osu.Game.Screens.Select.Filter;
using osu.Game.Screens.Select.Leaderboards;
using osu.Game.Skinning;
using osu.Game.Users;
@@ -41,6 +42,7 @@ namespace osu.Game.Configuration
SetDefault(OsuSetting.Skin, SkinInfo.ARGON_SKIN.ToString());
SetDefault(OsuSetting.BeatmapDetailTab, BeatmapDetailTab.Local);
SetDefault(OsuSetting.BeatmapLeaderboardSortMode, LeaderboardSortMode.Score);
SetDefault(OsuSetting.BeatmapDetailModsFilter, false);
SetDefault(OsuSetting.ShowConvertedBeatmaps, true);
@@ -382,6 +384,7 @@ namespace osu.Game.Configuration
MenuParallax,
Prefer24HourTime,
BeatmapDetailTab,
BeatmapLeaderboardSortMode,
BeatmapDetailModsFilter,
Username,
ReleaseStream,
@@ -76,6 +76,9 @@ namespace osu.Game.Online.Leaderboards
default:
{
if (newCriteria.Sorting != LeaderboardSortMode.Score)
throw new NotSupportedException($@"Requesting online scores with a {nameof(LeaderboardSortMode)} other than {nameof(LeaderboardSortMode.Score)} is not supported");
if (!api.IsLoggedIn)
{
scores.Value = LeaderboardScores.Failure(LeaderboardFailState.NotLoggedIn);
@@ -180,7 +183,7 @@ namespace osu.Game.Online.Leaderboards
}
}
newScores = newScores.Detach().OrderByTotalScore();
newScores = newScores.Detach().OrderByCriteria(CurrentCriteria.Sorting);
var newScoresArray = newScores.ToArray();
scores.Value = LeaderboardScores.Success(newScoresArray, newScoresArray.Length, null);
@@ -191,7 +194,8 @@ namespace osu.Game.Online.Leaderboards
BeatmapInfo? Beatmap,
RulesetInfo? Ruleset,
BeatmapLeaderboardScope Scope,
Mod[]? ExactMods
Mod[]? ExactMods,
LeaderboardSortMode Sorting = LeaderboardSortMode.Score
);
public record LeaderboardScores
+32
View File
@@ -1,10 +1,12 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.Select.Leaderboards;
namespace osu.Game.Scoring
{
@@ -26,6 +28,36 @@ namespace osu.Game.Scoring
// Local scores may not have an online ID. Fall back to date in these cases.
.ThenBy(s => s.Date);
/// <summary>
/// Orders an array of <see cref="ScoreInfo"/>s by the selected <see cref="LeaderboardSortMode"/>.
/// </summary>
/// <param name="scores">The array of <see cref="ScoreInfo"/>s to reorder.</param>
/// <param name="leaderboardSortMode">The attribute to sort the scores by.</param>
/// <returns>The given <paramref name="scores"/> ordered by the selected mode.</returns>
public static IEnumerable<ScoreInfo> OrderByCriteria(this IEnumerable<ScoreInfo> scores, LeaderboardSortMode leaderboardSortMode)
{
switch (leaderboardSortMode)
{
case LeaderboardSortMode.Score:
return scores.OrderByDescending(s => s.TotalScore);
case LeaderboardSortMode.Accuracy:
return scores.OrderByDescending(s => s.Accuracy).ThenByDescending(s => s.TotalScore);
case LeaderboardSortMode.MaxCombo:
return scores.OrderByDescending(s => s.MaxCombo).ThenByDescending(s => s.TotalScore);
case LeaderboardSortMode.Misses:
return scores.OrderBy(s => s.Statistics.GetValueOrDefault(HitResult.Miss, 0)).ThenByDescending(s => s.TotalScore);
case LeaderboardSortMode.Date:
return scores.OrderByDescending(s => s.Date);
default:
throw new ArgumentOutOfRangeException(nameof(leaderboardSortMode), leaderboardSortMode, null);
}
}
/// <summary>
/// Retrieves the maximum achievable combo for the provided score.
/// </summary>
+6
View File
@@ -277,6 +277,12 @@ namespace osu.Game.Screens.Play
showStoryboards.BindValueChanged(val => epilepsyWarning?.FadeTo(val.NewValue ? 1 : 0, 250, Easing.OutQuint), true);
epilepsyWarning?.FinishTransforms(true);
// this re-fetch has two purposes:
// - is a safety against potential unexpected screen transitions, making sure that the leaderboard
// displayed during gameplay definitely matches the beatmap and ruleset being played
// (as the solo gameplay leaderboard provider uses the global leaderboard manager to populate itself)
// - the sort mode is not specified and defaults to `Score` which is good because gameplay leaderboards only support sorting by score.
// this may change at some point in the future, at which point specifying a sort mode should be considered.
leaderboardManager?.FetchWithCriteria(new LeaderboardCriteria(
Beatmap.Value.BeatmapInfo,
Ruleset.Value,
@@ -45,7 +45,8 @@ namespace osu.Game.Screens.Ranking
Score.BeatmapInfo!,
Score.Ruleset,
leaderboardManager.CurrentCriteria?.Scope ?? BeatmapLeaderboardScope.Global,
leaderboardManager.CurrentCriteria?.ExactMods
leaderboardManager.CurrentCriteria?.ExactMods,
leaderboardManager.CurrentCriteria?.Sorting ?? LeaderboardSortMode.Score
);
var requestTaskSource = new TaskCompletionSource<LeaderboardScores>();
globalScores.BindValueChanged(_ =>
@@ -0,0 +1,19 @@
// 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.ComponentModel;
namespace osu.Game.Screens.Select.Leaderboards
{
public enum LeaderboardSortMode
{
Score,
Accuracy,
[Description("Max Combo")]
MaxCombo,
Misses,
Date,
}
}
@@ -87,6 +87,7 @@ namespace osu.Game.Screens.SelectV2
currentContent = new BeatmapLeaderboardWedge
{
Scope = { BindTarget = header.Scope },
Sorting = { BindTarget = header.Sorting },
FilterBySelectedMods = { BindTarget = header.FilterBySelectedMods },
};
@@ -24,6 +24,7 @@ namespace osu.Game.Screens.SelectV2
private FillFlowContainer leaderboardControls = null!;
private ShearedDropdown<BeatmapLeaderboardScope> scopeDropdown = null!;
private ShearedDropdown<LeaderboardSortMode> sortDropdown = null!;
private ShearedToggleButton selectedModsToggle = null!;
public IBindable<Selection> Type => tabControl.Current;
@@ -32,6 +33,10 @@ namespace osu.Game.Screens.SelectV2
private readonly Bindable<BeatmapDetailTab> configDetailTab = new Bindable<BeatmapDetailTab>();
public IBindable<LeaderboardSortMode> Sorting => sortDropdown.Current;
private readonly Bindable<LeaderboardSortMode> configLeaderboardSortMode = new Bindable<LeaderboardSortMode>();
public IBindable<bool> FilterBySelectedMods => selectedModsToggle.Active;
[BackgroundDependencyLoader]
@@ -58,52 +63,50 @@ namespace osu.Game.Screens.SelectV2
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
AutoSizeAxes = Axes.Both,
RelativeSizeAxes = Axes.X,
Height = 30,
Spacing = new Vector2(5f, 0f),
Direction = FillDirection.Horizontal,
Padding = new MarginPadding { Left = 125, Right = 133 },
Children = new Drawable[]
{
new Container
sortDropdown = new ShearedDropdown<LeaderboardSortMode>("Sort")
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Size = new Vector2(128f, 30f),
Child = selectedModsToggle = new ShearedToggleButton
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = @"Selected Mods",
Height = 30,
},
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
RelativeSizeAxes = Axes.X,
Width = 0,
Items = Enum.GetValues<LeaderboardSortMode>(),
},
// new Container
// {
// Anchor = Anchor.CentreRight,
// Origin = Anchor.CentreRight,
// Size = new Vector2(150f, 33f),
// Child = new ShearedDropdown<RankingsSort>(@"Sort")
// {
// Width = 150f,
// Items = Enum.GetValues<RankingsSort>(),
// },
// },
new Container
scopeDropdown = new ScopeDropdown
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Size = new Vector2(160f, 32f),
Child = scopeDropdown = new ScopeDropdown
{
Width = 160f,
Current = { Value = BeatmapLeaderboardScope.Global },
},
Anchor = Anchor.TopRight,
Origin = Anchor.TopRight,
RelativeSizeAxes = Axes.X,
Width = 0.4f,
Current = { Value = BeatmapLeaderboardScope.Global },
},
},
},
new Container
{
Anchor = Anchor.CentreRight,
Origin = Anchor.CentreRight,
Size = new Vector2(128f, 30f),
Child = selectedModsToggle = new ShearedToggleButton
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Text = @"Selected Mods",
Height = 30,
},
},
},
},
};
config.BindWith(OsuSetting.BeatmapDetailTab, configDetailTab);
config.BindWith(OsuSetting.BeatmapLeaderboardSortMode, configLeaderboardSortMode);
config.BindWith(OsuSetting.BeatmapDetailModsFilter, selectedModsToggle.Active);
}
@@ -114,12 +117,22 @@ namespace osu.Game.Screens.SelectV2
scopeDropdown.Current.Value = tryMapDetailTabToLeaderboardScope(configDetailTab.Value) ?? scopeDropdown.Current.Value;
scopeDropdown.Current.BindValueChanged(_ => updateConfigDetailTab());
sortDropdown.Current.Value = configLeaderboardSortMode.Value;
sortDropdown.Current.BindValueChanged(v => configLeaderboardSortMode.Value = v.NewValue);
tabControl.Current.Value = configDetailTab.Value == BeatmapDetailTab.Details ? Selection.Details : Selection.Ranking;
tabControl.Current.BindValueChanged(v =>
{
leaderboardControls.FadeTo(v.NewValue == Selection.Ranking ? 1 : 0, 300, Easing.OutQuint);
updateConfigDetailTab();
}, true);
scopeDropdown.Current.BindValueChanged(v =>
{
bool isLocal = v.NewValue == BeatmapLeaderboardScope.Local;
sortDropdown.ResizeWidthTo(isLocal ? 0.4f : 0, 300, Easing.OutQuint);
sortDropdown.FadeTo(isLocal ? 1 : 0, 300, Easing.OutQuint);
}, true);
}
#region Reading / writing state from / to configuration
@@ -197,15 +210,6 @@ namespace osu.Game.Screens.SelectV2
Ranking,
}
// public enum RankingsSort
// {
// Score,
// Accuracy,
// Combo,
// Misses,
// Date,
// }
private partial class ScopeDropdown : ShearedDropdown<BeatmapLeaderboardScope>
{
public ScopeDropdown()
@@ -45,6 +45,8 @@ namespace osu.Game.Screens.SelectV2
public IBindable<BeatmapLeaderboardScope> Scope { get; } = new Bindable<BeatmapLeaderboardScope>();
public IBindable<LeaderboardSortMode> Sorting { get; } = new Bindable<LeaderboardSortMode>();
public IBindable<bool> FilterBySelectedMods { get; } = new BindableBool();
[Resolved]
@@ -188,6 +190,7 @@ namespace osu.Game.Screens.SelectV2
base.LoadComplete();
Scope.BindValueChanged(_ => refetchScores());
Sorting.BindValueChanged(_ => refetchScores());
FilterBySelectedMods.BindValueChanged(_ => refetchScores());
beatmap.BindValueChanged(_ => refetchScores());
ruleset.BindValueChanged(_ => refetchScores());
@@ -233,12 +236,12 @@ namespace osu.Game.Screens.SelectV2
{
var fetchBeatmapInfo = beatmap.Value.BeatmapInfo;
var fetchRuleset = ruleset.Value ?? fetchBeatmapInfo.Ruleset;
var fetchSorting = Scope.Value == BeatmapLeaderboardScope.Local ? Sorting.Value : LeaderboardSortMode.Score;
// For now, we forcefully refresh to keep things simple.
// In the future, removing this requirement may be deemed useful, but will need ample testing of edge case scenarios
// (like returning from gameplay after setting a new score, returning to song select after main menu).
leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope.Value, FilterBySelectedMods.Value ? mods.Value.ToArray() : null),
forceRefresh: true);
leaderboardManager.FetchWithCriteria(new LeaderboardCriteria(fetchBeatmapInfo, fetchRuleset, Scope.Value, FilterBySelectedMods.Value ? mods.Value.ToArray() : null, fetchSorting), forceRefresh: true);
if (!initialFetchComplete)
{