From 3263060f2c90736bacf2dadebbddfaadaacb6f08 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Fri, 4 Jul 2025 09:23:57 +0300 Subject: [PATCH 01/51] Add grouping separator to PP display in user profile overlay --- .../Profile/Sections/Ranks/DrawableProfileScore.cs | 13 ++++++++----- .../Sections/Ranks/DrawableProfileWeightedScore.cs | 5 ++++- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index 407e9959f0..52e2ad6041 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using osu.Framework.Allocation; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -268,21 +269,23 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Direction = FillDirection.Horizontal, Children = new[] { - new OsuSpriteText + new SpriteTextWithTooltip { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Font = font, - Text = $"{Score.PP:0}", - Colour = colourProvider.Highlight1 + Text = Score.PP.ToLocalisableString(@"N0"), + TooltipText = Score.PP.ToLocalisableString(@"N"), + Colour = colourProvider.Highlight1, }, - new OsuSpriteText + new SpriteTextWithTooltip { Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Font = font.With(size: 12), Text = "pp", - Colour = colourProvider.Light3 + TooltipText = Score.PP.ToLocalisableString(@"N"), + Colour = colourProvider.Light3, } } }; diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs index 6cfe34ec6f..36b20d0be5 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs @@ -4,6 +4,7 @@ using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; @@ -44,7 +45,9 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Child = new OsuSpriteText { Font = OsuFont.GetFont(size: 14, weight: FontWeight.Bold, italics: true), - Text = Score.PP.HasValue ? $"{Score.PP * weight:0}pp" : string.Empty, + Text = Score.PP.HasValue + ? LocalisableString.Interpolate($"{Score.PP * weight:N0}pp") + : string.Empty, }, } } From 2029404f53571e53f12c3f5f8f73a16a839167ea Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Fri, 4 Jul 2025 10:44:09 +0300 Subject: [PATCH 02/51] Fix incorrect formating used for tooltips --- .../Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index 52e2ad6041..c651390869 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -275,7 +275,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Origin = Anchor.BottomLeft, Font = font, Text = Score.PP.ToLocalisableString(@"N0"), - TooltipText = Score.PP.ToLocalisableString(@"N"), + TooltipText = Score.PP.ToLocalisableString(@"0.###"), Colour = colourProvider.Highlight1, }, new SpriteTextWithTooltip @@ -284,7 +284,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Origin = Anchor.BottomLeft, Font = font.With(size: 12), Text = "pp", - TooltipText = Score.PP.ToLocalisableString(@"N"), + TooltipText = Score.PP.ToLocalisableString(@"0.###"), Colour = colourProvider.Light3, } } From 6da7db50822fb4f00f0b59eb42c10a5c127d9453 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Fri, 4 Jul 2025 10:57:57 +0300 Subject: [PATCH 03/51] Fix tooltips formatting again --- .../Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index c651390869..22156b8904 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -275,7 +275,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Origin = Anchor.BottomLeft, Font = font, Text = Score.PP.ToLocalisableString(@"N0"), - TooltipText = Score.PP.ToLocalisableString(@"0.###"), + TooltipText = Score.PP.ToLocalisableString(@"#,0.###"), Colour = colourProvider.Highlight1, }, new SpriteTextWithTooltip @@ -284,7 +284,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Origin = Anchor.BottomLeft, Font = font.With(size: 12), Text = "pp", - TooltipText = Score.PP.ToLocalisableString(@"0.###"), + TooltipText = Score.PP.ToLocalisableString(@"#,0.###"), Colour = colourProvider.Light3, } } From a1bbbf1ab92ec5aa09f5b5436738d7b729d67204 Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Fri, 4 Jul 2025 15:47:50 +0300 Subject: [PATCH 04/51] Lower decimal digits to one --- .../Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index 22156b8904..cd8f412a5b 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -275,7 +275,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Origin = Anchor.BottomLeft, Font = font, Text = Score.PP.ToLocalisableString(@"N0"), - TooltipText = Score.PP.ToLocalisableString(@"#,0.###"), + TooltipText = Score.PP.ToLocalisableString(@"N1"), Colour = colourProvider.Highlight1, }, new SpriteTextWithTooltip @@ -284,7 +284,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Origin = Anchor.BottomLeft, Font = font.With(size: 12), Text = "pp", - TooltipText = Score.PP.ToLocalisableString(@"#,0.###"), + TooltipText = Score.PP.ToLocalisableString(@"N1"), Colour = colourProvider.Light3, } } From f082b60c9baddf46a52dd90c8d40cc408d073557 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 7 Jul 2025 09:53:26 +0200 Subject: [PATCH 05/51] Track count of times gameplay was paused on `ScoreInfo` --- osu.Game.Tests/Visual/Gameplay/TestScenePause.cs | 2 ++ osu.Game/Scoring/ScoreInfo.cs | 3 +++ osu.Game/Screens/Play/Player.cs | 2 +- osu.Game/Screens/Play/SubmittingPlayer.cs | 12 ++++++++++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index 58fe6e8e56..03f5dacfa0 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -69,6 +69,7 @@ namespace osu.Game.Tests.Visual.Gameplay pauseViaBackAction(); pauseViaBackAction(); confirmPausedWithNoOverlay(); + AddAssert("score pause count incremented", () => Player.Score.ScoreInfo.PauseCount, () => Is.EqualTo(1)); } [Test] @@ -77,6 +78,7 @@ namespace osu.Game.Tests.Visual.Gameplay pauseViaPauseGameplayAction(); pauseViaPauseGameplayAction(); confirmPausedWithNoOverlay(); + AddAssert("score pause count incremented", () => Player.Score.ScoreInfo.PauseCount, () => Is.EqualTo(1)); } [Test] diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index a3dabc7945..3b0c53e9b3 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -155,6 +155,9 @@ namespace osu.Game.Scoring [MapTo("MaximumStatistics")] public string MaximumStatisticsJson { get; set; } = string.Empty; + [Ignored] + public int PauseCount { get; set; } + public ScoreInfo(BeatmapInfo? beatmap = null, RulesetInfo? ruleset = null, RealmUser? realmUser = null) { Ruleset = ruleset ?? new RulesetInfo(); diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 6ee3ed13a0..2a98527c16 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -1046,7 +1046,7 @@ namespace osu.Game.Screens.Play // already resuming && !IsResuming; - public bool Pause() + public virtual bool Pause() { if (!pausingSupportedByCurrentState) return false; diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 7becb2b33e..c950621134 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -234,6 +234,18 @@ namespace osu.Game.Screens.Play spectatorClient.BeginPlaying(token, GameplayState, Score); } + public override bool Pause() + { + bool wasPaused = GameplayClockContainer.IsPaused.Value; + + bool paused = base.Pause(); + + if (!wasPaused && paused) + Score.ScoreInfo.PauseCount++; + + return paused; + } + protected override void OnFail() { base.OnFail(); From c83dcdc915f5649cc92960283610d666900bbabd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 7 Jul 2025 10:32:07 +0200 Subject: [PATCH 06/51] Store score pause count to realm database --- osu.Game/Database/RealmAccess.cs | 3 ++- osu.Game/Scoring/ScoreInfo.cs | 1 - 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 59cbfcb1e3..0c2f2d4aba 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -99,8 +99,9 @@ namespace osu.Game.Database /// 47 2025-01-21 Remove right mouse button binding for absolute scroll. Never use mouse buttons (or scroll) for global actions. /// 48 2025-03-19 Clear online status for all qualified beatmaps (some were stuck in a qualified state due to local caching issues). /// 49 2025-06-10 Reset the LegacyOnlineID to -1 for all scores that have it set to 0 (which is semantically the same) for consistency of handling with OnlineID. + /// 50 2025-07-07 Add ScoreInfo.PauseCount. /// - private const int schema_version = 49; + private const int schema_version = 50; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index 3b0c53e9b3..a404375d0e 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -155,7 +155,6 @@ namespace osu.Game.Scoring [MapTo("MaximumStatistics")] public string MaximumStatisticsJson { get; set; } = string.Empty; - [Ignored] public int PauseCount { get; set; } public ScoreInfo(BeatmapInfo? beatmap = null, RulesetInfo? ruleset = null, RealmUser? realmUser = null) From 2b6dab1e9d55fe55bf6888e80ab4d1f1f32b089e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 7 Jul 2025 10:39:30 +0200 Subject: [PATCH 07/51] Store score pause count to replays --- osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs | 2 ++ osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs | 4 ++++ osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs | 2 ++ 3 files changed, 8 insertions(+) diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs index de07e2be01..0b498e340c 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs @@ -321,6 +321,7 @@ namespace osu.Game.Tests.Beatmaps.Formats CountryCode = CountryCode.PL }; scoreInfo.ClientVersion = "2023.1221.0"; + scoreInfo.PauseCount = 3; var beatmap = new TestBeatmap(ruleset); var score = new Score @@ -345,6 +346,7 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(decodedAfterEncode.ScoreInfo.Mods, Is.EqualTo(scoreInfo.Mods)); Assert.That(decodedAfterEncode.ScoreInfo.ClientVersion, Is.EqualTo("2023.1221.0")); Assert.That(decodedAfterEncode.ScoreInfo.RealmUser.OnlineID, Is.EqualTo(3035836)); + Assert.That(decodedAfterEncode.ScoreInfo.PauseCount, Is.EqualTo(3)); }); } diff --git a/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs b/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs index c99f104418..5995e2358b 100644 --- a/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs +++ b/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs @@ -49,6 +49,9 @@ namespace osu.Game.Scoring.Legacy [JsonProperty("total_score_without_mods")] public long? TotalScoreWithoutMods { get; set; } + [JsonProperty("pause_count")] + public int PauseCount { get; set; } + public static LegacyReplaySoloScoreInfo FromScore(ScoreInfo score) => new LegacyReplaySoloScoreInfo { OnlineID = score.OnlineID, @@ -59,6 +62,7 @@ namespace osu.Game.Scoring.Legacy Rank = score.Rank, UserID = score.User.OnlineID, TotalScoreWithoutMods = score.TotalScoreWithoutMods > 0 ? score.TotalScoreWithoutMods : null, + PauseCount = score.PauseCount, }; } } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index ec2b567a7b..987b3cd373 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -142,6 +142,8 @@ namespace osu.Game.Scoring.Legacy score.ScoreInfo.TotalScoreWithoutMods = totalScoreWithoutMods; else PopulateTotalScoreWithoutMods(score.ScoreInfo); + + score.ScoreInfo.PauseCount = readScore.PauseCount; }); } } From 4cdbe7e195f0c5a50176e87a983e4419105889fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 7 Jul 2025 10:43:41 +0200 Subject: [PATCH 08/51] Pass along pause count when submitting score --- osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs index da4122c434..8586133c5b 100644 --- a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs +++ b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs @@ -87,6 +87,9 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty("legacy_score_id")] public ulong? LegacyScoreId { get; set; } + [JsonProperty("pause_count")] + public int PauseCount { get; set; } + #region osu-web API additions (not stored to database). [JsonProperty("id")] @@ -260,6 +263,7 @@ namespace osu.Game.Online.API.Requests.Responses Mods = score.APIMods, Statistics = score.Statistics.Where(kvp => kvp.Value != 0).ToDictionary(), MaximumStatistics = score.MaximumStatistics.Where(kvp => kvp.Value != 0).ToDictionary(), + PauseCount = score.PauseCount, }; } } From c275064dea65740af6fdc5e17558f4d67e7917ac Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Tue, 8 Jul 2025 14:00:20 +0300 Subject: [PATCH 09/51] Add "pp" suffix to tooltip --- .../Profile/Sections/Ranks/DrawableProfileScore.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index cd8f412a5b..247faaeabf 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -263,6 +263,8 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks }; } + var ppTooltipText = LocalisableString.Interpolate($@"{Score.PP:N1}pp"); + return new FillFlowContainer { AutoSizeAxes = Axes.Both, @@ -275,7 +277,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Origin = Anchor.BottomLeft, Font = font, Text = Score.PP.ToLocalisableString(@"N0"), - TooltipText = Score.PP.ToLocalisableString(@"N1"), + TooltipText = ppTooltipText, Colour = colourProvider.Highlight1, }, new SpriteTextWithTooltip @@ -283,8 +285,8 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks Anchor = Anchor.BottomLeft, Origin = Anchor.BottomLeft, Font = font.With(size: 12), - Text = "pp", - TooltipText = Score.PP.ToLocalisableString(@"N1"), + Text = @"pp", + TooltipText = ppTooltipText, Colour = colourProvider.Light3, } } From f1eb7d367b562c22c208eed133a495ef2352af19 Mon Sep 17 00:00:00 2001 From: Hivie Date: Sun, 13 Jul 2025 01:53:07 +0100 Subject: [PATCH 10/51] include all of a beatmapset's diffs in the verifier context --- .../Rulesets/Edit/BeatmapVerifierContext.cs | 37 ++++++++++++++++++- osu.Game/Screens/Edit/Verify/IssueList.cs | 5 ++- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs b/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs index 53bdf3140c..647c43a3f2 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs @@ -1,6 +1,8 @@ // 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 osu.Game.Beatmaps; namespace osu.Game.Rulesets.Edit @@ -26,11 +28,44 @@ namespace osu.Game.Rulesets.Edit /// public DifficultyRating InterpretedDifficulty; - public BeatmapVerifierContext(IBeatmap beatmap, IWorkingBeatmap workingBeatmap, DifficultyRating difficultyRating = DifficultyRating.ExpertPlus) + /// + /// All beatmap difficulties in the same beatmapset, including the current beatmap. + /// + public IReadOnlyList BeatmapsetDifficulties => beatmapsetDifficulties.Value; + + private readonly Lazy> beatmapsetDifficulties; + + public BeatmapVerifierContext(IBeatmap beatmap, IWorkingBeatmap workingBeatmap, DifficultyRating difficultyRating = DifficultyRating.ExpertPlus, Func? beatmapResolver = null) { Beatmap = beatmap; WorkingBeatmap = workingBeatmap; InterpretedDifficulty = difficultyRating; + + beatmapsetDifficulties = new Lazy>(() => + { + var beatmapSet = beatmap.BeatmapInfo.BeatmapSet; + if (beatmapSet?.Beatmaps == null) + return new[] { beatmap }; + + var difficulties = new List(); + + foreach (var beatmapInfo in beatmapSet.Beatmaps) + { + // Use the current beatmap if it matches this BeatmapInfo + if (beatmapInfo.Equals(beatmap.BeatmapInfo)) + { + difficulties.Add(beatmap); + continue; + } + + // Try to resolve other difficulties using the provided resolver + var working = beatmapResolver?.Invoke(beatmapInfo); + if (working?.Beatmap != null) + difficulties.Add(working.Beatmap); + } + + return difficulties; + }); } } } diff --git a/osu.Game/Screens/Edit/Verify/IssueList.cs b/osu.Game/Screens/Edit/Verify/IssueList.cs index de7b760bcd..62056e2ae1 100644 --- a/osu.Game/Screens/Edit/Verify/IssueList.cs +++ b/osu.Game/Screens/Edit/Verify/IssueList.cs @@ -33,6 +33,9 @@ namespace osu.Game.Screens.Edit.Verify [Resolved] private VerifyScreen verify { get; set; } + [Resolved] + private BeatmapManager beatmapManager { get; set; } + private IBeatmapVerifier rulesetVerifier; private BeatmapVerifier generalVerifier; private BeatmapVerifierContext context; @@ -43,7 +46,7 @@ namespace osu.Game.Screens.Edit.Verify generalVerifier = new BeatmapVerifier(); rulesetVerifier = beatmap.BeatmapInfo.Ruleset.CreateInstance().CreateBeatmapVerifier(); - context = new BeatmapVerifierContext(beatmap, workingBeatmap.Value, verify.InterpretedDifficulty.Value); + context = new BeatmapVerifierContext(beatmap, workingBeatmap.Value, verify.InterpretedDifficulty.Value, beatmapInfo => beatmapManager.GetWorkingBeatmap(beatmapInfo)); verify.InterpretedDifficulty.BindValueChanged(difficulty => context.InterpretedDifficulty = difficulty.NewValue); RelativeSizeAxes = Axes.Both; From ccf6d9c1733d874ad4a4fd49e199f78f6a176a18 Mon Sep 17 00:00:00 2001 From: Hivie Date: Sun, 13 Jul 2025 02:00:50 +0100 Subject: [PATCH 11/51] Add verify check for lowest diff drain time requirement --- .../Edit/CatchBeatmapVerifier.cs | 3 + .../Checks/CheckCatchLowestDiffDrainTime.cs | 20 +++++ .../Checks/CheckManiaLowestDiffDrainTime.cs | 20 +++++ .../Edit/ManiaBeatmapVerifier.cs | 3 + .../Checks/CheckOsuLowestDiffDrainTime.cs | 20 +++++ .../Edit/OsuBeatmapVerifier.cs | 1 + .../Checks/CheckTaikoLowestDiffDrainTime.cs | 20 +++++ .../Edit/TaikoBeatmapVerifier.cs | 3 + .../Edit/Checks/CheckLowestDiffDrainTime.cs | 88 +++++++++++++++++++ 9 files changed, 178 insertions(+) create mode 100644 osu.Game.Rulesets.Catch/Edit/Checks/CheckCatchLowestDiffDrainTime.cs create mode 100644 osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaLowestDiffDrainTime.cs create mode 100644 osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuLowestDiffDrainTime.cs create mode 100644 osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.cs create mode 100644 osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs diff --git a/osu.Game.Rulesets.Catch/Edit/CatchBeatmapVerifier.cs b/osu.Game.Rulesets.Catch/Edit/CatchBeatmapVerifier.cs index 374ab16633..0783ec72e9 100644 --- a/osu.Game.Rulesets.Catch/Edit/CatchBeatmapVerifier.cs +++ b/osu.Game.Rulesets.Catch/Edit/CatchBeatmapVerifier.cs @@ -18,6 +18,9 @@ namespace osu.Game.Rulesets.Catch.Edit new CheckBananaShowerGap(), new CheckConcurrentObjects(), + // Spread + new CheckCatchLowestDiffDrainTime(), + // Settings new CheckCatchAbnormalDifficultySettings(), }; diff --git a/osu.Game.Rulesets.Catch/Edit/Checks/CheckCatchLowestDiffDrainTime.cs b/osu.Game.Rulesets.Catch/Edit/Checks/CheckCatchLowestDiffDrainTime.cs new file mode 100644 index 0000000000..70d806100f --- /dev/null +++ b/osu.Game.Rulesets.Catch/Edit/Checks/CheckCatchLowestDiffDrainTime.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks; + +namespace osu.Game.Rulesets.Catch.Edit.Checks +{ + public class CheckCatchLowestDiffDrainTime : CheckLowestDiffDrainTime + { + protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds() + { + // See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21catch#general + yield return (DifficultyRating.Hard, (2 * 60 + 30) * 1000, "Platter"); // 2:30 + yield return (DifficultyRating.Insane, (3 * 60 + 15) * 1000, "Rain"); // 3:15 + yield return (DifficultyRating.Expert, 4 * 60 * 1000, "Overdose"); // 4:00 + } + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaLowestDiffDrainTime.cs b/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaLowestDiffDrainTime.cs new file mode 100644 index 0000000000..4d8cf458b8 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaLowestDiffDrainTime.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks; + +namespace osu.Game.Rulesets.Mania.Edit.Checks +{ + public class CheckManiaLowestDiffDrainTime : CheckLowestDiffDrainTime + { + protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds() + { + // See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21mania#rules + yield return (DifficultyRating.Hard, (2 * 60 + 30) * 1000, "Hard"); // 2:30 + yield return (DifficultyRating.Insane, (2 * 60 + 45) * 1000, "Insane"); // 2:45 + yield return (DifficultyRating.Expert, (3 * 60 + 30) * 1000, "Expert"); // 3:30 + } + } +} diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBeatmapVerifier.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBeatmapVerifier.cs index efb1d354af..17997ed463 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaBeatmapVerifier.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBeatmapVerifier.cs @@ -16,6 +16,9 @@ namespace osu.Game.Rulesets.Mania.Edit // Compose new CheckManiaConcurrentObjects(), + // Spread + new CheckManiaLowestDiffDrainTime(), + // Settings new CheckKeyCount(), new CheckManiaAbnormalDifficultySettings(), diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuLowestDiffDrainTime.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuLowestDiffDrainTime.cs new file mode 100644 index 0000000000..400fe7d0fa --- /dev/null +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuLowestDiffDrainTime.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks; + +namespace osu.Game.Rulesets.Osu.Edit.Checks +{ + public class CheckOsuLowestDiffDrainTime : CheckLowestDiffDrainTime + { + protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds() + { + // See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21#general + yield return (DifficultyRating.Hard, (3 * 60 + 30) * 1000, "Hard"); // 3:30 + yield return (DifficultyRating.Insane, (4 * 60 + 15) * 1000, "Insane"); // 4:15 + yield return (DifficultyRating.Expert, 5 * 60 * 1000, "Expert"); // 5:00 + } + } +} diff --git a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs index c3796124b8..67fddfb8a4 100644 --- a/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs +++ b/osu.Game.Rulesets.Osu/Edit/OsuBeatmapVerifier.cs @@ -23,6 +23,7 @@ namespace osu.Game.Rulesets.Osu.Edit new CheckTimeDistanceEquality(), new CheckLowDiffOverlaps(), new CheckTooShortSliders(), + new CheckOsuLowestDiffDrainTime(), // Settings new CheckOsuAbnormalDifficultySettings(), diff --git a/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.cs b/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.cs new file mode 100644 index 0000000000..60a7cd2a5e --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks; + +namespace osu.Game.Rulesets.Taiko.Edit.Checks +{ + public class CheckTaikoLowestDiffDrainTime : CheckLowestDiffDrainTime + { + protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds() + { + // See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21taiko#general + yield return (DifficultyRating.Hard, (3 * 60 + 30) * 1000, "Muzukashii"); // 3:30 + yield return (DifficultyRating.Insane, (4 * 60 + 15) * 1000, "Oni"); // 4:15 + yield return (DifficultyRating.Expert, 5 * 60 * 1000, "Inner Oni"); // 5:00 + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Edit/TaikoBeatmapVerifier.cs b/osu.Game.Rulesets.Taiko/Edit/TaikoBeatmapVerifier.cs index 8f695c4834..23d0abed08 100644 --- a/osu.Game.Rulesets.Taiko/Edit/TaikoBeatmapVerifier.cs +++ b/osu.Game.Rulesets.Taiko/Edit/TaikoBeatmapVerifier.cs @@ -17,6 +17,9 @@ namespace osu.Game.Rulesets.Taiko.Edit // Compose new CheckConcurrentObjects(), + // Spread + new CheckTaikoLowestDiffDrainTime(), + // Settings new CheckTaikoAbnormalDifficultySettings(), }; diff --git a/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs b/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs new file mode 100644 index 0000000000..47db1fc54b --- /dev/null +++ b/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs @@ -0,0 +1,88 @@ +// 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 osu.Game.Beatmaps; +using osu.Game.Rulesets.Edit.Checks.Components; + +namespace osu.Game.Rulesets.Edit.Checks +{ + public abstract class CheckLowestDiffDrainTime : ICheck + { + /// + /// Defines the minimum drain time thresholds for different difficulty ratings. + /// + protected abstract IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds(); + + private const double break_time_leniency = 30 * 1000; + + public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Spread, "Lowest difficulty too difficult for the given drain/play time(s)"); + + public IEnumerable PossibleTemplates => new IssueTemplate[] + { + new IssueTemplateTooShort(this) + }; + + public IEnumerable Run(BeatmapVerifierContext context) + { + IReadOnlyList difficulties = context.BeatmapsetDifficulties; + + if (difficulties.Count == 0) + yield break; + + var lowestDifficulty = difficulties.OrderBy(b => b.BeatmapInfo.StarRating).First(); + + // Get difficulty rating for the lowest difficulty + DifficultyRating lowestDifficultyRating = lowestDifficulty == context.Beatmap + ? context.InterpretedDifficulty + : StarDifficulty.GetDifficultyRating(lowestDifficulty.BeatmapInfo.StarRating); + + double drainTime = context.Beatmap.CalculateDrainLength(); + double playTime = context.Beatmap.CalculatePlayableLength(); + + bool isHighestDifficulty = difficulties.OrderByDescending(b => b.BeatmapInfo.StarRating).First() == context.Beatmap; + + // Use play time unless it's the highest difficulty and has significant breaks + bool canUsePlayTime = !isHighestDifficulty || context.Beatmap.TotalBreakTime < break_time_leniency; + + double effectiveTime = canUsePlayTime ? playTime : drainTime; + double thresholdReduction = canUsePlayTime ? 0 : break_time_leniency; + + // Check against thresholds based on the lowest difficulty's rating in the beatmapset + // Find the most appropriate threshold (highest rating that applies) + var applicableThreshold = GetThresholds() + .Where(t => lowestDifficultyRating >= t.rating) + .OrderByDescending(t => t.rating) + .FirstOrDefault(); + + if (applicableThreshold != default && effectiveTime < applicableThreshold.thresholdMs - thresholdReduction) + { + yield return new IssueTemplateTooShort(this).Create( + applicableThreshold.name, + canUsePlayTime ? "play" : "drain", + context.Beatmap.BeatmapInfo.DifficultyName, + applicableThreshold.thresholdMs - thresholdReduction, + effectiveTime + ); + } + } + + public class IssueTemplateTooShort : IssueTemplate + { + public IssueTemplateTooShort(ICheck check) + : base(check, IssueType.Problem, "With a lowest difficulty {0}, the {1} time of {2} must be at least {3}, currently {4}.") + { + } + + public Issue Create(string lowestDiffLevel, string timeType, string beatmapName, double requiredTime, double currentTime) + => new Issue(this, + lowestDiffLevel, + timeType, + beatmapName, + TimeSpan.FromMilliseconds(requiredTime).ToString(@"m\:ss"), + TimeSpan.FromMilliseconds(currentTime).ToString(@"m\:ss")); + } + } +} From 47bb254497f986e75e21a2850e79d9b938658f5d Mon Sep 17 00:00:00 2001 From: Hivie Date: Sun, 13 Jul 2025 02:01:00 +0100 Subject: [PATCH 12/51] add test coverage --- .../Checks/CheckLowestDiffDrainTimeTest.cs | 260 ++++++++++++++++++ 1 file changed, 260 insertions(+) create mode 100644 osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs diff --git a/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs b/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs new file mode 100644 index 0000000000..96f942fd8e --- /dev/null +++ b/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs @@ -0,0 +1,260 @@ +// Copyright (c) ppy Pty Ltd . 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.Game.Beatmaps; +using osu.Game.Beatmaps.Timing; +using osu.Game.Extensions; +using osu.Game.Rulesets.Edit; +using osu.Game.Rulesets.Edit.Checks; +using osu.Game.Rulesets.Objects; +using osu.Game.Tests.Beatmaps; + +namespace osu.Game.Tests.Editing.Checks +{ + [TestFixture] + public class CheckLowestDiffDrainTimeTest + { + private TestCheckLowestDiffDrainTime check = null!; + + [SetUp] + public void Setup() + { + check = new TestCheckLowestDiffDrainTime(); + } + + [Test] + public void TestSingleDifficultyMeetsRequirement() + { + var beatmap = createBeatmapWithDrainTime(4 * 60 * 1000, 3.5, "Hard"); // 4 minutes + assertOk(beatmap); + } + + [Test] + public void TestSingleDifficultyTooShort() + { + var beatmap = createBeatmapWithDrainTime(2 * 60 * 1000, 3.5, "Hard"); // 2 minutes - too short for Hard + assertTooShort(beatmap); + } + + [Test] + public void TestHardDifficultyAtThreshold() + { + var beatmap = createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 3.5, "Hard"); // Exactly 3:30 + assertOk(beatmap); + } + + [Test] + public void TestHardDifficultyJustUnderThreshold() + { + var beatmap = createBeatmapWithDrainTime((3 * 60 + 29) * 1000, 3.5, "Hard"); // 3:29 - just under threshold + assertTooShort(beatmap); + } + + [Test] + public void TestInsaneDifficultyAtThreshold() + { + var beatmap = createBeatmapWithDrainTime((4 * 60 + 15) * 1000, 4.5, "Insane"); // Exactly 4:15 + assertOk(beatmap); + } + + [Test] + public void TestInsaneDifficultyTooShort() + { + var beatmap = createBeatmapWithDrainTime(4 * 60 * 1000, 4.5, "Insane"); // 4:00 - too short for Insane + assertTooShort(beatmap); + } + + [Test] + public void TestExpertDifficultyAtThreshold() + { + var beatmap = createBeatmapWithDrainTime(5 * 60 * 1000, 5.5, "Expert"); // Exactly 5:00 + assertOk(beatmap); + } + + [Test] + public void TestExpertDifficultyTooShort() + { + var beatmap = createBeatmapWithDrainTime((4 * 60 + 30) * 1000, 5.5, "Expert"); // 4:30 - too short for Expert + assertTooShort(beatmap); + } + + [Test] + public void TestEasyDifficultyMeetsRequirement() + { + var beatmap = createBeatmapWithDrainTime(2 * 60 * 1000, 1.5, "Easy"); // 2 minutes - should be ok for Easy + assertOk(beatmap); + } + + [Test] + public void TestNormalDifficultyMeetsRequirement() + { + var beatmap = createBeatmapWithDrainTime(2 * 60 * 1000, 2.5, "Normal"); // 2 minutes - should be ok for Normal + assertOk(beatmap); + } + + [Test] + public void TestMultipleDifficultiesMeetsRequirement() + { + var difficulties = new List + { + createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 3.5, "Hard"), // Hard - lowest difficulty, 3:30 + createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 4.5, "Insane"), + createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 5.5, "Expert") + }; + + // All should be ok because lowest difficulty is Hard and drain time meets Hard requirement + assertOkWithMultipleDifficulties(difficulties[0], difficulties); + assertOkWithMultipleDifficulties(difficulties[1], difficulties); + assertOkWithMultipleDifficulties(difficulties[2], difficulties); + } + + [Test] + public void TestMultipleDifficultiesTooShort() + { + var difficulties = new List + { + createBeatmapWithDrainTime(4 * 60 * 1000, 4.5, "Insane"), // Insane - lowest difficulty, 4:00 + createBeatmapWithDrainTime(4 * 60 * 1000, 5.5, "Expert") // Same drain time + }; + + // Should be too short because lowest difficulty is Insane and requires 4:15 + assertTooShortWithMultipleDifficulties(difficulties[0], difficulties); + assertTooShortWithMultipleDifficulties(difficulties[1], difficulties); + } + + [Test] + public void TestPlayTimeVsDrainTimeNotHighestDifficulty() + { + var expertBeatmap = createBeatmapWithPlayTime(5 * 60 * 1000, 5.5, "Expert"); // 5:00 play time + expertBeatmap.Breaks.Add(new BreakPeriod(60000, 100000)); // 40-second break + + var difficulties = new List + { + expertBeatmap, // Expert - 5:00 play, 4:20 drain + createBeatmapWithPlayTime(5 * 60 * 1000, 6.5, "ExpertPlus") // ExpertPlus - highest difficulty + }; + + // The Expert difficulty (not highest) should use play time (5:00) and pass the Expert requirement + assertOkWithMultipleDifficulties(difficulties[0], difficulties); + } + + [Test] + public void TestPlayTimeVsDrainTimeHighestDifficulty() + { + var expertBeatmap = createBeatmapWithPlayTime(5 * 60 * 1000, 5.5, "Expert"); // 5:00 play time + expertBeatmap.Breaks.Add(new BreakPeriod(60000, 100000)); // 40-second break + + // As the highest difficulty with breaks > 30s, it should use drain time and fail + assertTooShort(expertBeatmap); + } + + private IBeatmap createBeatmapWithDrainTime(double drainTimeMs, double starRating = 3.5, string difficultyName = "Default") + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + StarRating = starRating, + DifficultyName = difficultyName + }, + HitObjects = new List + { + new HitObject { StartTime = 0 }, + new HitObject { StartTime = drainTimeMs } // Last object at drain time + } + }; + + return beatmap; + } + + private IBeatmap createBeatmapWithPlayTime(double playTimeMs, double starRating = 3.5, string difficultyName = "Default") + { + var beatmap = new Beatmap + { + BeatmapInfo = new BeatmapInfo + { + StarRating = starRating, + DifficultyName = difficultyName + }, + HitObjects = new List + { + new HitObject { StartTime = 0 }, + new HitObject { StartTime = playTimeMs } // Last object at play time + } + }; + + return beatmap; + } + + private void assertOk(IBeatmap beatmap) + { + var difficultyRating = StarDifficulty.GetDifficultyRating(beatmap.BeatmapInfo.StarRating); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating); + + Assert.That(check.Run(context), Is.Empty); + } + + private void assertTooShort(IBeatmap beatmap) + { + var difficultyRating = StarDifficulty.GetDifficultyRating(beatmap.BeatmapInfo.StarRating); + var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.First().Template is CheckLowestDiffDrainTime.IssueTemplateTooShort); + } + + private void assertOkWithMultipleDifficulties(IBeatmap currentBeatmap, IEnumerable allDifficulties) + { + var context = createContextWithMultipleDifficulties(currentBeatmap, allDifficulties); + + Assert.That(check.Run(context), Is.Empty); + } + + private void assertTooShortWithMultipleDifficulties(IBeatmap currentBeatmap, IEnumerable allDifficulties) + { + var context = createContextWithMultipleDifficulties(currentBeatmap, allDifficulties); + var issues = check.Run(context).ToList(); + + Assert.That(issues, Has.Count.EqualTo(1)); + Assert.That(issues.First().Template is CheckLowestDiffDrainTime.IssueTemplateTooShort); + } + + private BeatmapVerifierContext createContextWithMultipleDifficulties(IBeatmap currentBeatmap, IEnumerable allDifficulties) + { + var beatmapSet = new BeatmapSetInfo(); + var beatmapInfos = allDifficulties.Select(d => d.BeatmapInfo).ToList(); + + // Set up the beatmapset with all difficulties + beatmapSet.Beatmaps.AddRange(beatmapInfos); + currentBeatmap.BeatmapInfo.BeatmapSet = beatmapSet; + + // Create a resolver that returns the appropriate working beatmap for each difficulty + var difficultyDict = allDifficulties.ToDictionary(d => d.BeatmapInfo, d => new TestWorkingBeatmap(d)); + + // Use the current beatmap's star rating to determine its difficulty rating + var currentDifficultyRating = StarDifficulty.GetDifficultyRating(currentBeatmap.BeatmapInfo.StarRating); + + return new BeatmapVerifierContext( + currentBeatmap, + new TestWorkingBeatmap(currentBeatmap), + currentDifficultyRating, + beatmapInfo => difficultyDict.TryGetValue(beatmapInfo, out var workingBeatmap) ? workingBeatmap : null + ); + } + + private class TestCheckLowestDiffDrainTime : CheckLowestDiffDrainTime + { + protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds() + { + // Same thresholds as `CheckOsuLowestDiffDrainTime` for testing + yield return (DifficultyRating.Hard, (3 * 60 + 30) * 1000, "Hard"); + yield return (DifficultyRating.Insane, (4 * 60 + 15) * 1000, "Insane"); + yield return (DifficultyRating.Expert, 5 * 60 * 1000, "Expert"); + } + } + } +} From 806995e951544edaa738063bb5e7681b13717a2b Mon Sep 17 00:00:00 2001 From: Hivie Date: Mon, 14 Jul 2025 13:53:01 +0100 Subject: [PATCH 13/51] unlazy BeatmapsetDifficulties --- .../Rulesets/Edit/BeatmapVerifierContext.cs | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs b/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs index 647c43a3f2..9b4448a6f9 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs @@ -31,9 +31,7 @@ namespace osu.Game.Rulesets.Edit /// /// All beatmap difficulties in the same beatmapset, including the current beatmap. /// - public IReadOnlyList BeatmapsetDifficulties => beatmapsetDifficulties.Value; - - private readonly Lazy> beatmapsetDifficulties; + public readonly IReadOnlyList BeatmapsetDifficulties; public BeatmapVerifierContext(IBeatmap beatmap, IWorkingBeatmap workingBeatmap, DifficultyRating difficultyRating = DifficultyRating.ExpertPlus, Func? beatmapResolver = null) { @@ -41,31 +39,32 @@ namespace osu.Game.Rulesets.Edit WorkingBeatmap = workingBeatmap; InterpretedDifficulty = difficultyRating; - beatmapsetDifficulties = new Lazy>(() => + var beatmapSet = beatmap.BeatmapInfo.BeatmapSet; + + if (beatmapSet?.Beatmaps == null) { - var beatmapSet = beatmap.BeatmapInfo.BeatmapSet; - if (beatmapSet?.Beatmaps == null) - return new[] { beatmap }; + BeatmapsetDifficulties = new[] { beatmap }; + return; + } - var difficulties = new List(); + var difficulties = new List(); - foreach (var beatmapInfo in beatmapSet.Beatmaps) + foreach (var beatmapInfo in beatmapSet.Beatmaps) + { + // Use the current beatmap if it matches this BeatmapInfo + if (beatmapInfo.Equals(beatmap.BeatmapInfo)) { - // Use the current beatmap if it matches this BeatmapInfo - if (beatmapInfo.Equals(beatmap.BeatmapInfo)) - { - difficulties.Add(beatmap); - continue; - } - - // Try to resolve other difficulties using the provided resolver - var working = beatmapResolver?.Invoke(beatmapInfo); - if (working?.Beatmap != null) - difficulties.Add(working.Beatmap); + difficulties.Add(beatmap); + continue; } - return difficulties; - }); + // Try to resolve other difficulties using the provided resolver + var working = beatmapResolver?.Invoke(beatmapInfo); + if (working?.Beatmap != null) + difficulties.Add(working.Beatmap); + } + + BeatmapsetDifficulties = difficulties; } } } From 8cb81974eb49724d66bb3f31fe3687fb00dc9c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 11 Jul 2025 14:04:09 +0200 Subject: [PATCH 14/51] Add initial support for filtering by user tags in song select The way that this works is that it plugs into the online request to retrieve the beatmap set that the client is already performing, and stores user tag data to the local realm database. This means that for now user tags will only populate for beatmaps that the user has displayed on song select which is obviously subpar. I plan to follow this change up by adding user tag state dumps to `online.db` and using that data for initial tag population to make the majority case (ranked beatmaps) work. Note that several decisions were made here that are potential discussion points: - `RealmPopulatingOnlineLookupSource` is set up such that it can be the middle man / redirection point for similar flows that we need and we are currently missing, such as storing guest difficulty information, or storing the user's current best score on a beatmap (handy for rank achieved sorting / filtering / etc.) - The user tags are stored in `BeatmapMetadata` which breaks the longstanding assumption that you can arbitrarily pull out a metadata instance from any of the beatmaps in a set and get essentially the same object back. I've attempted to constrain this some by not adding user tags to the `IBeatmapMetadataInfo` interface through which `BeatmapSetInfo` exposes metadata further, but I warn in advance that this is a temporary state of affairs and I will make it worse in the future when `BeatmapMetadata.Author` becomes `Authors` plural in order to support guest mapper display (and direct guest difficulty submission). - The syntax for searching via user tags is chosen to mostly match web - it's `tag=`, with support for all of the string matching modes song select already has (bare word for substring, `""` quotes for phrase isolated by whitespace, `""!` for exact full match). --- .../NonVisual/Filtering/FilterMatchingTest.cs | 32 ++++++++ osu.Game/Beatmaps/BeatmapMetadata.cs | 15 +++- osu.Game/Database/RealmAccess.cs | 3 +- .../Select/Carousel/CarouselBeatmap.cs | 9 +++ osu.Game/Screens/Select/FilterCriteria.cs | 1 + osu.Game/Screens/Select/FilterQueryParser.cs | 3 + .../SelectV2/BeatmapCarouselFilterMatching.cs | 9 +++ .../Screens/SelectV2/BeatmapMetadataWedge.cs | 62 +++++++------- .../RealmPopulatingOnlineLookupSource.cs | 81 +++++++++++++++++++ osu.Game/Screens/SelectV2/SongSelect.cs | 4 + 10 files changed, 185 insertions(+), 34 deletions(-) create mode 100644 osu.Game/Screens/SelectV2/RealmPopulatingOnlineLookupSource.cs diff --git a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs index 1efcc8542d..eeca60a314 100644 --- a/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs +++ b/osu.Game.Tests/NonVisual/Filtering/FilterMatchingTest.cs @@ -40,6 +40,11 @@ namespace osu.Game.Tests.NonVisual.Filtering Author = { Username = "The Author" }, Source = "unit tests", Tags = "look for tags too", + UserTags = + { + "song representation/simple", + "style/clean", + } }, DifficultyName = "version as well", Length = 2500, @@ -292,6 +297,33 @@ namespace osu.Game.Tests.NonVisual.Filtering Assert.AreEqual(filtered, carouselItem.Filtered.Value); } + [TestCase("simple", false)] + [TestCase("\"style/clean\"", false)] + [TestCase("\"style/clean\"!", false)] + [TestCase("iNiS-style", true)] + [TestCase("\"reading/visually dense\"!", true)] + public void TestCriteriaMatchingUserTags(string query, bool filtered) + { + var beatmap = getExampleBeatmap(); + var criteria = new FilterCriteria { UserTag = { SearchTerm = query } }; + var carouselItem = new CarouselBeatmap(beatmap); + carouselItem.Filter(criteria); + + Assert.AreEqual(filtered, carouselItem.Filtered.Value); + } + + [Test] + public void TestBeatmapMustHaveAtLeastOneTagIfUserTagFilterActive() + { + var beatmap = getExampleBeatmap(); + var criteria = new FilterCriteria { UserTag = { SearchTerm = "simple" } }; + var carouselItem = new CarouselBeatmap(beatmap); + carouselItem.BeatmapInfo.Metadata.UserTags.Clear(); + carouselItem.Filter(criteria); + + Assert.True(carouselItem.Filtered.Value); + } + [Test] public void TestCustomRulesetCriteria([Values(null, true, false)] bool? matchCustomCriteria) { diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs index 811dc54e16..1603a9848c 100644 --- a/osu.Game/Beatmaps/BeatmapMetadata.cs +++ b/osu.Game/Beatmaps/BeatmapMetadata.cs @@ -2,9 +2,11 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using JetBrains.Annotations; using Newtonsoft.Json; using osu.Game.Models; +using osu.Game.Screens.SelectV2; using osu.Game.Users; using osu.Game.Utils; using Realms; @@ -15,10 +17,10 @@ namespace osu.Game.Beatmaps /// A realm model containing metadata for a beatmap. /// /// - /// This is currently stored against each beatmap difficulty, even when it is duplicated. + /// An instance of this object is stored against each beatmap difficulty. /// It is also provided via for convenience and historical purposes. - /// A future effort could see this converted to an or potentially de-duped - /// and shared across multiple difficulties in the same set, if required. + /// Note that accessing the metadata via may result in indeterminate results + /// as metadata can meaningfully differ per beatmap in a set. /// /// Note that difficulty name is not stored in this metadata but in . /// @@ -43,6 +45,13 @@ namespace osu.Game.Beatmaps [JsonProperty(@"tags")] public string Tags { get; set; } = string.Empty; + /// + /// The list of user-voted tags applicable to this beatmap. + /// This information is populated from online sources () + /// and can meaningfully differ between beatmaps of a single set. + /// + public IList UserTags { get; } = null!; + /// /// The time in milliseconds to begin playing the track for preview purposes. /// If -1, the track should begin playing at 40% of its length. diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 59cbfcb1e3..3c4850cb4d 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -99,8 +99,9 @@ namespace osu.Game.Database /// 47 2025-01-21 Remove right mouse button binding for absolute scroll. Never use mouse buttons (or scroll) for global actions. /// 48 2025-03-19 Clear online status for all qualified beatmaps (some were stuck in a qualified state due to local caching issues). /// 49 2025-06-10 Reset the LegacyOnlineID to -1 for all scores that have it set to 0 (which is semantically the same) for consistency of handling with OnlineID. + /// 50 2025-07-11 Add UserTags to BeatmapMetadata. /// - private const int schema_version = 49; + private const int schema_version = 50; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. diff --git a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs index 02b5eb5b7a..f7bf1eb778 100644 --- a/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs +++ b/osu.Game/Screens/Select/Carousel/CarouselBeatmap.cs @@ -83,6 +83,15 @@ namespace osu.Game.Screens.Select.Carousel criteria.Title.Matches(BeatmapInfo.Metadata.TitleUnicode); match &= !criteria.DifficultyName.HasFilter || criteria.DifficultyName.Matches(BeatmapInfo.DifficultyName); match &= !criteria.Source.HasFilter || criteria.Source.Matches(BeatmapInfo.Metadata.Source); + + if (criteria.UserTag.HasFilter) + { + bool anyTagMatched = false; + foreach (string tag in BeatmapInfo.Metadata.UserTags) + anyTagMatched |= criteria.UserTag.Matches(tag); + match &= anyTagMatched; + } + match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(BeatmapInfo.StarRating); if (!match) return false; diff --git a/osu.Game/Screens/Select/FilterCriteria.cs b/osu.Game/Screens/Select/FilterCriteria.cs index cc8a92c7c7..05c36a43cf 100644 --- a/osu.Game/Screens/Select/FilterCriteria.cs +++ b/osu.Game/Screens/Select/FilterCriteria.cs @@ -39,6 +39,7 @@ namespace osu.Game.Screens.Select public OptionalTextFilter Title; public OptionalTextFilter DifficultyName; public OptionalTextFilter Source; + public OptionalTextFilter UserTag; public OptionalRange UserStarDifficulty = new OptionalRange { diff --git a/osu.Game/Screens/Select/FilterQueryParser.cs b/osu.Game/Screens/Select/FilterQueryParser.cs index 02a6da146e..36afd8fb72 100644 --- a/osu.Game/Screens/Select/FilterQueryParser.cs +++ b/osu.Game/Screens/Select/FilterQueryParser.cs @@ -116,6 +116,9 @@ namespace osu.Game.Screens.Select case "source": return TryUpdateCriteriaText(ref criteria.Source, op, value); + case "tag": + return TryUpdateCriteriaText(ref criteria.UserTag, op, value); + default: return criteria.RulesetCriteria?.TryParseCustomKeywordCriteria(key, op, value) ?? false; } diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs index f2f246093d..166ca72487 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterMatching.cs @@ -105,6 +105,15 @@ namespace osu.Game.Screens.SelectV2 criteria.Title.Matches(beatmap.Metadata.TitleUnicode); match &= !criteria.DifficultyName.HasFilter || criteria.DifficultyName.Matches(beatmap.DifficultyName); match &= !criteria.Source.HasFilter || criteria.Source.Matches(beatmap.Metadata.Source); + + if (criteria.UserTag.HasFilter) + { + bool anyTagMatched = false; + foreach (string tag in beatmap.Metadata.UserTags) + anyTagMatched |= criteria.UserTag.Matches(tag); + match &= anyTagMatched; + } + match &= !criteria.UserStarDifficulty.HasFilter || criteria.UserStarDifficulty.IsInRange(beatmap.StarRating); if (!match) return false; diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs index 8d1dd105a3..0c8d5d288c 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge.cs @@ -2,18 +2,21 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Logging; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Graphics.Containers; using osu.Game.Localisation; using osu.Game.Online; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Chat; using osu.Game.Resources.Localisation.Web; @@ -51,6 +54,12 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private IAPIProvider api { get; set; } = null!; + [Resolved] + private RealmPopulatingOnlineLookupSource onlineLookupSource { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + private IBindable apiState = null!; [Resolved] @@ -314,34 +323,34 @@ namespace osu.Game.Screens.SelectV2 } private APIBeatmapSet? currentOnlineBeatmapSet; - private GetBeatmapSetRequest? currentRequest; + private CancellationTokenSource? cancellationTokenSource; + private Task? currentFetchTask; private void refetchBeatmapSet() { var beatmapSetInfo = beatmap.Value.BeatmapSetInfo; - currentRequest?.Cancel(); - currentRequest = null; + cancellationTokenSource?.Cancel(); currentOnlineBeatmapSet = null; if (beatmapSetInfo.OnlineID >= 1) { - // todo: consider introducing a BeatmapSetLookupCache for caching benefits. - currentRequest = new GetBeatmapSetRequest(beatmapSetInfo.OnlineID); - currentRequest.Failure += _ => updateOnlineDisplay(); - currentRequest.Success += s => + cancellationTokenSource = new CancellationTokenSource(); + currentFetchTask = onlineLookupSource.GetBeatmapSetAsync(beatmapSetInfo.OnlineID); + currentFetchTask.ContinueWith(t => { - currentOnlineBeatmapSet = s; - updateOnlineDisplay(); - }; - - api.Queue(currentRequest); + if (t.IsCompletedSuccessfully) + currentOnlineBeatmapSet = t.GetResultSafely(); + if (t.Exception != null) + Logger.Log($"Error when fetching online beatmap set: {t.Exception}", LoggingTarget.Network); + Scheduler.AddOnce(updateOnlineDisplay); + }); } } private void updateOnlineDisplay() { - if (currentRequest?.CompletionState == APIRequestCompletionState.Waiting) + if (currentFetchTask?.IsCompleted == false) { genre.Data = null; language.Data = null; @@ -379,28 +388,21 @@ namespace osu.Game.Screens.SelectV2 private void updateUserTags() { - var beatmapInfo = beatmap.Value.BeatmapInfo; - var onlineBeatmapSet = currentOnlineBeatmapSet; - var onlineBeatmap = onlineBeatmapSet?.Beatmaps.SingleOrDefault(b => b.OnlineID == beatmapInfo.OnlineID); + string[] tags = realm.Run(r => + { + // need to refetch because `beatmap.Value.BeatmapInfo` is not going to have the latest tags + var refetchedBeatmap = r.Find(beatmap.Value.BeatmapInfo.ID); + return refetchedBeatmap?.Metadata.UserTags.ToArray() ?? []; + }); - if (onlineBeatmap?.TopTags == null || onlineBeatmap.TopTags.Length == 0 || onlineBeatmapSet?.RelatedTags == null) + if (tags.Length == 0) { userTags.FadeOut(transition_duration, Easing.OutQuint); return; } - var tagsById = onlineBeatmapSet.RelatedTags.ToDictionary(t => t.Id); - string[] userTagsArray = onlineBeatmap.TopTags - .Select(t => (topTag: t, relatedTag: tagsById.GetValueOrDefault(t.TagId))) - .Where(t => t.relatedTag != null) - // see https://github.com/ppy/osu-web/blob/bb3bd2e7c6f84f26066df5ea20a81c77ec9bb60a/resources/js/beatmapsets-show/controller.ts#L103-L106 for sort criteria - .OrderByDescending(t => t.topTag.VoteCount) - .ThenBy(t => t.relatedTag!.Name) - .Select(t => t.relatedTag!.Name) - .ToArray(); - userTags.FadeIn(transition_duration, Easing.OutQuint); - userTags.Tags = (userTagsArray, t => songSelect?.Search(t)); + userTags.Tags = (tags, t => songSelect?.Search($@"tag=""{t}""!")); } } } diff --git a/osu.Game/Screens/SelectV2/RealmPopulatingOnlineLookupSource.cs b/osu.Game/Screens/SelectV2/RealmPopulatingOnlineLookupSource.cs new file mode 100644 index 0000000000..c2ede24a5d --- /dev/null +++ b/osu.Game/Screens/SelectV2/RealmPopulatingOnlineLookupSource.cs @@ -0,0 +1,81 @@ +// Copyright (c) ppy Pty Ltd . 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 System.Threading; +using System.Threading.Tasks; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using Realms; + +namespace osu.Game.Screens.SelectV2 +{ + /// + /// This component is designed to perform lookups of online data + /// and store portions of it for later local use to the realm database. + /// + /// + /// This component is designed to locally persist potentially-volatile online information such as: + /// + /// user tags assigned to difficulties of a beatmap, + /// guest mappers assigned to difficulties of a beatmap, + /// the local user's best score on a given beatmap. + /// + /// + public partial class RealmPopulatingOnlineLookupSource : Component + { + [Resolved] + private IAPIProvider api { get; set; } = null!; + + [Resolved] + private RealmAccess realm { get; set; } = null!; + + public Task GetBeatmapSetAsync(int id, CancellationToken token = default) + { + var request = new GetBeatmapSetRequest(id); + var tcs = new TaskCompletionSource(); + + request.Success += onlineBeatmapSet => + { + if (token.IsCancellationRequested) + { + tcs.SetCanceled(token); + return; + } + + var tagsById = (onlineBeatmapSet.RelatedTags ?? []).ToDictionary(t => t.Id); + var onlineBeatmaps = onlineBeatmapSet.Beatmaps.ToDictionary(b => b.OnlineID); + realm.Write(r => + { + foreach (var dbBeatmap in r.All().Filter($@"{nameof(BeatmapInfo.BeatmapSet)}.{nameof(BeatmapSetInfo.OnlineID)} == $0", id)) + { + if (onlineBeatmaps.TryGetValue(dbBeatmap.OnlineID, out var onlineBeatmap)) + { + string[] userTagsArray = onlineBeatmap.TopTags? + .Select(t => (topTag: t, relatedTag: tagsById.GetValueOrDefault(t.TagId))) + .Where(t => t.relatedTag != null) + // see https://github.com/ppy/osu-web/blob/bb3bd2e7c6f84f26066df5ea20a81c77ec9bb60a/resources/js/beatmapsets-show/controller.ts#L103-L106 for sort criteria + .OrderByDescending(t => t.topTag.VoteCount) + .ThenBy(t => t.relatedTag!.Name) + .Select(t => t.relatedTag!.Name) + .ToArray() ?? []; + dbBeatmap.Metadata.UserTags.Clear(); + dbBeatmap.Metadata.UserTags.AddRange(userTagsArray); + } + } + }); + tcs.SetResult(onlineBeatmapSet); + }; + request.Failure += tcs.SetException; + api.Queue(request); + return tcs.Task; + } + } +} diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index 1e16fa335a..84293f62ca 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -133,6 +133,9 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private IDialogOverlay? dialogOverlay { get; set; } + [Cached] + private RealmPopulatingOnlineLookupSource onlineLookupSource = new RealmPopulatingOnlineLookupSource(); + private Bindable configBackgroundBlur = null!; [BackgroundDependencyLoader] @@ -143,6 +146,7 @@ namespace osu.Game.Screens.SelectV2 AddRangeInternal(new Drawable[] { new GlobalScrollAdjustsVolume(), + onlineLookupSource, mainContent = new Container { Anchor = Anchor.Centre, From 6d8d5bdd006f7380478a3d8ac277a1523c76e92a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 15 Jul 2025 10:38:44 +0200 Subject: [PATCH 15/51] Fix `TagsLine` arbitrarily changing how it performs search in the popover So much passing of the `linkAction` to only then give up halfway through and reimplement it locally again down in the overflow popover. This materially matters now because mapper tags are searched as plain words and user tags are searched using the `tag=""!` syntax. --- .../BeatmapMetadataWedge_MetadataDisplay.cs | 8 ++++---- .../SelectV2/BeatmapMetadataWedge_TagsLine.cs | 20 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_MetadataDisplay.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_MetadataDisplay.cs index a98c806634..606b5e6a8c 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_MetadataDisplay.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_MetadataDisplay.cs @@ -56,12 +56,12 @@ namespace osu.Game.Screens.SelectV2 } } - public (string[] tags, Action linkAction)? Tags + public (string[] tags, Action searchAction)? Tags { set { if (value != null) - setTags(value.Value.tags, value.Value.linkAction); + setTags(value.Value.tags, value.Value.searchAction); else setLoading(); } @@ -161,12 +161,12 @@ namespace osu.Game.Screens.SelectV2 contentDate.Date = date; } - private void setTags(string[] tags, Action link) + private void setTags(string[] tags, Action searchAction) { clear(); contentTags.Tags = tags; - contentTags.Action = link; + contentTags.PerformSearch = searchAction; } private void setLoading() diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs index 683cd428e9..b5a1556d29 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs @@ -44,7 +44,7 @@ namespace osu.Game.Screens.SelectV2 } } - public Action? Action; + public Action? PerformSearch { get; set; } [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; @@ -103,7 +103,7 @@ namespace osu.Game.Screens.SelectV2 ChildrenEnumerable = tags.Select(t => new OsuHoverContainer { AutoSizeAxes = Axes.Both, - Action = () => Action?.Invoke(t), + Action = () => PerformSearch?.Invoke(t), IdleColour = colourProvider.Light2, AlwaysPresent = true, Alpha = 0f, @@ -117,6 +117,7 @@ namespace osu.Game.Screens.SelectV2 Add(overflowButton = new TagsOverflowButton(tags) { Alpha = 0f, + PerformSearch = PerformSearch, }); drawSizeLayout.Invalidate(); @@ -132,11 +133,10 @@ namespace osu.Game.Screens.SelectV2 [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; - [Resolved] - private ISongSelect? songSelect { get; set; } - public float LineBaseHeight => text.LineBaseHeight; + public Action? PerformSearch { get; set; } + public TagsOverflowButton(string[] tags) { this.tags = tags; @@ -188,18 +188,18 @@ namespace osu.Game.Screens.SelectV2 return true; } - public Popover GetPopover() => new TagsOverflowPopover(tags, songSelect); + public Popover GetPopover() => new TagsOverflowPopover(tags, PerformSearch); } public partial class TagsOverflowPopover : OsuPopover { private readonly string[] tags; - private readonly ISongSelect? songSelect; + private readonly Action? performSearch; - public TagsOverflowPopover(string[] tags, ISongSelect? songSelect) + public TagsOverflowPopover(string[] tags, Action? performSearchAction) { this.tags = tags; - this.songSelect = songSelect; + this.performSearch = performSearchAction; } [BackgroundDependencyLoader] @@ -215,7 +215,7 @@ namespace osu.Game.Screens.SelectV2 foreach (string tag in tags) { - textFlow.AddLink(tag, () => songSelect?.Search(tag)); + textFlow.AddLink(tag, () => performSearch?.Invoke(tag)); textFlow.AddText(" "); } } From 6ad9714318b92e9b6c39ef7854c762139b8b5c11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 15 Jul 2025 11:35:25 +0200 Subject: [PATCH 16/51] Fix code quality --- osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs index b5a1556d29..aee7731f55 100644 --- a/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs +++ b/osu.Game/Screens/SelectV2/BeatmapMetadataWedge_TagsLine.cs @@ -199,7 +199,7 @@ namespace osu.Game.Screens.SelectV2 public TagsOverflowPopover(string[] tags, Action? performSearchAction) { this.tags = tags; - this.performSearch = performSearchAction; + performSearch = performSearchAction; } [BackgroundDependencyLoader] From 02d54e5a385aaac2894c534fb9f78f82df13ebd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 15 Jul 2025 14:30:29 +0200 Subject: [PATCH 17/51] Fix tests --- .../SongSelectV2/TestSceneBeatmapMetadataWedge.cs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs index f18250402e..ca52e476e2 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapMetadataWedge.cs @@ -4,6 +4,7 @@ using System; using System.Linq; using NUnit.Framework; +using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Game.Beatmaps; @@ -25,9 +26,19 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { base.LoadComplete(); - Child = wedge = new BeatmapMetadataWedge + var lookupSource = new RealmPopulatingOnlineLookupSource(); + Child = new DependencyProvidingContainer { - State = { Value = Visibility.Visible }, + RelativeSizeAxes = Axes.Both, + CachedDependencies = [(typeof(RealmPopulatingOnlineLookupSource), lookupSource)], + Children = + [ + lookupSource, + wedge = new BeatmapMetadataWedge + { + State = { Value = Visibility.Visible }, + } + ] }; } From 747bff1df9e671eff2a773dfd35c80eac5d0f8e5 Mon Sep 17 00:00:00 2001 From: Hivie Date: Wed, 16 Jul 2025 01:53:22 +0100 Subject: [PATCH 18/51] address review - add TODO for refactoring verifier context ctor - call `GetPlayableBeatmap()` in verifier context ctor - filter diffs with relevant ruleset in check logic - fix tests --- .../Editing/Checks/CheckLowestDiffDrainTimeTest.cs | 9 ++++++--- osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs | 9 +++++---- .../Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs | 4 +++- osu.Game/Screens/Edit/Verify/IssueList.cs | 11 ++++++++++- 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs b/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs index 96f942fd8e..20213b13a4 100644 --- a/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs @@ -10,6 +10,7 @@ using osu.Game.Extensions; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Edit.Checks; using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Osu; using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Editing.Checks @@ -158,7 +159,8 @@ namespace osu.Game.Tests.Editing.Checks BeatmapInfo = new BeatmapInfo { StarRating = starRating, - DifficultyName = difficultyName + DifficultyName = difficultyName, + Ruleset = new OsuRuleset().RulesetInfo }, HitObjects = new List { @@ -177,7 +179,8 @@ namespace osu.Game.Tests.Editing.Checks BeatmapInfo = new BeatmapInfo { StarRating = starRating, - DifficultyName = difficultyName + DifficultyName = difficultyName, + Ruleset = new OsuRuleset().RulesetInfo }, HitObjects = new List { @@ -242,7 +245,7 @@ namespace osu.Game.Tests.Editing.Checks currentBeatmap, new TestWorkingBeatmap(currentBeatmap), currentDifficultyRating, - beatmapInfo => difficultyDict.TryGetValue(beatmapInfo, out var workingBeatmap) ? workingBeatmap : null + beatmapInfo => difficultyDict.TryGetValue(beatmapInfo, out var workingBeatmap) ? workingBeatmap.Beatmap : null ); } diff --git a/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs b/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs index 9b4448a6f9..9761212b55 100644 --- a/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs +++ b/osu.Game/Rulesets/Edit/BeatmapVerifierContext.cs @@ -33,7 +33,8 @@ namespace osu.Game.Rulesets.Edit /// public readonly IReadOnlyList BeatmapsetDifficulties; - public BeatmapVerifierContext(IBeatmap beatmap, IWorkingBeatmap workingBeatmap, DifficultyRating difficultyRating = DifficultyRating.ExpertPlus, Func? beatmapResolver = null) + // TODO: Refactor this to have a simple constructor that only stores data and move the beatmap resolution logic to a static factory method. + public BeatmapVerifierContext(IBeatmap beatmap, IWorkingBeatmap workingBeatmap, DifficultyRating difficultyRating = DifficultyRating.ExpertPlus, Func? beatmapResolver = null) { Beatmap = beatmap; WorkingBeatmap = workingBeatmap; @@ -59,9 +60,9 @@ namespace osu.Game.Rulesets.Edit } // Try to resolve other difficulties using the provided resolver - var working = beatmapResolver?.Invoke(beatmapInfo); - if (working?.Beatmap != null) - difficulties.Add(working.Beatmap); + var resolvedBeatmap = beatmapResolver?.Invoke(beatmapInfo); + if (resolvedBeatmap != null) + difficulties.Add(resolvedBeatmap); } BeatmapsetDifficulties = difficulties; diff --git a/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs b/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs index 47db1fc54b..58346f7e3e 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs @@ -27,7 +27,9 @@ namespace osu.Game.Rulesets.Edit.Checks public IEnumerable Run(BeatmapVerifierContext context) { - IReadOnlyList difficulties = context.BeatmapsetDifficulties; + IReadOnlyList difficulties = context.BeatmapsetDifficulties + .Where(d => d.BeatmapInfo.Ruleset.Equals(context.Beatmap.BeatmapInfo.Ruleset)) + .ToList(); if (difficulties.Count == 0) yield break; diff --git a/osu.Game/Screens/Edit/Verify/IssueList.cs b/osu.Game/Screens/Edit/Verify/IssueList.cs index 62056e2ae1..6ef193fd79 100644 --- a/osu.Game/Screens/Edit/Verify/IssueList.cs +++ b/osu.Game/Screens/Edit/Verify/IssueList.cs @@ -46,7 +46,16 @@ namespace osu.Game.Screens.Edit.Verify generalVerifier = new BeatmapVerifier(); rulesetVerifier = beatmap.BeatmapInfo.Ruleset.CreateInstance().CreateBeatmapVerifier(); - context = new BeatmapVerifierContext(beatmap, workingBeatmap.Value, verify.InterpretedDifficulty.Value, beatmapInfo => beatmapManager.GetWorkingBeatmap(beatmapInfo)); + context = new BeatmapVerifierContext( + beatmap, + workingBeatmap.Value, + verify.InterpretedDifficulty.Value, + beatmapInfo => + beatmapManager + .GetWorkingBeatmap(beatmapInfo) + ?.GetPlayableBeatmap(beatmapInfo.Ruleset) + ); + verify.InterpretedDifficulty.BindValueChanged(difficulty => context.InterpretedDifficulty = difficulty.NewValue); RelativeSizeAxes = Axes.Both; From 83ad34b718442a409d36e73042dabe1cba9343c9 Mon Sep 17 00:00:00 2001 From: Hivie Date: Wed, 16 Jul 2025 02:08:07 +0100 Subject: [PATCH 19/51] fix ci --- osu.Game/Screens/Edit/Verify/IssueList.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/Edit/Verify/IssueList.cs b/osu.Game/Screens/Edit/Verify/IssueList.cs index 6ef193fd79..2c7d3932ad 100644 --- a/osu.Game/Screens/Edit/Verify/IssueList.cs +++ b/osu.Game/Screens/Edit/Verify/IssueList.cs @@ -53,7 +53,7 @@ namespace osu.Game.Screens.Edit.Verify beatmapInfo => beatmapManager .GetWorkingBeatmap(beatmapInfo) - ?.GetPlayableBeatmap(beatmapInfo.Ruleset) + .GetPlayableBeatmap(beatmapInfo.Ruleset) ); verify.InterpretedDifficulty.BindValueChanged(difficulty => context.InterpretedDifficulty = difficulty.NewValue); From 961b8103a8ffcd42af87a8a46bd6d4458b645393 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 18 Jul 2025 10:05:41 +0200 Subject: [PATCH 20/51] Initial pass on favourite button appearance --- .../Screens/SelectV2/BeatmapTitleWedge.cs | 10 +- .../BeatmapTitleWedge_FavouriteButton.cs | 192 ++++++++++++++++++ 2 files changed, 197 insertions(+), 5 deletions(-) create mode 100644 osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs index 6b80fc69c9..c132fd252c 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs @@ -59,7 +59,7 @@ namespace osu.Game.Screens.SelectV2 internal string DisplayedArtist => artistLabel.Text.ToString(); private StatisticPlayCount playCount = null!; - private Statistic favouritesStatistic = null!; + private FavouriteButton favouriteButton = null!; private Statistic lengthStatistic = null!; private Statistic bpmStatistic = null!; @@ -157,7 +157,7 @@ namespace osu.Game.Screens.SelectV2 { Margin = new MarginPadding { Left = -SongSelect.WEDGE_CONTENT_MARGIN }, }, - favouritesStatistic = new Statistic(OsuIcon.Heart, background: true, minSize: 25f) + favouriteButton = new FavouriteButton { TooltipText = BeatmapsStrings.StatusFavourites, }, @@ -316,12 +316,12 @@ namespace osu.Game.Screens.SelectV2 if (currentRequest?.CompletionState == APIRequestCompletionState.Waiting) { playCount.Value = null; - favouritesStatistic.Text = null; + favouriteButton.Text = null; } else if (currentOnlineBeatmapSet == null) { playCount.Value = new StatisticPlayCount.Data(-1, -1); - favouritesStatistic.Text = "-"; + favouriteButton.Text = "-"; } else { @@ -329,7 +329,7 @@ namespace osu.Game.Screens.SelectV2 var onlineBeatmap = currentOnlineBeatmapSet.Beatmaps.SingleOrDefault(b => b.OnlineID == working.Value.BeatmapInfo.OnlineID); playCount.Value = new StatisticPlayCount.Data(onlineBeatmap?.PlayCount ?? -1, onlineBeatmap?.UserPlayCount ?? -1); - favouritesStatistic.Text = onlineBeatmapSet.FavouriteCount.ToLocalisableString(@"N0"); + favouriteButton.Text = onlineBeatmapSet.FavouriteCount.ToLocalisableString(@"N0"); } } } diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs new file mode 100644 index 0000000000..a78a73e0ce --- /dev/null +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs @@ -0,0 +1,192 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Input.Events; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; +using osuTK; +using osuTK.Graphics; + +namespace osu.Game.Screens.SelectV2 +{ + public partial class BeatmapTitleWedge + { + public partial class FavouriteButton : OsuClickableContainer + { + private readonly BindableBool isFavourite = new BindableBool(); + + private Box background = null!; + private OsuSpriteText valueText = null!; + private LoadingSpinner loading = null!; + private Box hoverLayer = null!; + private Box flashLayer = null!; + private SpriteIcon icon = null!; + + private LocalisableString? text; + + public LocalisableString? Text + { + get => text; + set + { + text = value; + Scheduler.AddOnce(updateDisplay); + } + } + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public FavouriteButton() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + Masking = true; + CornerRadius = 5; + Shear = OsuGame.SHEAR; + + AddRange(new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both, + Colour = Color4.Black.Opacity(0.2f), + }, + new FillFlowContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Margin = new MarginPadding { Left = 10, Right = 10, Vertical = 5f }, + Spacing = new Vector2(4f, 0f), + Shear = -OsuGame.SHEAR, + Children = new Drawable[] + { + icon = new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = OsuIcon.Heart, + Size = new Vector2(OsuFont.Style.Heading2.Size), + Colour = colourProvider.Content2, + }, + new Container + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.X, + Height = 20, + Children = new Drawable[] + { + loading = new LoadingSpinner + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(12f), + State = { Value = Visibility.Visible }, + }, + new GridContainer + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + RowDimensions = new[] { new Dimension(GridSizeMode.AutoSize) }, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize, minSize: 25), + }, + Content = new[] + { + new[] + { + valueText = new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Font = OsuFont.Style.Heading2, + Colour = colourProvider.Content2, + Margin = new MarginPadding { Bottom = 2f }, + AlwaysPresent = true, + }, + } + } + }, + }, + }, + }, + }, + hoverLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = Colour4.White.Opacity(0.1f), + Blending = BlendingParameters.Additive, + }, + flashLayer = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + Colour = Colour4.White, + } + }); + Action = isFavourite.Toggle; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Scheduler.AddOnce(updateDisplay); + isFavourite.BindValueChanged(_ => + { + if (isFavourite.Value) + flashLayer.FadeOutFromOne(500, Easing.Out); + Scheduler.AddOnce(updateDisplay); + }); + } + + protected override bool OnHover(HoverEvent e) + { + hoverLayer.FadeIn(500, Easing.OutQuint); + return true; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + hoverLayer.FadeOut(500, Easing.OutQuint); + } + + private void updateDisplay() + { + loading.State.Value = text != null ? Visibility.Hidden : Visibility.Visible; + + if (text != null) + { + valueText.Text = text.Value; + valueText.FadeIn(120, Easing.OutQuint); + } + else + valueText.FadeOut(120, Easing.OutQuint); + + background.FadeColour(isFavourite.Value ? colours.Pink1 : Colour4.Black.Opacity(0.2f), 500, Easing.OutQuint); + icon.Icon = isFavourite.Value ? FontAwesome.Solid.Heart : FontAwesome.Regular.Heart; + } + } + } +} From a2fef272a774b758ae761ee9068f59c66c6f2c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 18 Jul 2025 11:28:56 +0200 Subject: [PATCH 21/51] Implement favouriting operation when clicking on button --- .../Screens/SelectV2/BeatmapTitleWedge.cs | 14 +-- .../BeatmapTitleWedge_FavouriteButton.cs | 115 ++++++++++++------ 2 files changed, 82 insertions(+), 47 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs index c132fd252c..28031f12fc 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge.cs @@ -8,7 +8,6 @@ using System.Threading; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Localisation; @@ -316,20 +315,13 @@ namespace osu.Game.Screens.SelectV2 if (currentRequest?.CompletionState == APIRequestCompletionState.Waiting) { playCount.Value = null; - favouriteButton.Text = null; - } - else if (currentOnlineBeatmapSet == null) - { - playCount.Value = new StatisticPlayCount.Data(-1, -1); - favouriteButton.Text = "-"; + favouriteButton.SetLoading(); } else { - var onlineBeatmapSet = currentOnlineBeatmapSet; - var onlineBeatmap = currentOnlineBeatmapSet.Beatmaps.SingleOrDefault(b => b.OnlineID == working.Value.BeatmapInfo.OnlineID); - + var onlineBeatmap = currentOnlineBeatmapSet?.Beatmaps.SingleOrDefault(b => b.OnlineID == working.Value.BeatmapInfo.OnlineID); playCount.Value = new StatisticPlayCount.Data(onlineBeatmap?.PlayCount ?? -1, onlineBeatmap?.UserPlayCount ?? -1); - favouriteButton.Text = onlineBeatmapSet.FavouriteCount.ToLocalisableString(@"N0"); + favouriteButton.SetBeatmapSet(currentOnlineBeatmapSet); } } } diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs index a78a73e0ce..359985bae8 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs @@ -1,9 +1,11 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -14,6 +16,9 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osuTK; using osuTK.Graphics; @@ -28,26 +33,22 @@ namespace osu.Game.Screens.SelectV2 private Box background = null!; private OsuSpriteText valueText = null!; - private LoadingSpinner loading = null!; + private LoadingSpinner loadingSpinner = null!; private Box hoverLayer = null!; private Box flashLayer = null!; private SpriteIcon icon = null!; - private LocalisableString? text; - - public LocalisableString? Text - { - get => text; - set - { - text = value; - Scheduler.AddOnce(updateDisplay); - } - } + private APIBeatmapSet? onlineBeatmapSet; + private PostBeatmapFavouriteRequest? favouriteRequest; [Resolved] private OsuColour colours { get; set; } = null!; + [Resolved] + private IAPIProvider api { get; set; } = null!; + + internal LocalisableString Text => valueText.Text; + public FavouriteButton() { AutoSizeAxes = Axes.Both; @@ -94,7 +95,7 @@ namespace osu.Game.Screens.SelectV2 Height = 20, Children = new Drawable[] { - loading = new LoadingSpinner + loadingSpinner = new LoadingSpinner { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -145,19 +146,7 @@ namespace osu.Game.Screens.SelectV2 Colour = Colour4.White, } }); - Action = isFavourite.Toggle; - } - - protected override void LoadComplete() - { - base.LoadComplete(); - Scheduler.AddOnce(updateDisplay); - isFavourite.BindValueChanged(_ => - { - if (isFavourite.Value) - flashLayer.FadeOutFromOne(500, Easing.Out); - Scheduler.AddOnce(updateDisplay); - }); + Action = toggleFavourite; } protected override bool OnHover(HoverEvent e) @@ -172,21 +161,75 @@ namespace osu.Game.Screens.SelectV2 hoverLayer.FadeOut(500, Easing.OutQuint); } - private void updateDisplay() - { - loading.State.Value = text != null ? Visibility.Hidden : Visibility.Visible; + // Note: `setLoading()` and `setBeatmapSet()` are called externally via their public counterparts by song select when the beatmap changes, + // as well as internally in order to display the progress and result of the (un)favourite operation when the button is clicked. + // In case of external calls, we want to cancel pending favourite requests, primarily to avoid a situation when a late success callback from an (un)favourite + // could show the favourite count from a prior beatmap. - if (text != null) - { - valueText.Text = text.Value; - valueText.FadeIn(120, Easing.OutQuint); - } - else - valueText.FadeOut(120, Easing.OutQuint); + public void SetLoading() + { + if (favouriteRequest?.CompletionState == APIRequestCompletionState.Waiting) + favouriteRequest.Cancel(); + setLoading(); + } + + private void setLoading() + { + loadingSpinner.State.Value = Visibility.Visible; + valueText.FadeOut(120, Easing.OutQuint); + + onlineBeatmapSet = null; + updateFavouriteState(); + } + + public void SetBeatmapSet(APIBeatmapSet? beatmapSet) + { + if (favouriteRequest?.CompletionState == APIRequestCompletionState.Waiting) + favouriteRequest.Cancel(); + setBeatmapSet(beatmapSet); + } + + private void setBeatmapSet(APIBeatmapSet? beatmapSet) + { + loadingSpinner.State.Value = Visibility.Hidden; + valueText.FadeIn(120, Easing.OutQuint); + + onlineBeatmapSet = beatmapSet; + updateFavouriteState(); + } + + private void updateFavouriteState() + { + Enabled.Value = onlineBeatmapSet != null; + valueText.Text = onlineBeatmapSet?.FavouriteCount.ToLocalisableString(@"N0") ?? "-"; + isFavourite.Value = onlineBeatmapSet?.HasFavourited == true; background.FadeColour(isFavourite.Value ? colours.Pink1 : Colour4.Black.Opacity(0.2f), 500, Easing.OutQuint); icon.Icon = isFavourite.Value ? FontAwesome.Solid.Heart : FontAwesome.Regular.Heart; } + + private void toggleFavourite() + { + Debug.Assert(onlineBeatmapSet != null); + + // having this copy locally is important to capture this particular beatmap set instance rather than the field in the request success callback, + // because if it was captured via the field / `this`, it could change value due to an external `setLoading()` or `setBeatmapSet()` call. + // there's also the part where we want to call `setLoading()` here to show the spinner, but that also sets `onlineBeatmapSet` to null. + var beatmapSet = onlineBeatmapSet; + + favouriteRequest = new PostBeatmapFavouriteRequest(beatmapSet.OnlineID, isFavourite.Value ? BeatmapFavouriteAction.UnFavourite : BeatmapFavouriteAction.Favourite); + favouriteRequest.Success += () => + { + bool hasFavourited = favouriteRequest.Action == BeatmapFavouriteAction.Favourite; + beatmapSet.HasFavourited = hasFavourited; + beatmapSet.FavouriteCount += hasFavourited ? 1 : -1; + setBeatmapSet(beatmapSet); + if (hasFavourited) + flashLayer.FadeOutFromOne(500, Easing.OutQuint); + }; + api.Queue(favouriteRequest); + setLoading(); + } } } } From a686157b478c28f537b5e1cbce7d80d131a40d85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 18 Jul 2025 12:04:12 +0200 Subject: [PATCH 22/51] Add test coverage for favouriting from song select --- .../TestSceneBeatmapTitleWedge.cs | 115 ++++++++++++++---- 1 file changed, 94 insertions(+), 21 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs index 85d82e536d..2ff677becd 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs @@ -5,6 +5,8 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; +using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -50,24 +52,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 { base.LoadComplete(); - ((DummyAPIAccess)API).HandleRequest = request => - { - switch (request) - { - case GetBeatmapSetRequest set: - if (set.ID == currentOnlineSet?.OnlineID) - { - set.TriggerSuccess(currentOnlineSet); - return true; - } - - return false; - - default: - return false; - } - }; - AddRange(new Drawable[] { new Container @@ -151,6 +135,27 @@ namespace osu.Game.Tests.Visual.SongSelectV2 [Test] public void TestOnlineAvailability() { + AddStep("set up request handler", () => + { + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case GetBeatmapSetRequest set: + if (set.ID == currentOnlineSet?.OnlineID) + { + set.TriggerSuccess(currentOnlineSet); + return true; + } + + return false; + + default: + return false; + } + }; + }); + AddStep("online beatmapset", () => { var (working, onlineSet) = createTestBeatmap(); @@ -159,7 +164,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 Beatmap.Value = working; }); AddAssert("play count = 10000", () => this.ChildrenOfType().ElementAt(0).Text.ToString() == "10,000"); - AddAssert("favourites count = 2345", () => this.ChildrenOfType().ElementAt(1).Text.ToString() == "2,345"); + AddAssert("favourites count = 2345", () => this.ChildrenOfType().Single().Text.ToString() == "2,345"); AddStep("online beatmapset with local diff", () => { var (working, onlineSet) = createTestBeatmap(); @@ -170,7 +175,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 Beatmap.Value = working; }); AddAssert("play count = -", () => this.ChildrenOfType().ElementAt(0).Text.ToString() == "-"); - AddAssert("favourites count = 2345", () => this.ChildrenOfType().ElementAt(1).Text.ToString() == "2,345"); + AddAssert("favourites count = 2345", () => this.ChildrenOfType().Single().Text.ToString() == "2,345"); AddStep("local beatmapset", () => { var (working, _) = createTestBeatmap(); @@ -179,7 +184,75 @@ namespace osu.Game.Tests.Visual.SongSelectV2 Beatmap.Value = working; }); AddAssert("play count = -", () => this.ChildrenOfType().ElementAt(0).Text.ToString() == "-"); - AddAssert("favourites count = -", () => this.ChildrenOfType().ElementAt(1).Text.ToString() == "-"); + AddAssert("favourites count = -", () => this.ChildrenOfType().Single().Text.ToString() == "-"); + } + + [Test] + public void TestFavouriting() + { + var resetEvent = new ManualResetEventSlim(false); + + AddStep("set up request handler", () => + { + ((DummyAPIAccess)API).HandleRequest = request => + { + switch (request) + { + case GetBeatmapSetRequest set: + if (set.ID == currentOnlineSet?.OnlineID) + { + set.TriggerSuccess(currentOnlineSet); + return true; + } + + return false; + + case PostBeatmapFavouriteRequest favourite: + Task.Run(() => + { + resetEvent.Wait(10000); + favourite.TriggerSuccess(); + }); + return true; + + default: + return false; + } + }; + }); + + AddStep("online beatmapset", () => + { + var (working, onlineSet) = createTestBeatmap(); + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddUntilStep("play count = 10000", () => this.ChildrenOfType().ElementAt(0).Text.ToString() == "10,000"); + AddUntilStep("favourites count = 2345", () => this.ChildrenOfType().Single().Text.ToString() == "2,345"); + + AddStep("click favourite button", () => this.ChildrenOfType().Single().TriggerClick()); + AddStep("allow request to complete", () => resetEvent.Set()); + AddAssert("favourites count = 2346", () => this.ChildrenOfType().Single().Text.ToString() == "2,346"); + + AddStep("reset event", () => resetEvent.Reset()); + AddStep("click favourite button", () => this.ChildrenOfType().Single().TriggerClick()); + AddStep("allow request to complete", () => resetEvent.Set()); + AddAssert("favourites count = 2345", () => this.ChildrenOfType().Single().Text.ToString() == "2,345"); + + AddStep("reset event", () => resetEvent.Reset()); + AddStep("click favourite button", () => this.ChildrenOfType().Single().TriggerClick()); + AddStep("change to another beatmap", () => + { + var (working, onlineSet) = createTestBeatmap(); + onlineSet.FavouriteCount = 9999; + working.BeatmapSetInfo.OnlineID = onlineSet.OnlineID = 99999; + + currentOnlineSet = onlineSet; + Beatmap.Value = working; + }); + AddStep("allow request to complete", () => resetEvent.Set()); + AddAssert("favourites count = 9999", () => this.ChildrenOfType().Single().Text.ToString() == "9,999"); } [TestCase(120, 125, null, "120-125 (mostly 120)")] From dd09a2487e0d416f2ab36383421dfc376b522d1c Mon Sep 17 00:00:00 2001 From: Chris Ehmann Date: Fri, 18 Jul 2025 20:46:38 -0700 Subject: [PATCH 23/51] Fix editor background not updating in certain scenarios --- .../Screens/Backgrounds/EditorBackgroundScreen.cs | 12 ++++++------ osu.Game/Screens/Edit/Editor.cs | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs b/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs index 9982357157..7aa071ec38 100644 --- a/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs +++ b/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs @@ -19,7 +19,7 @@ namespace osu.Game.Screens.Backgrounds { public partial class EditorBackgroundScreen : BackgroundScreen { - private readonly WorkingBeatmap beatmap; + private readonly IBindable beatmap; private readonly Container dimContainer; private CancellationTokenSource? cancellationTokenSource; @@ -31,7 +31,7 @@ namespace osu.Game.Screens.Backgrounds private IFrameBasedClock? clockSource; - public EditorBackgroundScreen(WorkingBeatmap beatmap) + public EditorBackgroundScreen(IBindable beatmap) { this.beatmap = beatmap; @@ -54,14 +54,14 @@ namespace osu.Game.Screens.Backgrounds private IEnumerable createContent() => [ - new BeatmapBackground(beatmap) { RelativeSizeAxes = Axes.Both, }, + new BeatmapBackground(beatmap.Value) { RelativeSizeAxes = Axes.Both, }, // this kooky container nesting is here because the storyboard needs a custom clock // but also needs it on an isolated-enough level that doesn't break screen stack expiry logic (which happens if the clock was put on `this`), // or doesn't make it literally impossible to fade the storyboard in/out in real time (which happens if the fade transforms were to be applied directly to the storyboard). new Container { RelativeSizeAxes = Axes.Both, - Child = new DrawableStoryboard(beatmap.Storyboard) + Child = new DrawableStoryboard(beatmap.Value.Storyboard) { Clock = clockSource ?? Clock, } @@ -82,7 +82,7 @@ namespace osu.Game.Screens.Backgrounds storyboardContainer.FadeTo(showStoryboard.Value ? 1 : 0, duration, Easing.OutQuint); // yes, this causes overdraw, but is also a (crude) fix for bad-looking transitions on screen entry // caused by the previous background on the background stack poking out from under this one and then instantly fading out - background.FadeColour(beatmap.Storyboard.ReplacesBackground && showStoryboard.Value ? Colour4.Black : Colour4.White, duration, Easing.OutQuint); + background.FadeColour(beatmap.Value.Storyboard.ReplacesBackground && showStoryboard.Value ? Colour4.Black : Colour4.White, duration, Easing.OutQuint); } public void ChangeClockSource(IFrameBasedClock frameBasedClock) @@ -103,7 +103,7 @@ namespace osu.Game.Screens.Backgrounds background = dimContainer.OfType().Single(); storyboardContainer = dimContainer.OfType().Single(); updateState(0); - }, (cancellationTokenSource ??= new CancellationTokenSource()).Token); + }, (cancellationTokenSource = new CancellationTokenSource()).Token); } public override bool Equals(BackgroundScreen? other) diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 365a59b033..88a1f74991 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -480,7 +480,7 @@ namespace osu.Game.Screens.Edit [Resolved] private MusicController musicController { get; set; } - protected override BackgroundScreen CreateBackground() => new EditorBackgroundScreen(Beatmap.Value); + protected override BackgroundScreen CreateBackground() => new EditorBackgroundScreen(Beatmap); protected override void LoadComplete() { From cd811332d6c9a53dd2be3ad9bfffe929ff683d69 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sat, 19 Jul 2025 23:42:57 +0900 Subject: [PATCH 24/51] Only update text width when finished loading to avoid extraneous resizing --- .../Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs index 359985bae8..81f4561e7e 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs @@ -201,7 +201,10 @@ namespace osu.Game.Screens.SelectV2 private void updateFavouriteState() { Enabled.Value = onlineBeatmapSet != null; - valueText.Text = onlineBeatmapSet?.FavouriteCount.ToLocalisableString(@"N0") ?? "-"; + + if (loadingSpinner.State.Value == Visibility.Hidden) + valueText.Text = onlineBeatmapSet?.FavouriteCount.ToLocalisableString(@"N0") ?? "-"; + isFavourite.Value = onlineBeatmapSet?.HasFavourited == true; background.FadeColour(isFavourite.Value ? colours.Pink1 : Colour4.Black.Opacity(0.2f), 500, Easing.OutQuint); From 067d884756b524d160bc321c0ee1f13b196a1a16 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sat, 19 Jul 2025 23:58:07 +0900 Subject: [PATCH 25/51] Use more muted design --- .../SelectV2/BeatmapTitleWedge_FavouriteButton.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs index 81f4561e7e..bb2a0f3934 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs @@ -41,6 +41,9 @@ namespace osu.Game.Screens.SelectV2 private APIBeatmapSet? onlineBeatmapSet; private PostBeatmapFavouriteRequest? favouriteRequest; + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + [Resolved] private OsuColour colours { get; set; } = null!; @@ -55,7 +58,7 @@ namespace osu.Game.Screens.SelectV2 } [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) + private void load() { Masking = true; CornerRadius = 5; @@ -207,7 +210,10 @@ namespace osu.Game.Screens.SelectV2 isFavourite.Value = onlineBeatmapSet?.HasFavourited == true; - background.FadeColour(isFavourite.Value ? colours.Pink1 : Colour4.Black.Opacity(0.2f), 500, Easing.OutQuint); + background.FadeColour(isFavourite.Value ? colours.Pink4.Darken(1f).Opacity(0.5f) : Color4.Black.Opacity(0.2f), 500, Easing.OutQuint); + icon.FadeColour(isFavourite.Value ? colours.Pink1 : colourProvider.Content2, 500, Easing.OutQuint); + valueText.FadeColour(isFavourite.Value ? colours.Pink1 : colourProvider.Content2, 500, Easing.OutQuint); + icon.Icon = isFavourite.Value ? FontAwesome.Solid.Heart : FontAwesome.Regular.Heart; } From 9572c2ba6708b1e2b969c922f446e4f0c55fa42c Mon Sep 17 00:00:00 2001 From: Shin Morisawa Date: Sun, 20 Jul 2025 20:50:20 +0900 Subject: [PATCH 26/51] fix one tiny miniscule english error i encountered by editing a beatmap --- osu.Game/Rulesets/Edit/Checks/CheckSongFormat.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Rulesets/Edit/Checks/CheckSongFormat.cs b/osu.Game/Rulesets/Edit/Checks/CheckSongFormat.cs index 592d61852f..1a31d19a78 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckSongFormat.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckSongFormat.cs @@ -60,7 +60,7 @@ namespace osu.Game.Rulesets.Edit.Checks public class IssueTemplateIncorrectFormat : IssueTemplate { public IssueTemplateIncorrectFormat(ICheck check) - : base(check, IssueType.Problem, "\"{0}\" is using a incorrect format. Use mp3 or ogg for the song's audio.") + : base(check, IssueType.Problem, "\"{0}\" is using an incorrect format. Use mp3 or ogg for the song's audio.") { } From d770a08526d3cb20ffd199706bcd59a79b328943 Mon Sep 17 00:00:00 2001 From: eyhn Date: Sun, 20 Jul 2025 22:24:59 +0800 Subject: [PATCH 27/51] Fix beatmap set cover not loading at screen edges --- osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs b/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs index 5bce472613..a03ee64ef4 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs @@ -50,8 +50,7 @@ namespace osu.Game.Beatmaps.Drawables protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func createContentFunc, double timeBeforeLoad) => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, timeBeforeUnload) { - Anchor = Anchor.Centre, - Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, }; protected override Drawable CreateDrawable(IBeatmapSetOnlineInfo model) From 1b473f8a81f71576a6c3c01701a6a2ae58013aaa Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Mon, 21 Jul 2025 02:22:53 +0300 Subject: [PATCH 28/51] Update test cases to match expected behaviour --- .../BeatmapCarouselFilterGroupingTest.cs | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs index 2874384c4d..946e95398d 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/BeatmapCarouselFilterGroupingTest.cs @@ -108,15 +108,17 @@ namespace osu.Game.Tests.Visual.SongSelectV2 addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddHours(-5), beatmapSets, out var todayBeatmap); addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddDays(-1), beatmapSets, out var yesterdayBeatmap); addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddDays(-4), beatmapSets, out var lastWeekBeatmap); - addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddDays(-21), beatmapSets, out var oneMonthBeatmap); - addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddMonths(-2).AddDays(-3), beatmapSets, out var threeMonthBeatmap); + addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddDays(-21), beatmapSets, out var lastMonthBeatmap); + addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddMonths(-1).AddDays(-21), beatmapSets, out var oneMonthAgoBeatmap); + addBeatmapSet(s => s.DateAdded = DateTimeOffset.Now.AddMonths(-2).AddDays(-3), beatmapSets, out var twoMonthsAgoBeatmap); var results = await runGrouping(GroupMode.DateAdded, beatmapSets); assertGroup(results, 0, "Today", new[] { todayBeatmap }, ref total); assertGroup(results, 1, "Yesterday", new[] { yesterdayBeatmap }, ref total); assertGroup(results, 2, "Last week", new[] { lastWeekBeatmap }, ref total); - assertGroup(results, 3, "1 month ago", new[] { oneMonthBeatmap }, ref total); - assertGroup(results, 4, "3 months ago", new[] { threeMonthBeatmap }, ref total); + assertGroup(results, 3, "Last month", new[] { lastMonthBeatmap }, ref total); + assertGroup(results, 4, "1 month ago", new[] { oneMonthAgoBeatmap }, ref total); + assertGroup(results, 5, "2 months ago", new[] { twoMonthsAgoBeatmap }, ref total); assertTotal(results, total); } @@ -129,17 +131,19 @@ namespace osu.Game.Tests.Visual.SongSelectV2 addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddHours(-5)), beatmapSets, out var todayBeatmap); addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddDays(-1)), beatmapSets, out var yesterdayBeatmap); addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddDays(-4)), beatmapSets, out var lastWeekBeatmap); - addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddDays(-21)), beatmapSets, out var oneMonthBeatmap); - addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddMonths(-2).AddDays(-3)), beatmapSets, out var threeMonthBeatmap); + addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddDays(-21)), beatmapSets, out var lastMonthBeatmap); + addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddMonths(-1).AddDays(-21)), beatmapSets, out var oneMonthAgoBeatmap); + addBeatmapSet(applyLastPlayed(DateTimeOffset.Now.AddMonths(-2).AddDays(-3)), beatmapSets, out var twoMonthsBeatmap); addBeatmapSet(applyLastPlayed(null), beatmapSets, out var neverBeatmap); var results = await runGrouping(GroupMode.LastPlayed, beatmapSets); assertGroup(results, 0, "Today", new[] { todayBeatmap }, ref total); assertGroup(results, 1, "Yesterday", new[] { yesterdayBeatmap }, ref total); assertGroup(results, 2, "Last week", new[] { lastWeekBeatmap }, ref total); - assertGroup(results, 3, "1 month ago", new[] { oneMonthBeatmap }, ref total); - assertGroup(results, 4, "3 months ago", new[] { threeMonthBeatmap }, ref total); - assertGroup(results, 5, "Never", new[] { neverBeatmap }, ref total); + assertGroup(results, 3, "Last month", new[] { lastMonthBeatmap }, ref total); + assertGroup(results, 4, "1 month ago", new[] { oneMonthAgoBeatmap }, ref total); + assertGroup(results, 5, "2 months ago", new[] { twoMonthsBeatmap }, ref total); + assertGroup(results, 6, "Never", new[] { neverBeatmap }, ref total); assertTotal(results, total); } From 6eb327173fffb61831e1c065df225c8a4cff0ffc Mon Sep 17 00:00:00 2001 From: Salman Alshamrani Date: Mon, 21 Jul 2025 02:23:11 +0300 Subject: [PATCH 29/51] Fix date grouping handling months incorrectly --- .../Screens/SelectV2/BeatmapCarouselFilterGrouping.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs index eb55e03d6b..cb68e2d6b5 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarouselFilterGrouping.cs @@ -261,12 +261,15 @@ namespace osu.Game.Screens.SelectV2 return new GroupDefinition(2, "Last week"); if (elapsed.TotalDays < 30) - return new GroupDefinition(3, "1 month ago"); + return new GroupDefinition(3, "Last month"); - for (int i = 60; i <= 150; i += 30) + if (elapsed.TotalDays < 60) + return new GroupDefinition(4, "1 month ago"); + + for (int i = 90; i <= 150; i += 30) { if (elapsed.TotalDays < i) - return new GroupDefinition(i, $"{i / 30} months ago"); + return new GroupDefinition(i, $"{i / 30 - 1} months ago"); } return new GroupDefinition(151, "Over 5 months ago"); From cce9543f4ae2ea3a726b7304492dde7d5c0489ed Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Mon, 21 Jul 2025 14:19:59 +0900 Subject: [PATCH 30/51] Update framework --- osu.Android.props | 2 +- osu.Game/osu.Game.csproj | 2 +- osu.iOS.props | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Android.props b/osu.Android.props index 0509d86b0a..1af3a90632 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + diff --git a/osu.iOS.props b/osu.iOS.props index 99eed6c204..0f5d295c87 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -17,6 +17,6 @@ -all - + From fae5c0e7bb37b3361038380aeeaa67fccc1b9aa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 21 Jul 2025 10:48:14 +0200 Subject: [PATCH 31/51] Fix playlists leaderboard provider not being thread safe Should close https://github.com/ppy/osu/issues/34222. I haven't exactly managed to reproduce the issue myself but I'm relatively confident in the imagined mode of failure here. See https://github.com/ppy/osu/blob/861a7e1db4b72ccfec67ef5f9a6a19faa0978d3f/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs#L25-L37 https://github.com/ppy/osu/blob/861a7e1db4b72ccfec67ef5f9a6a19faa0978d3f/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsPlayer.cs#L53 --- .../Leaderboards/IGameplayLeaderboardProvider.cs | 3 +++ .../PlaylistsGameplayLeaderboardProvider.cs | 15 ++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/osu.Game/Screens/Select/Leaderboards/IGameplayLeaderboardProvider.cs b/osu.Game/Screens/Select/Leaderboards/IGameplayLeaderboardProvider.cs index 6118529780..9c4875477c 100644 --- a/osu.Game/Screens/Select/Leaderboards/IGameplayLeaderboardProvider.cs +++ b/osu.Game/Screens/Select/Leaderboards/IGameplayLeaderboardProvider.cs @@ -13,6 +13,9 @@ namespace osu.Game.Screens.Select.Leaderboards /// /// List of all scores to display on the leaderboard. /// + /// + /// Implementors should ensure that this list is only mutated from the update thread. + /// IBindableList Scores { get; } } diff --git a/osu.Game/Screens/Select/Leaderboards/PlaylistsGameplayLeaderboardProvider.cs b/osu.Game/Screens/Select/Leaderboards/PlaylistsGameplayLeaderboardProvider.cs index 206b1375de..c60e06939b 100644 --- a/osu.Game/Screens/Select/Leaderboards/PlaylistsGameplayLeaderboardProvider.cs +++ b/osu.Game/Screens/Select/Leaderboards/PlaylistsGameplayLeaderboardProvider.cs @@ -34,24 +34,22 @@ namespace osu.Game.Screens.Select.Leaderboards [BackgroundDependencyLoader] private void load(IAPIProvider api, GameplayState? gameplayState) { + var scoresToShow = new List(); + var scoresRequest = new IndexPlaylistScoresRequest(room.RoomID!.Value, playlistItem.ID); scoresRequest.Success += response => { - var newScores = new List(); - isPartial = response.Scores.Count < response.TotalScores; for (int i = 0; i < response.Scores.Count; i++) { var score = response.Scores[i]; score.Position = i + 1; - newScores.Add(new GameplayLeaderboardScore(score, tracked: false, GameplayLeaderboardScore.ComboDisplayMode.Highest)); + scoresToShow.Add(new GameplayLeaderboardScore(score, tracked: false, GameplayLeaderboardScore.ComboDisplayMode.Highest)); } if (response.UserScore != null && response.Scores.All(s => s.ID != response.UserScore.ID)) - newScores.Add(new GameplayLeaderboardScore(response.UserScore, tracked: false, GameplayLeaderboardScore.ComboDisplayMode.Highest)); - - scores.AddRange(newScores); + scoresToShow.Add(new GameplayLeaderboardScore(response.UserScore, tracked: false, GameplayLeaderboardScore.ComboDisplayMode.Highest)); }; api.Perform(scoresRequest); @@ -59,9 +57,12 @@ namespace osu.Game.Screens.Select.Leaderboards { var localScore = new GameplayLeaderboardScore(gameplayState, tracked: true, GameplayLeaderboardScore.ComboDisplayMode.Highest); localScore.TotalScore.BindValueChanged(_ => sorting.Invalidate()); - scores.Add(localScore); + scoresToShow.Add(localScore); } + // touching the public bindable must happen on the update thread for general thread safety, + // since we may have external subscribers bound already + Schedule(() => scores.AddRange(scoresToShow)); Scheduler.AddDelayed(sort, 1000, true); } From 72254226cf347b2fbecd056752ed3af8f97ba30b Mon Sep 17 00:00:00 2001 From: Hivie Date: Mon, 21 Jul 2025 12:22:43 +0100 Subject: [PATCH 32/51] reduce whitespace --- osu.Game/Screens/Edit/Verify/IssueList.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/osu.Game/Screens/Edit/Verify/IssueList.cs b/osu.Game/Screens/Edit/Verify/IssueList.cs index 2c7d3932ad..e2eeff9ad5 100644 --- a/osu.Game/Screens/Edit/Verify/IssueList.cs +++ b/osu.Game/Screens/Edit/Verify/IssueList.cs @@ -50,10 +50,7 @@ namespace osu.Game.Screens.Edit.Verify beatmap, workingBeatmap.Value, verify.InterpretedDifficulty.Value, - beatmapInfo => - beatmapManager - .GetWorkingBeatmap(beatmapInfo) - .GetPlayableBeatmap(beatmapInfo.Ruleset) + beatmapInfo => beatmapManager.GetWorkingBeatmap(beatmapInfo).GetPlayableBeatmap(beatmapInfo.Ruleset) ); verify.InterpretedDifficulty.BindValueChanged(difficulty => context.InterpretedDifficulty = difficulty.NewValue); From 86369ab40344ef513b5066ef384eb657c48ff668 Mon Sep 17 00:00:00 2001 From: Hivie Date: Mon, 21 Jul 2025 12:32:45 +0100 Subject: [PATCH 33/51] use `TimeSpan` for defining thresholds --- .../Edit/Checks/CheckCatchLowestDiffDrainTime.cs | 7 ++++--- .../Edit/Checks/CheckManiaLowestDiffDrainTime.cs | 7 ++++--- .../Edit/Checks/CheckOsuLowestDiffDrainTime.cs | 7 ++++--- .../Edit/Checks/CheckTaikoLowestDiffDrainTime.cs | 7 ++++--- .../Editing/Checks/CheckLowestDiffDrainTimeTest.cs | 7 ++++--- 5 files changed, 20 insertions(+), 15 deletions(-) diff --git a/osu.Game.Rulesets.Catch/Edit/Checks/CheckCatchLowestDiffDrainTime.cs b/osu.Game.Rulesets.Catch/Edit/Checks/CheckCatchLowestDiffDrainTime.cs index 70d806100f..960469112f 100644 --- a/osu.Game.Rulesets.Catch/Edit/Checks/CheckCatchLowestDiffDrainTime.cs +++ b/osu.Game.Rulesets.Catch/Edit/Checks/CheckCatchLowestDiffDrainTime.cs @@ -1,6 +1,7 @@ // 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 osu.Game.Beatmaps; using osu.Game.Rulesets.Edit.Checks; @@ -12,9 +13,9 @@ namespace osu.Game.Rulesets.Catch.Edit.Checks protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds() { // See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21catch#general - yield return (DifficultyRating.Hard, (2 * 60 + 30) * 1000, "Platter"); // 2:30 - yield return (DifficultyRating.Insane, (3 * 60 + 15) * 1000, "Rain"); // 3:15 - yield return (DifficultyRating.Expert, 4 * 60 * 1000, "Overdose"); // 4:00 + yield return (DifficultyRating.Hard, new TimeSpan(0, 2, 30).TotalMilliseconds, "Platter"); + yield return (DifficultyRating.Insane, new TimeSpan(0, 3, 15).TotalMilliseconds, "Rain"); + yield return (DifficultyRating.Expert, new TimeSpan(0, 4, 0).TotalMilliseconds, "Overdose"); } } } diff --git a/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaLowestDiffDrainTime.cs b/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaLowestDiffDrainTime.cs index 4d8cf458b8..5e2223467d 100644 --- a/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaLowestDiffDrainTime.cs +++ b/osu.Game.Rulesets.Mania/Edit/Checks/CheckManiaLowestDiffDrainTime.cs @@ -1,6 +1,7 @@ // 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 osu.Game.Beatmaps; using osu.Game.Rulesets.Edit.Checks; @@ -12,9 +13,9 @@ namespace osu.Game.Rulesets.Mania.Edit.Checks protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds() { // See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21mania#rules - yield return (DifficultyRating.Hard, (2 * 60 + 30) * 1000, "Hard"); // 2:30 - yield return (DifficultyRating.Insane, (2 * 60 + 45) * 1000, "Insane"); // 2:45 - yield return (DifficultyRating.Expert, (3 * 60 + 30) * 1000, "Expert"); // 3:30 + yield return (DifficultyRating.Hard, new TimeSpan(0, 2, 30).TotalMilliseconds, "Hard"); + yield return (DifficultyRating.Insane, new TimeSpan(0, 2, 45).TotalMilliseconds, "Insane"); + yield return (DifficultyRating.Expert, new TimeSpan(0, 3, 30).TotalMilliseconds, "Expert"); } } } diff --git a/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuLowestDiffDrainTime.cs b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuLowestDiffDrainTime.cs index 400fe7d0fa..283f3b93af 100644 --- a/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuLowestDiffDrainTime.cs +++ b/osu.Game.Rulesets.Osu/Edit/Checks/CheckOsuLowestDiffDrainTime.cs @@ -1,6 +1,7 @@ // 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 osu.Game.Beatmaps; using osu.Game.Rulesets.Edit.Checks; @@ -12,9 +13,9 @@ namespace osu.Game.Rulesets.Osu.Edit.Checks protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds() { // See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21#general - yield return (DifficultyRating.Hard, (3 * 60 + 30) * 1000, "Hard"); // 3:30 - yield return (DifficultyRating.Insane, (4 * 60 + 15) * 1000, "Insane"); // 4:15 - yield return (DifficultyRating.Expert, 5 * 60 * 1000, "Expert"); // 5:00 + yield return (DifficultyRating.Hard, new TimeSpan(0, 3, 30).TotalMilliseconds, "Hard"); + yield return (DifficultyRating.Insane, new TimeSpan(0, 4, 15).TotalMilliseconds, "Insane"); + yield return (DifficultyRating.Expert, new TimeSpan(0, 5, 0).TotalMilliseconds, "Expert"); } } } diff --git a/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.cs b/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.cs index 60a7cd2a5e..8ef911c18e 100644 --- a/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.cs +++ b/osu.Game.Rulesets.Taiko/Edit/Checks/CheckTaikoLowestDiffDrainTime.cs @@ -1,6 +1,7 @@ // 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 osu.Game.Beatmaps; using osu.Game.Rulesets.Edit.Checks; @@ -12,9 +13,9 @@ namespace osu.Game.Rulesets.Taiko.Edit.Checks protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds() { // See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21taiko#general - yield return (DifficultyRating.Hard, (3 * 60 + 30) * 1000, "Muzukashii"); // 3:30 - yield return (DifficultyRating.Insane, (4 * 60 + 15) * 1000, "Oni"); // 4:15 - yield return (DifficultyRating.Expert, 5 * 60 * 1000, "Inner Oni"); // 5:00 + yield return (DifficultyRating.Hard, new TimeSpan(0, 3, 30).TotalMilliseconds, "Muzukashii"); + yield return (DifficultyRating.Insane, new TimeSpan(0, 4, 15).TotalMilliseconds, "Oni"); + yield return (DifficultyRating.Expert, new TimeSpan(0, 5, 0).TotalMilliseconds, "Inner Oni"); } } } diff --git a/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs b/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs index 20213b13a4..6b46378c5a 100644 --- a/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs +++ b/osu.Game.Tests/Editing/Checks/CheckLowestDiffDrainTimeTest.cs @@ -1,6 +1,7 @@ // 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 NUnit.Framework; @@ -254,9 +255,9 @@ namespace osu.Game.Tests.Editing.Checks protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds() { // Same thresholds as `CheckOsuLowestDiffDrainTime` for testing - yield return (DifficultyRating.Hard, (3 * 60 + 30) * 1000, "Hard"); - yield return (DifficultyRating.Insane, (4 * 60 + 15) * 1000, "Insane"); - yield return (DifficultyRating.Expert, 5 * 60 * 1000, "Expert"); + yield return (DifficultyRating.Hard, new TimeSpan(0, 3, 30).TotalMilliseconds, "Hard"); + yield return (DifficultyRating.Insane, new TimeSpan(0, 4, 15).TotalMilliseconds, "Insane"); + yield return (DifficultyRating.Expert, new TimeSpan(0, 5, 0).TotalMilliseconds, "Expert"); } } } From 9bab2444ea26d912cf0e3ac79bfdcd10959d514b Mon Sep 17 00:00:00 2001 From: Hivie Date: Mon, 21 Jul 2025 13:18:25 +0100 Subject: [PATCH 34/51] adjust wording --- osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs b/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs index 58346f7e3e..641bd66f14 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs @@ -64,7 +64,6 @@ namespace osu.Game.Rulesets.Edit.Checks yield return new IssueTemplateTooShort(this).Create( applicableThreshold.name, canUsePlayTime ? "play" : "drain", - context.Beatmap.BeatmapInfo.DifficultyName, applicableThreshold.thresholdMs - thresholdReduction, effectiveTime ); @@ -74,15 +73,14 @@ namespace osu.Game.Rulesets.Edit.Checks public class IssueTemplateTooShort : IssueTemplate { public IssueTemplateTooShort(ICheck check) - : base(check, IssueType.Problem, "With a lowest difficulty {0}, the {1} time of {2} must be at least {3}, currently {4}.") + : base(check, IssueType.Problem, "With the lowest difficulty being \"{0}\", the {1} time of this difficulty must be at least {3}, currently {4}.") { } - public Issue Create(string lowestDiffLevel, string timeType, string beatmapName, double requiredTime, double currentTime) + public Issue Create(string lowestDiffLevel, string timeType, double requiredTime, double currentTime) => new Issue(this, lowestDiffLevel, timeType, - beatmapName, TimeSpan.FromMilliseconds(requiredTime).ToString(@"m\:ss"), TimeSpan.FromMilliseconds(currentTime).ToString(@"m\:ss")); } From 209a75c4df38bfcfbcb150516f483fbd37bc4835 Mon Sep 17 00:00:00 2001 From: Hivie Date: Mon, 21 Jul 2025 13:21:30 +0100 Subject: [PATCH 35/51] oops --- osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs b/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs index 641bd66f14..f4b9cc7ecb 100644 --- a/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs +++ b/osu.Game/Rulesets/Edit/Checks/CheckLowestDiffDrainTime.cs @@ -73,7 +73,7 @@ namespace osu.Game.Rulesets.Edit.Checks public class IssueTemplateTooShort : IssueTemplate { public IssueTemplateTooShort(ICheck check) - : base(check, IssueType.Problem, "With the lowest difficulty being \"{0}\", the {1} time of this difficulty must be at least {3}, currently {4}.") + : base(check, IssueType.Problem, "With the lowest difficulty being \"{0}\", the {1} time of this difficulty must be at least {2}, currently {3}.") { } From 4127d7044f4be5fa4858dde7a356ce495f174fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marvin=20Sch=C3=BCrz?= Date: Mon, 21 Jul 2025 15:53:24 +0200 Subject: [PATCH 36/51] Animate heart icon when favouriting beatmaps --- .../BeatmapTitleWedge_FavouriteButton.cs | 146 +++++++++++++++--- 1 file changed, 126 insertions(+), 20 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs index bb2a0f3934..d16f1f9789 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs @@ -1,6 +1,7 @@ // 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.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -35,8 +36,7 @@ namespace osu.Game.Screens.SelectV2 private OsuSpriteText valueText = null!; private LoadingSpinner loadingSpinner = null!; private Box hoverLayer = null!; - private Box flashLayer = null!; - private SpriteIcon icon = null!; + private HeartIcon icon = null!; private APIBeatmapSet? onlineBeatmapSet; private PostBeatmapFavouriteRequest? favouriteRequest; @@ -82,13 +82,11 @@ namespace osu.Game.Screens.SelectV2 Shear = -OsuGame.SHEAR, Children = new Drawable[] { - icon = new SpriteIcon + icon = new HeartIcon { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, - Icon = OsuIcon.Heart, Size = new Vector2(OsuFont.Style.Heading2.Size), - Colour = colourProvider.Content2, }, new Container { @@ -142,12 +140,6 @@ namespace osu.Game.Screens.SelectV2 Colour = Colour4.White.Opacity(0.1f), Blending = BlendingParameters.Additive, }, - flashLayer = new Box - { - RelativeSizeAxes = Axes.Both, - Alpha = 0, - Colour = Colour4.White, - } }); Action = toggleFavourite; } @@ -192,16 +184,16 @@ namespace osu.Game.Screens.SelectV2 setBeatmapSet(beatmapSet); } - private void setBeatmapSet(APIBeatmapSet? beatmapSet) + private void setBeatmapSet(APIBeatmapSet? beatmapSet, bool withHeartAnimation = false) { loadingSpinner.State.Value = Visibility.Hidden; valueText.FadeIn(120, Easing.OutQuint); onlineBeatmapSet = beatmapSet; - updateFavouriteState(); + updateFavouriteState(withHeartAnimation); } - private void updateFavouriteState() + private void updateFavouriteState(bool withAnimation = false) { Enabled.Value = onlineBeatmapSet != null; @@ -211,10 +203,8 @@ namespace osu.Game.Screens.SelectV2 isFavourite.Value = onlineBeatmapSet?.HasFavourited == true; background.FadeColour(isFavourite.Value ? colours.Pink4.Darken(1f).Opacity(0.5f) : Color4.Black.Opacity(0.2f), 500, Easing.OutQuint); - icon.FadeColour(isFavourite.Value ? colours.Pink1 : colourProvider.Content2, 500, Easing.OutQuint); valueText.FadeColour(isFavourite.Value ? colours.Pink1 : colourProvider.Content2, 500, Easing.OutQuint); - - icon.Icon = isFavourite.Value ? FontAwesome.Solid.Heart : FontAwesome.Regular.Heart; + icon.SetActive(isFavourite.Value, withAnimation); } private void toggleFavourite() @@ -232,13 +222,129 @@ namespace osu.Game.Screens.SelectV2 bool hasFavourited = favouriteRequest.Action == BeatmapFavouriteAction.Favourite; beatmapSet.HasFavourited = hasFavourited; beatmapSet.FavouriteCount += hasFavourited ? 1 : -1; - setBeatmapSet(beatmapSet); - if (hasFavourited) - flashLayer.FadeOutFromOne(500, Easing.OutQuint); + setBeatmapSet(beatmapSet, withHeartAnimation: hasFavourited); }; api.Queue(favouriteRequest); setLoading(); } } + + private partial class HeartIcon : CompositeDrawable + { + private readonly SpriteIcon icon; + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } = null!; + + [Resolved] + private OsuColour colours { get; set; } = null!; + + public HeartIcon() + { + InternalChildren = new Drawable[] + { + icon = new SpriteIcon + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Icon = FontAwesome.Regular.Heart, + RelativeSizeAxes = Axes.Both, + }, + }; + } + + private const double pop_out_duration = 100; + private const double pop_in_duration = 500; + + private bool active; + + public void SetActive(bool active, bool withAnimation = false) + { + if (this.active == active) + return; + + this.active = active; + + FinishTransforms(true); + + if (active) + { + transitionIcon(FontAwesome.Solid.Heart, colours.Pink1, emphasised: withAnimation); + + if (withAnimation) + playFavouriteAnimation(); + } + else + { + transitionIcon(FontAwesome.Regular.Heart, colourProvider.Content2); + } + } + + private void transitionIcon(IconUsage newIcon, Color4 colour, bool emphasised = false) + { + icon.ScaleTo(emphasised ? 0.5f : 0.8f, pop_out_duration, Easing.OutQuad) + .Then() + .FadeColour(colour) + .Schedule(() => icon.Icon = newIcon) + .ScaleTo(1, pop_in_duration, Easing.OutElasticHalf); + } + + private void playFavouriteAnimation() + { + var circle = new FastCircle + { + RelativeSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(0.5f), + Blending = BlendingParameters.Additive, + Alpha = 0, + Depth = 1, + }; + + AddInternal(circle); + + circle.Delay(pop_out_duration) + .FadeTo(0.35f) + .FadeOut(1200, Easing.OutCubic) + .FadeColour(colours.Pink1, 1200, Easing.Out) + .ScaleTo(10f, 1200, Easing.OutQuint) + .Expire(); + + const int num_particles = 8; + + static float randomFloat(float min, float max) => min + Random.Shared.NextSingle() * (max - min); + + for (int i = 0; i < num_particles; i++) + { + double duration = randomFloat(600, 1000); + float angle = (i + randomFloat(0, 0.75f)) / num_particles * MathF.PI * 2; + var direction = new Vector2(MathF.Cos(angle), MathF.Sin(angle)); + float distance = randomFloat(DrawWidth / 2, DrawWidth); + + var particle = new FastCircle + { + Position = direction * DrawWidth / 4, + Size = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Blending = BlendingParameters.Additive, + Alpha = 0, + Depth = 2, + Colour = colours.Pink, + }; + + AddInternal(particle); + + particle + .Delay(pop_out_duration) + .FadeTo(0.5f) + .MoveTo(direction * distance, 1300, Easing.OutQuint) + .FadeOut(duration, Easing.Out) + .ScaleTo(0.5f, duration) + .Expire(); + } + } + } } } From 28765f60a6658893048546b3027a287e389a64d6 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 22 Jul 2025 00:46:07 +0900 Subject: [PATCH 37/51] Adjust circle animation, colour and depth --- .../Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs index d16f1f9789..ae44442876 100644 --- a/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs +++ b/osu.Game/Screens/SelectV2/BeatmapTitleWedge_FavouriteButton.cs @@ -299,16 +299,15 @@ namespace osu.Game.Screens.SelectV2 Scale = new Vector2(0.5f), Blending = BlendingParameters.Additive, Alpha = 0, - Depth = 1, + Depth = float.MinValue, }; AddInternal(circle); circle.Delay(pop_out_duration) .FadeTo(0.35f) - .FadeOut(1200, Easing.OutCubic) - .FadeColour(colours.Pink1, 1200, Easing.Out) - .ScaleTo(10f, 1200, Easing.OutQuint) + .FadeOut(1400, Easing.OutCubic) + .ScaleTo(10f, 750, Easing.OutQuint) .Expire(); const int num_particles = 8; From aeb1941cf94089c0711853cdd9e46be68dd918be Mon Sep 17 00:00:00 2001 From: Chris Ehmann Date: Mon, 21 Jul 2025 20:58:09 -0700 Subject: [PATCH 38/51] Resolve `IBindable` via DI in `EditorBackgroundScreen` --- .../Screens/Backgrounds/EditorBackgroundScreen.cs | 11 +++++++---- osu.Game/Screens/Edit/Editor.cs | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs b/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs index 7aa071ec38..24b582b71b 100644 --- a/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs +++ b/osu.Game/Screens/Backgrounds/EditorBackgroundScreen.cs @@ -19,7 +19,6 @@ namespace osu.Game.Screens.Backgrounds { public partial class EditorBackgroundScreen : BackgroundScreen { - private readonly IBindable beatmap; private readonly Container dimContainer; private CancellationTokenSource? cancellationTokenSource; @@ -31,10 +30,14 @@ namespace osu.Game.Screens.Backgrounds private IFrameBasedClock? clockSource; - public EditorBackgroundScreen(IBindable beatmap) - { - this.beatmap = beatmap; + // We retrieve IBindable from our dependency cache instead of passing WorkingBeatmap directly into EditorBackgroundScreen. + // Otherwise, DummyWorkingBeatmap will be erroneously passed in whenever creating a new beatmap (since the Schedule() in the Editor that populates + // a new WorkingBeatmap with correct values generally runs after EditorBackgroundScreen is created), which causes any background changes to not be displayed. + [Resolved] + private IBindable beatmap { get; set; } = null!; + public EditorBackgroundScreen() + { InternalChild = dimContainer = new Container { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index 88a1f74991..05f74c8514 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -480,7 +480,7 @@ namespace osu.Game.Screens.Edit [Resolved] private MusicController musicController { get; set; } - protected override BackgroundScreen CreateBackground() => new EditorBackgroundScreen(Beatmap); + protected override BackgroundScreen CreateBackground() => new EditorBackgroundScreen(); protected override void LoadComplete() { From d8900defd34690de92be3406003fb3839fc0df1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 22 Jul 2025 09:26:12 +0200 Subject: [PATCH 39/51] Store pause timestamps (rounded to ms) instead of general count --- osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs | 5 +++-- osu.Game.Tests/Visual/Gameplay/TestScenePause.cs | 4 ++-- osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs | 6 +++--- osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs | 6 +++--- osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs | 3 ++- osu.Game/Scoring/ScoreInfo.cs | 2 +- osu.Game/Screens/Play/SubmittingPlayer.cs | 2 +- 7 files changed, 15 insertions(+), 13 deletions(-) diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs index 0b498e340c..2815c9cd8f 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs @@ -13,6 +13,7 @@ using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Legacy; +using osu.Game.Extensions; using osu.Game.IO.Legacy; using osu.Game.Online.API.Requests.Responses; using osu.Game.Replays; @@ -321,7 +322,7 @@ namespace osu.Game.Tests.Beatmaps.Formats CountryCode = CountryCode.PL }; scoreInfo.ClientVersion = "2023.1221.0"; - scoreInfo.PauseCount = 3; + scoreInfo.Pauses.AddRange([111111, 222222, 333333]); var beatmap = new TestBeatmap(ruleset); var score = new Score @@ -346,7 +347,7 @@ namespace osu.Game.Tests.Beatmaps.Formats Assert.That(decodedAfterEncode.ScoreInfo.Mods, Is.EqualTo(scoreInfo.Mods)); Assert.That(decodedAfterEncode.ScoreInfo.ClientVersion, Is.EqualTo("2023.1221.0")); Assert.That(decodedAfterEncode.ScoreInfo.RealmUser.OnlineID, Is.EqualTo(3035836)); - Assert.That(decodedAfterEncode.ScoreInfo.PauseCount, Is.EqualTo(3)); + Assert.That(decodedAfterEncode.ScoreInfo.Pauses, Is.EquivalentTo(new[] { 111111, 222222, 333333 })); }); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index 03f5dacfa0..356cc5f998 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -69,7 +69,7 @@ namespace osu.Game.Tests.Visual.Gameplay pauseViaBackAction(); pauseViaBackAction(); confirmPausedWithNoOverlay(); - AddAssert("score pause count incremented", () => Player.Score.ScoreInfo.PauseCount, () => Is.EqualTo(1)); + AddAssert("pause recorded", () => Player.Score.ScoreInfo.Pauses, () => Has.Length.EqualTo(1)); } [Test] @@ -78,7 +78,7 @@ namespace osu.Game.Tests.Visual.Gameplay pauseViaPauseGameplayAction(); pauseViaPauseGameplayAction(); confirmPausedWithNoOverlay(); - AddAssert("score pause count incremented", () => Player.Score.ScoreInfo.PauseCount, () => Is.EqualTo(1)); + AddAssert("pause recorded", () => Player.Score.ScoreInfo.Pauses, () => Has.Length.EqualTo(1)); } [Test] diff --git a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs index 8586133c5b..58c819f391 100644 --- a/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs +++ b/osu.Game/Online/API/Requests/Responses/SoloScoreInfo.cs @@ -87,8 +87,8 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty("legacy_score_id")] public ulong? LegacyScoreId { get; set; } - [JsonProperty("pause_count")] - public int PauseCount { get; set; } + [JsonProperty("pauses")] + public int[] Pauses { get; set; } = []; #region osu-web API additions (not stored to database). @@ -263,7 +263,7 @@ namespace osu.Game.Online.API.Requests.Responses Mods = score.APIMods, Statistics = score.Statistics.Where(kvp => kvp.Value != 0).ToDictionary(), MaximumStatistics = score.MaximumStatistics.Where(kvp => kvp.Value != 0).ToDictionary(), - PauseCount = score.PauseCount, + Pauses = score.Pauses.ToArray(), }; } } diff --git a/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs b/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs index 5995e2358b..8247dc60cb 100644 --- a/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs +++ b/osu.Game/Scoring/Legacy/LegacyReplaySoloScoreInfo.cs @@ -49,8 +49,8 @@ namespace osu.Game.Scoring.Legacy [JsonProperty("total_score_without_mods")] public long? TotalScoreWithoutMods { get; set; } - [JsonProperty("pause_count")] - public int PauseCount { get; set; } + [JsonProperty("pauses")] + public int[] Pauses { get; set; } = []; public static LegacyReplaySoloScoreInfo FromScore(ScoreInfo score) => new LegacyReplaySoloScoreInfo { @@ -62,7 +62,7 @@ namespace osu.Game.Scoring.Legacy Rank = score.Rank, UserID = score.User.OnlineID, TotalScoreWithoutMods = score.TotalScoreWithoutMods > 0 ? score.TotalScoreWithoutMods : null, - PauseCount = score.PauseCount, + Pauses = score.Pauses.ToArray(), }; } } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index 987b3cd373..393df65cc8 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -13,6 +13,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Legacy; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.IO.Legacy; using osu.Game.Online.API.Requests.Responses; using osu.Game.Replays; @@ -143,7 +144,7 @@ namespace osu.Game.Scoring.Legacy else PopulateTotalScoreWithoutMods(score.ScoreInfo); - score.ScoreInfo.PauseCount = readScore.PauseCount; + score.ScoreInfo.Pauses.AddRange(readScore.Pauses); }); } } diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index a404375d0e..9e10b93168 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -155,7 +155,7 @@ namespace osu.Game.Scoring [MapTo("MaximumStatistics")] public string MaximumStatisticsJson { get; set; } = string.Empty; - public int PauseCount { get; set; } + public IList Pauses { get; } = null!; public ScoreInfo(BeatmapInfo? beatmap = null, RulesetInfo? ruleset = null, RealmUser? realmUser = null) { diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index c950621134..9f0ae7168b 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -241,7 +241,7 @@ namespace osu.Game.Screens.Play bool paused = base.Pause(); if (!wasPaused && paused) - Score.ScoreInfo.PauseCount++; + Score.ScoreInfo.Pauses.Add((int)Math.Round(GameplayClockContainer.CurrentTime)); return paused; } From 5e0219c58f3724d32288f3d6287cec4791942dbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 22 Jul 2025 09:44:35 +0200 Subject: [PATCH 40/51] Fix comment Co-authored-by: Dean Herbert --- osu.Game/Database/RealmAccess.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index 61c6f550fa..17f4068fc4 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -100,7 +100,7 @@ namespace osu.Game.Database /// 48 2025-03-19 Clear online status for all qualified beatmaps (some were stuck in a qualified state due to local caching issues). /// 49 2025-06-10 Reset the LegacyOnlineID to -1 for all scores that have it set to 0 (which is semantically the same) for consistency of handling with OnlineID. /// 50 2025-07-11 Add UserTags to BeatmapMetadata. - /// 51 2025-07-22 Add ScoreInfo.PauseCount. + /// 51 2025-07-22 Add ScoreInfo.Pauses. /// private const int schema_version = 51; From b9bda61e2782970b55d7e0f83ad0a67dedf06467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 22 Jul 2025 10:10:39 +0200 Subject: [PATCH 41/51] Fix tests --- osu.Game.Tests/Visual/Gameplay/TestScenePause.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs index 356cc5f998..f28baada9e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePause.cs @@ -69,7 +69,7 @@ namespace osu.Game.Tests.Visual.Gameplay pauseViaBackAction(); pauseViaBackAction(); confirmPausedWithNoOverlay(); - AddAssert("pause recorded", () => Player.Score.ScoreInfo.Pauses, () => Has.Length.EqualTo(1)); + AddAssert("pause recorded", () => Player.Score.ScoreInfo.Pauses, () => Has.Count.EqualTo(1)); } [Test] @@ -78,7 +78,7 @@ namespace osu.Game.Tests.Visual.Gameplay pauseViaPauseGameplayAction(); pauseViaPauseGameplayAction(); confirmPausedWithNoOverlay(); - AddAssert("pause recorded", () => Player.Score.ScoreInfo.Pauses, () => Has.Length.EqualTo(1)); + AddAssert("pause recorded", () => Player.Score.ScoreInfo.Pauses, () => Has.Count.EqualTo(1)); } [Test] From d3701f465957192a78710a410fe18862eefa64f6 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 22 Jul 2025 17:27:26 +0900 Subject: [PATCH 42/51] Remove iOS workload rollbacks --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f041f2e916..650d6b7c74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -143,7 +143,7 @@ jobs: dotnet-version: "8.0.x" - name: Install .NET Workloads - run: dotnet workload install ios --from-rollback-file https://raw.githubusercontent.com/ppy/osu-framework/refs/heads/master/workloads.json + run: dotnet workload install ios - name: Build run: dotnet build -c Debug osu.iOS.slnf From 4daa900a192edc1d49d02d9c522429490550d02d Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 22 Jul 2025 18:14:09 +0900 Subject: [PATCH 43/51] Use macOS-15 for iOS builds --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 650d6b7c74..d468886d6a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,7 +131,7 @@ jobs: build-only-ios: name: Build only (iOS) - runs-on: macos-latest + runs-on: macos-15 timeout-minutes: 60 steps: - name: Checkout From 44531794a14a934d98682a93a6b773370423e788 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 23 Jul 2025 14:44:15 +0900 Subject: [PATCH 44/51] Add test coverage of incorrect formatting output Fix a cosmetic UI issue where -0.0 is displayed when clicking the Calibrate using last play button. Removed changes to AudioOffsetAdjustControl and added check to ToStandardFormattedString for if floatValue is 0 --- .../Visual/Gameplay/TestSceneBeatmapOffsetControl.cs | 6 ++++++ .../Screens/Play/PlayerSettings/BeatmapOffsetControl.cs | 9 ++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs index 92a10628ff..2af941d592 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs @@ -242,6 +242,12 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("No calibration button", () => !offsetControl.ChildrenOfType().Any()); } + [Test] + public void TestNegativeZero() + { + AddAssert("assert", () => BeatmapOffsetControl.GetOffsetExplanatoryText(-0.0001).ToString(), () => Is.EqualTo("0 ms")); + } + private void recreateControl() { AddStep("Create control", () => diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index b0b4f6cc5d..df64200cd7 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -17,6 +17,7 @@ using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -315,9 +316,11 @@ namespace osu.Game.Screens.Play.PlayerSettings public static LocalisableString GetOffsetExplanatoryText(double offset) { - return offset == 0 - ? LocalisableString.Interpolate($@"{offset:0.0} ms") - : LocalisableString.Interpolate($@"{offset:0.0} ms {getEarlyLateText(offset)}"); + string formatOffset = offset.ToStandardFormattedString(1); + + return formatOffset == "0" + ? LocalisableString.Interpolate($@"{formatOffset} ms") + : LocalisableString.Interpolate($@"{formatOffset} ms {getEarlyLateText(offset)}"); LocalisableString getEarlyLateText(double value) { From c72a6d929b569d8ca8fa978ce61d55f912a5f3d3 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 23 Jul 2025 15:29:03 +0900 Subject: [PATCH 45/51] Trim suffix from `CFBundleVersion` --- osu.iOS/osu.iOS.csproj | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/osu.iOS/osu.iOS.csproj b/osu.iOS/osu.iOS.csproj index 19c0c610b5..a13120dc18 100644 --- a/osu.iOS/osu.iOS.csproj +++ b/osu.iOS/osu.iOS.csproj @@ -4,8 +4,12 @@ 13.4 Exe 0.1.0 - $(Version) - $(Version) + + + $([System.String]::Copy('$(Version)').Split('-')[0]) + + $(VersionNoSuffix) + $(VersionNoSuffix) From c91991a328d7104913fd0dc3283b98a6d041bdc6 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 23 Jul 2025 17:27:41 +0900 Subject: [PATCH 46/51] Embed full version into PList --- osu.iOS/OsuGameIOS.cs | 12 +++--------- osu.iOS/osu.iOS.csproj | 10 ++++++++++ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/osu.iOS/OsuGameIOS.cs b/osu.iOS/OsuGameIOS.cs index c7ef1c885a..fff781f38f 100644 --- a/osu.iOS/OsuGameIOS.cs +++ b/osu.iOS/OsuGameIOS.cs @@ -20,15 +20,9 @@ namespace osu.iOS { private readonly AppDelegate appDelegate; - public override Version AssemblyVersion - { - get - { - // Example: 2025.613.0-tachyon - string bundleVersion = NSBundle.MainBundle.InfoDictionary["CFBundleVersion"].ToString(); - return new Version(bundleVersion.Split('-')[0]); - } - } + public override Version AssemblyVersion => new Version(NSBundle.MainBundle.InfoDictionary["CFBundleVersion"].ToString()); + + public override string Version => NSBundle.MainBundle.InfoDictionary["OsuVersion"].ToString(); public override bool HideUnlicensedContent => true; diff --git a/osu.iOS/osu.iOS.csproj b/osu.iOS/osu.iOS.csproj index a13120dc18..3e8beddaa4 100644 --- a/osu.iOS/osu.iOS.csproj +++ b/osu.iOS/osu.iOS.csproj @@ -22,4 +22,14 @@ + + + + $(AppBundleDir)/Info.plist + OsuVersion + + + From e55a9e486b264067149f1b1fcbfc5393caa80f64 Mon Sep 17 00:00:00 2001 From: eyhn Date: Wed, 23 Jul 2025 22:29:07 +0800 Subject: [PATCH 47/51] Fix present beatmap audio start at the preview point --- .../Navigation/TestSceneScreenNavigation.cs | 22 +++++++++++++++++++ osu.Game/Screens/SelectV2/SongSelect.cs | 8 +++---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 53cd411bb0..1e6381dfd8 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -1347,6 +1347,28 @@ namespace osu.Game.Tests.Visual.Navigation } } + [Test] + public void TestSongPresentBeatmap() + { + BeatmapSetInfo beatmap = null!; + AddStep("import beatmap", () => + { + var task = BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true); + task.WaitSafely(); + beatmap = task.GetResultSafely(); + }); + + AddStep("present Beatmap", () => Game.PresentBeatmap(beatmap)); + + AddUntilStep("wait for track playing", () => Game.MusicController.IsPlaying); + AddAssert("ensure time is reset to preview point", + () => + { + double timeFormPreviewPoint = Game.MusicController.CurrentTrack.CurrentTime - beatmap.Metadata.PreviewTime; + return timeFormPreviewPoint > 0 && timeFormPreviewPoint < 1000; + }); + } + [Test] public void TestPresentBeatmapAfterDeletion() { diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index 33f2bd227d..7d3917cc26 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -346,7 +346,7 @@ namespace osu.Game.Screens.SelectV2 ensureGlobalBeatmapValid(); - ensurePlayingSelected(true); + ensurePlayingSelected(); updateBackgroundDim(); updateWedgeVisibility(); }); @@ -379,7 +379,7 @@ namespace osu.Game.Screens.SelectV2 /// Ensures some music is playing for the current track. /// Will resume playback from a manual user pause if the track has changed. /// - private void ensurePlayingSelected(bool restart) + private void ensurePlayingSelected() { if (!ControlGlobalMusic) return; @@ -391,7 +391,7 @@ namespace osu.Game.Screens.SelectV2 if (!track.IsRunning && (music.UserPauseRequested != true || isNewTrack)) { Logger.Log($"Song select decided to {nameof(ensurePlayingSelected)}"); - music.Play(restart); + music.Play(isNewTrack); } lastTrack.SetTarget(track); @@ -634,7 +634,7 @@ namespace osu.Game.Screens.SelectV2 ensureGlobalBeatmapValid(); - ensurePlayingSelected(false); + ensurePlayingSelected(); updateBackgroundDim(); } From 843cd86551690fb70e8509552f87c8e3d9eab9e1 Mon Sep 17 00:00:00 2001 From: eyhn Date: Wed, 23 Jul 2025 22:33:22 +0800 Subject: [PATCH 48/51] Adjust variable name --- .../Visual/Navigation/TestSceneScreenNavigation.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 1e6381dfd8..62ef4fb9d5 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -1350,21 +1350,21 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestSongPresentBeatmap() { - BeatmapSetInfo beatmap = null!; + BeatmapSetInfo beatmapInfo = null!; AddStep("import beatmap", () => { var task = BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true); task.WaitSafely(); - beatmap = task.GetResultSafely(); + beatmapInfo = task.GetResultSafely(); }); - AddStep("present Beatmap", () => Game.PresentBeatmap(beatmap)); + AddStep("present beatmap", () => Game.PresentBeatmap(beatmapInfo)); AddUntilStep("wait for track playing", () => Game.MusicController.IsPlaying); AddAssert("ensure time is reset to preview point", () => { - double timeFormPreviewPoint = Game.MusicController.CurrentTrack.CurrentTime - beatmap.Metadata.PreviewTime; + double timeFormPreviewPoint = Game.MusicController.CurrentTrack.CurrentTime - beatmapInfo.Metadata.PreviewTime; return timeFormPreviewPoint > 0 && timeFormPreviewPoint < 1000; }); } From ad9584e6586f73684702bfdc128b95f53008d199 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 24 Jul 2025 13:23:27 +0900 Subject: [PATCH 49/51] Remove duplicate test --- .../Navigation/TestSceneScreenNavigation.cs | 60 ------------------- .../TestSceneSongSelectNavigation.cs | 24 ++++++++ 2 files changed, 24 insertions(+), 60 deletions(-) diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 62ef4fb9d5..466fbf92a8 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -697,44 +697,6 @@ namespace osu.Game.Tests.Visual.Navigation AddUntilStep("wait for score panel removal", () => scorePanel.Parent == null); } - [TestCase(true)] - [TestCase(false)] - public void TestSongContinuesAfterExitPlayer(bool withUserPause) - { - Player player = null; - - IWorkingBeatmap beatmap() => Game.Beatmap.Value; - - Screens.SelectV2.SongSelect songSelect = null; - PushAndConfirm(() => songSelect = new SoloSongSelect()); - AddUntilStep("wait for song select", () => songSelect.CarouselItemsPresented); - - AddStep("import beatmap", () => BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true).WaitSafely()); - - AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); - - if (withUserPause) - AddStep("pause", () => Game.Dependencies.Get().Stop(true)); - - AddStep("press enter", () => InputManager.Key(Key.Enter)); - - AddUntilStep("wait for player", () => - { - DismissAnyNotifications(); - return (player = Game.ScreenStack.CurrentScreen as Player) != null; - }); - - AddUntilStep("wait for fail", () => player.GameplayState.HasFailed); - - AddUntilStep("wait for track stop", () => !Game.MusicController.IsPlaying); - AddAssert("Ensure time before preview point", () => Game.MusicController.CurrentTrack.CurrentTime < beatmap().BeatmapInfo.Metadata.PreviewTime); - - pushEscape(); - - AddUntilStep("wait for track playing", () => Game.MusicController.IsPlaying); - AddAssert("Ensure time wasn't reset to preview point", () => Game.MusicController.CurrentTrack.CurrentTime < beatmap().BeatmapInfo.Metadata.PreviewTime); - } - [Test] public void TestMenuMakesMusic() { @@ -1347,28 +1309,6 @@ namespace osu.Game.Tests.Visual.Navigation } } - [Test] - public void TestSongPresentBeatmap() - { - BeatmapSetInfo beatmapInfo = null!; - AddStep("import beatmap", () => - { - var task = BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true); - task.WaitSafely(); - beatmapInfo = task.GetResultSafely(); - }); - - AddStep("present beatmap", () => Game.PresentBeatmap(beatmapInfo)); - - AddUntilStep("wait for track playing", () => Game.MusicController.IsPlaying); - AddAssert("ensure time is reset to preview point", - () => - { - double timeFormPreviewPoint = Game.MusicController.CurrentTrack.CurrentTime - beatmapInfo.Metadata.PreviewTime; - return timeFormPreviewPoint > 0 && timeFormPreviewPoint < 1000; - }); - } - [Test] public void TestPresentBeatmapAfterDeletion() { diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs index 676be8fccf..9a1f1dc515 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneSongSelectNavigation.cs @@ -92,6 +92,30 @@ namespace osu.Game.Tests.Visual.Navigation waitForScreen(); } + [Test] + public void TestPresentBeatmapFromMainMenuUsesPreviewPoint() + { + BeatmapSetInfo beatmapInfo = null!; + + AddStep("import beatmap", () => + { + var task = BeatmapImportHelper.LoadOszIntoOsu(Game, virtualTrack: true); + task.WaitSafely(); + beatmapInfo = task.GetResultSafely(); + }); + + AddStep("present beatmap", () => Game.PresentBeatmap(beatmapInfo)); + + AddUntilStep("wait for track playing", () => Game.MusicController.IsPlaying); + + AddAssert("ensure time is reset to preview point", + () => + { + double timeFromPreviewPoint = Math.Abs(Game.MusicController.CurrentTrack.CurrentTime - beatmapInfo.Metadata.PreviewTime); + return timeFromPreviewPoint < 5000; + }); + } + [TestCase(true)] [TestCase(false)] public void TestSongContinuesAfterExitPlayer(bool withUserPause) From 07137f353fc1670ee8c6682b463db504f373157c Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 24 Jul 2025 13:25:06 +0900 Subject: [PATCH 50/51] Add note about why we don't always restart on resuming --- osu.Game/Screens/SelectV2/SongSelect.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/osu.Game/Screens/SelectV2/SongSelect.cs b/osu.Game/Screens/SelectV2/SongSelect.cs index 7d3917cc26..6b1e812cdd 100644 --- a/osu.Game/Screens/SelectV2/SongSelect.cs +++ b/osu.Game/Screens/SelectV2/SongSelect.cs @@ -391,6 +391,9 @@ namespace osu.Game.Screens.SelectV2 if (!track.IsRunning && (music.UserPauseRequested != true || isNewTrack)) { Logger.Log($"Song select decided to {nameof(ensurePlayingSelected)}"); + + // Only restart playback if a new track. + // This is important so that when exiting gameplay, the track is not restarted back to the preview point. music.Play(isNewTrack); } From d36c50de13a1c5273f3bd2db2b468c9041576cd1 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 24 Jul 2025 22:35:10 +0900 Subject: [PATCH 51/51] Revert "Update framework" This is temporary until we have a fix for https://github.com/ppy/osu/issues/34340, which will require resolution in bass-side thread https://www.un4seen.com/forum/?topic=20482.msg145307#msg145307. --- osu.Android.props | 2 +- osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs | 5 +++-- osu.Game/osu.Game.csproj | 2 +- osu.iOS.props | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/osu.Android.props b/osu.Android.props index 1af3a90632..ebe2ca782a 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -10,7 +10,7 @@ true - + diff --git a/osu.iOS.props b/osu.iOS.props index 0f5d295c87..74b56bbaf6 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -17,6 +17,6 @@ -all - +