From 2cbf71c592e3b533395fd1b9e4dffbccde39cf9d Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 14 Mar 2025 18:42:36 +0900 Subject: [PATCH 01/27] Add `MultiplayerPlaylistItem` copy constructor + tests --- .../OnlinePlay/MultiplayerPlaylistItemTest.cs | 66 +++++++++++++++++++ osu.Game.Tests/osu.Game.Tests.csproj | 1 + .../Online/Rooms/MultiplayerPlaylistItem.cs | 25 +++++++ 3 files changed, 92 insertions(+) create mode 100644 osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs diff --git a/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs b/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs new file mode 100644 index 0000000000..6885a579fa --- /dev/null +++ b/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs @@ -0,0 +1,66 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Bogus; +using MessagePack; +using NUnit.Framework; +using osu.Game.Online.API; +using osu.Game.Online.Rooms; + +namespace osu.Game.Tests.OnlinePlay +{ + [TestFixture] + public class MultiplayerPlaylistItemTest + { + [Test] + public void TestCloneMultiplayerPlaylistItem() + { + var faker = new Faker() + .StrictMode(true) + .RuleFor(o => o.ID, f => f.Random.Long()) + .RuleFor(o => o.OwnerID, f => f.Random.Int()) + .RuleFor(o => o.BeatmapID, f => f.Random.Int()) + .RuleFor(o => o.BeatmapChecksum, f => f.Random.Hash()) + .RuleFor(o => o.RulesetID, f => f.Random.Int()) + .RuleFor(o => o.RequiredMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) })) + .RuleFor(o => o.AllowedMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) })) + .RuleFor(o => o.Expired, f => f.Random.Bool()) + .RuleFor(o => o.PlaylistOrder, f => f.Random.UShort()) + .RuleFor(o => o.PlayedAt, f => f.Date.RecentOffset()) + .RuleFor(o => o.StarRating, f => f.Random.Double()) + .RuleFor(o => o.Freestyle, f => f.Random.Bool()); + + for (int i = 0; i < 100; i++) + { + MultiplayerPlaylistItem item = faker.Generate(); + Assert.That(MessagePackSerializer.SerializeToJson(item.Clone()), Is.EqualTo(MessagePackSerializer.SerializeToJson(item))); + } + } + + [Test] + public void TestConstructFromAPIModel() + { + var faker = new Faker() + .StrictMode(true) + .RuleFor(o => o.ID, f => f.Random.Long()) + .RuleFor(o => o.OwnerID, f => f.Random.Int()) + .RuleFor(o => o.BeatmapID, f => f.Random.Int()) + .RuleFor(o => o.BeatmapChecksum, f => f.Random.Hash()) + .RuleFor(o => o.RulesetID, f => f.Random.Int()) + .RuleFor(o => o.RequiredMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) })) + .RuleFor(o => o.AllowedMods, f => f.Make(5, _ => new APIMod { Acronym = f.Random.String2(3) })) + .RuleFor(o => o.Expired, f => f.Random.Bool()) + .RuleFor(o => o.PlaylistOrder, f => f.Random.UShort()) + .RuleFor(o => o.PlayedAt, f => f.Date.RecentOffset()) + .RuleFor(o => o.StarRating, f => f.Random.Double()) + .RuleFor(o => o.Freestyle, f => f.Random.Bool()); + + for (int i = 0; i < 100; i++) + { + MultiplayerPlaylistItem initialItem = faker.Generate(); + MultiplayerPlaylistItem copiedItem = new MultiplayerPlaylistItem(new PlaylistItem(initialItem)); + Assert.That(MessagePackSerializer.SerializeToJson(copiedItem), Is.EqualTo(MessagePackSerializer.SerializeToJson(initialItem))); + } + } + } +} diff --git a/osu.Game.Tests/osu.Game.Tests.csproj b/osu.Game.Tests/osu.Game.Tests.csproj index a1f43505f0..c86f05c257 100644 --- a/osu.Game.Tests/osu.Game.Tests.csproj +++ b/osu.Game.Tests/osu.Game.Tests.csproj @@ -1,6 +1,7 @@  + diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs index 3234e28166..d4417f2de4 100644 --- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs +++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs @@ -62,11 +62,17 @@ namespace osu.Game.Online.Rooms [Key(11)] public bool Freestyle { get; set; } + /// + /// Creates a new . + /// [SerializationConstructor] public MultiplayerPlaylistItem() { } + /// + /// Creates a new from an API . + /// public MultiplayerPlaylistItem(PlaylistItem item) { ID = item.ID; @@ -82,5 +88,24 @@ namespace osu.Game.Online.Rooms StarRating = item.Beatmap.StarRating; Freestyle = item.Freestyle; } + + /// + /// Creates a copy of this . + /// + public MultiplayerPlaylistItem Clone() => new MultiplayerPlaylistItem + { + ID = ID, + OwnerID = OwnerID, + BeatmapID = BeatmapID, + BeatmapChecksum = BeatmapChecksum, + RulesetID = RulesetID, + RequiredMods = RequiredMods.ToArray(), + AllowedMods = AllowedMods.ToArray(), + Expired = Expired, + PlaylistOrder = PlaylistOrder, + PlayedAt = PlayedAt, + StarRating = StarRating, + Freestyle = Freestyle, + }; } } From 0608058f5d5e613462c6be187a2c38f6e91b2d24 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 14 Mar 2025 18:42:43 +0900 Subject: [PATCH 02/27] Fix beatmap checksum being lost --- osu.Game/Online/Rooms/PlaylistItem.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index 817b42f503..68c1ba62d2 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -97,7 +97,7 @@ namespace osu.Game.Online.Rooms } public PlaylistItem(MultiplayerPlaylistItem item) - : this(new APIBeatmap { OnlineID = item.BeatmapID, StarRating = item.StarRating }) + : this(new APIBeatmap { OnlineID = item.BeatmapID, StarRating = item.StarRating, Checksum = item.BeatmapChecksum }) { ID = item.ID; OwnerID = item.OwnerID; From 1fc684c4301a5ecca466e402ff7385885c231798 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 14 Mar 2025 19:25:58 +0900 Subject: [PATCH 03/27] Add package to Android project too --- osu.Game.Tests.Android/osu.Game.Tests.Android.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj index b02425eadd..a8fc9536b9 100644 --- a/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj +++ b/osu.Game.Tests.Android/osu.Game.Tests.Android.csproj @@ -28,6 +28,7 @@ + From 05c57d7a3f07911c28117385b3ee6a3e698fa04a Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 18 Mar 2025 16:23:49 +0900 Subject: [PATCH 04/27] Add RNG seed --- osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs b/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs index 6885a579fa..4a80c71c3d 100644 --- a/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.cs +++ b/osu.Game.Tests/OnlinePlay/MultiplayerPlaylistItemTest.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 Bogus; using MessagePack; using NUnit.Framework; @@ -12,6 +13,12 @@ namespace osu.Game.Tests.OnlinePlay [TestFixture] public class MultiplayerPlaylistItemTest { + [SetUp] + public void Setup() + { + Randomizer.Seed = new Random(1337); + } + [Test] public void TestCloneMultiplayerPlaylistItem() { From 160dd686ea234657662035ba421988f5489d3504 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 18 Mar 2025 16:31:08 +0900 Subject: [PATCH 05/27] Add documentation regarding copying behaviour --- osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs | 6 ++++++ osu.Game/Online/Rooms/PlaylistItem.cs | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs index d4417f2de4..d0f806e561 100644 --- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs +++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs @@ -73,6 +73,9 @@ namespace osu.Game.Online.Rooms /// /// Creates a new from an API . /// + /// + /// This will create unique instances of the and arrays but NOT unique instances of the contained s. + /// public MultiplayerPlaylistItem(PlaylistItem item) { ID = item.ID; @@ -92,6 +95,9 @@ namespace osu.Game.Online.Rooms /// /// Creates a copy of this . /// + /// + /// This will create unique instances of the and arrays but NOT unique instances of the contained s. + /// public MultiplayerPlaylistItem Clone() => new MultiplayerPlaylistItem { ID = ID, diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index 68c1ba62d2..427f31fc64 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -96,6 +96,12 @@ namespace osu.Game.Online.Rooms Beatmap = beatmap; } + /// + /// Creates a new from a . + /// + /// + /// This will create unique instances of the and arrays but NOT unique instances of the contained s. + /// public PlaylistItem(MultiplayerPlaylistItem item) : this(new APIBeatmap { OnlineID = item.BeatmapID, StarRating = item.StarRating, Checksum = item.BeatmapChecksum }) { From 1d80d4d046ad58c09497ca9820ede4686286a84a Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 18 Mar 2025 16:45:33 +0900 Subject: [PATCH 06/27] Use `MemberwiseClone()` for shallow copy --- .../Online/Rooms/MultiplayerPlaylistItem.cs | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs index d0f806e561..f58a67294e 100644 --- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs +++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs @@ -98,20 +98,12 @@ namespace osu.Game.Online.Rooms /// /// This will create unique instances of the and arrays but NOT unique instances of the contained s. /// - public MultiplayerPlaylistItem Clone() => new MultiplayerPlaylistItem + public MultiplayerPlaylistItem Clone() { - ID = ID, - OwnerID = OwnerID, - BeatmapID = BeatmapID, - BeatmapChecksum = BeatmapChecksum, - RulesetID = RulesetID, - RequiredMods = RequiredMods.ToArray(), - AllowedMods = AllowedMods.ToArray(), - Expired = Expired, - PlaylistOrder = PlaylistOrder, - PlayedAt = PlayedAt, - StarRating = StarRating, - Freestyle = Freestyle, - }; + MultiplayerPlaylistItem clone = (MultiplayerPlaylistItem)MemberwiseClone(); + clone.RequiredMods = RequiredMods.ToArray(); + clone.AllowedMods = AllowedMods.ToArray(); + return clone; + } } } From ea757029f11e1c1bb7a3cd6ceb4a58f4031949eb Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 20 Mar 2025 16:36:35 +0900 Subject: [PATCH 07/27] Add package to iOS tests project --- osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj index da07373037..9f13b0587b 100644 --- a/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj +++ b/osu.Game.Tests.iOS/osu.Game.Tests.iOS.csproj @@ -29,6 +29,7 @@ + From bb8f8e8d8c709069d41338afb01206869dee4c99 Mon Sep 17 00:00:00 2001 From: sineplusx <112003827+sineplusx@users.noreply.github.com> Date: Wed, 26 Mar 2025 15:41:48 +0100 Subject: [PATCH 08/27] Use median instead of mean for automatic beatmap offset adjustment --- .../Rulesets/Scoring/HitEventExtensions.cs | 19 +++++++++++++++++++ .../PlayerSettings/BeatmapOffsetControl.cs | 4 ++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs index fed0c3b51b..da1ac9f2a1 100644 --- a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs +++ b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs @@ -71,6 +71,25 @@ namespace osu.Game.Rulesets.Scoring return timeOffsets.Average(); } + /// + /// Calculates the median hit offset/error for a sequence of s, where negative numbers mean the user hit too early on average. + /// + /// + /// A non-null value if unstable rate could be calculated, + /// and if unstable rate cannot be calculated due to being empty. + /// + public static double? CalculateMedianHitError(this IEnumerable hitEvents) + { + double[] timeOffsets = hitEvents.Where(AffectsUnstableRate).Select(ev => ev.TimeOffset).OrderBy(x => x).ToArray(); + + if (timeOffsets.Length == 0) + return null; + + int center = timeOffsets.Length / 2; + + return timeOffsets.Length % 2 == 0 ? (timeOffsets[center - 1] + timeOffsets[center]) / 2 : timeOffsets[center]; + } + public static bool AffectsUnstableRate(HitEvent e) => AffectsUnstableRate(e.HitObject, e.Result); public static bool AffectsUnstableRate(HitObject hitObject, HitResult result) => hitObject.HitWindows != HitWindows.Empty && result.IsHit(); diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index cef5884d39..ce474ed594 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -196,7 +196,7 @@ namespace osu.Game.Screens.Play.PlayerSettings var hitEvents = score.NewValue.HitEvents; - if (!(hitEvents.CalculateAverageHitError() is double average)) + if (!(hitEvents.CalculateMedianHitError() is double median)) return; referenceScoreContainer.Children = new Drawable[] @@ -226,7 +226,7 @@ namespace osu.Game.Screens.Play.PlayerSettings return; } - lastPlayAverage = average; + lastPlayAverage = median; lastPlayBeatmapOffset = Current.Value; LinkFlowContainer globalOffsetText; From 6452514066161dd6c38e533c5564f6c0249abb59 Mon Sep 17 00:00:00 2001 From: sineplusx <112003827+sineplusx@users.noreply.github.com> Date: Wed, 26 Mar 2025 17:22:57 +0100 Subject: [PATCH 09/27] Add comment --- osu.Game/Rulesets/Scoring/HitEventExtensions.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs index da1ac9f2a1..01d800a351 100644 --- a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs +++ b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs @@ -87,6 +87,7 @@ namespace osu.Game.Rulesets.Scoring int center = timeOffsets.Length / 2; + // Use average of the 2 central values if length is even return timeOffsets.Length % 2 == 0 ? (timeOffsets[center - 1] + timeOffsets[center]) / 2 : timeOffsets[center]; } From 77d73c5f50ef2a36877f0a1b33e0cdab356ead98 Mon Sep 17 00:00:00 2001 From: sineplusx <112003827+sineplusx@users.noreply.github.com> Date: Wed, 26 Mar 2025 17:23:19 +0100 Subject: [PATCH 10/27] Increase number of timed hits needed to activate button --- osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index ce474ed594..c2cd09c56f 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -210,7 +210,7 @@ namespace osu.Game.Screens.Play.PlayerSettings // affecting unstable rate here is used as a substitute of determining if a hit event represents a *timed* hit event, // i.e. an user input that the user had to *time to the track*, // i.e. one that it *makes sense to use* when doing anything with timing and offsets. - if (hitEvents.Count(HitEventExtensions.AffectsUnstableRate) < 10) + if (hitEvents.Count(HitEventExtensions.AffectsUnstableRate) < 50) { referenceScoreContainer.AddRange(new Drawable[] { From 4b8fe015e56614b1a98c3e2081ff9606d0d3bd9b Mon Sep 17 00:00:00 2001 From: sineplusx <112003827+sineplusx@users.noreply.github.com> Date: Wed, 26 Mar 2025 17:53:03 +0100 Subject: [PATCH 11/27] Apply median to `SessionAverageHitErrorTracker` --- osu.Game/Configuration/SessionAverageHitErrorTracker.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game/Configuration/SessionAverageHitErrorTracker.cs b/osu.Game/Configuration/SessionAverageHitErrorTracker.cs index cd21eb6fa8..49f7657f91 100644 --- a/osu.Game/Configuration/SessionAverageHitErrorTracker.cs +++ b/osu.Game/Configuration/SessionAverageHitErrorTracker.cs @@ -40,10 +40,10 @@ namespace osu.Game.Configuration if (newScore.Mods.Any(m => !m.UserPlayable || m is IHasNoTimedInputs)) return; - if (newScore.HitEvents.Count < 10) + if (newScore.HitEvents.Count < 50) return; - if (newScore.HitEvents.CalculateAverageHitError() is not double averageError) + if (newScore.HitEvents.CalculateMedianHitError() is not double medianError) return; // keep a sane maximum number of entries. @@ -51,7 +51,7 @@ namespace osu.Game.Configuration averageHitErrorHistory.RemoveAt(0); double globalOffset = configManager.Get(OsuSetting.AudioOffset); - averageHitErrorHistory.Add(new DataPoint(averageError, globalOffset)); + averageHitErrorHistory.Add(new DataPoint(medianError, globalOffset)); } public void ClearHistory() => averageHitErrorHistory.Clear(); From fa06643bb6c0aacde659640ae0a65c68ab9b0c61 Mon Sep 17 00:00:00 2001 From: sineplusx <112003827+sineplusx@users.noreply.github.com> Date: Sat, 29 Mar 2025 13:44:14 +0100 Subject: [PATCH 12/27] Use median for statistic display --- osu.Game/Screens/Ranking/Statistics/AverageHitError.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs b/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs index fb7107cc88..29df085c62 100644 --- a/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs +++ b/osu.Game/Screens/Ranking/Statistics/AverageHitError.cs @@ -8,18 +8,18 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Screens.Ranking.Statistics { /// - /// Displays the unstable rate statistic for a given play. + /// Displays the average hit error statistic for a given play. /// public partial class AverageHitError : SimpleStatisticItem { /// /// Creates and computes an statistic. /// - /// Sequence of s to calculate the unstable rate based on. + /// Sequence of s to calculate the average hit error based on. public AverageHitError(IEnumerable hitEvents) : base("Average Hit Error") { - Value = hitEvents.CalculateAverageHitError(); + Value = hitEvents.CalculateMedianHitError(); } protected override string DisplayValue(double? value) => value == null ? "(not available)" : $"{Math.Abs(value.Value):N2} ms {(value.Value < 0 ? "early" : "late")}"; From b3c578e5455c572e34e2def301ba657182747149 Mon Sep 17 00:00:00 2001 From: sineplusx <112003827+sineplusx@users.noreply.github.com> Date: Sat, 29 Mar 2025 13:45:39 +0100 Subject: [PATCH 13/27] Remove mean hit error calculation --- osu.Game/Rulesets/Scoring/HitEventExtensions.cs | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs index 01d800a351..39fc8b357b 100644 --- a/osu.Game/Rulesets/Scoring/HitEventExtensions.cs +++ b/osu.Game/Rulesets/Scoring/HitEventExtensions.cs @@ -54,23 +54,6 @@ namespace osu.Game.Rulesets.Scoring return result; } - /// - /// Calculates the average hit offset/error for a sequence of s, where negative numbers mean the user hit too early on average. - /// - /// - /// A non-null value if unstable rate could be calculated, - /// and if unstable rate cannot be calculated due to being empty. - /// - public static double? CalculateAverageHitError(this IEnumerable hitEvents) - { - double[] timeOffsets = hitEvents.Where(AffectsUnstableRate).Select(ev => ev.TimeOffset).ToArray(); - - if (timeOffsets.Length == 0) - return null; - - return timeOffsets.Average(); - } - /// /// Calculates the median hit offset/error for a sequence of s, where negative numbers mean the user hit too early on average. /// From 403b24ef9bb32d95dc3f701ee9fce8b217d16848 Mon Sep 17 00:00:00 2001 From: sineplusx <112003827+sineplusx@users.noreply.github.com> Date: Sat, 29 Mar 2025 20:43:59 +0100 Subject: [PATCH 14/27] Adapt `TestNotEnoughTimedHitEvents` with new minimum hit amount --- .../Gameplay/TestSceneBeatmapOffsetControl.cs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs index aa99b22701..92a10628ff 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapOffsetControl.cs @@ -50,21 +50,17 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("Set short reference score", () => { + // 50 events total. one of them (head circle) being timed / having hitwindows, rest having no hitwindows List hitEvents = [ - // 10 events total. one of them (head circle) being timed / having hitwindows, rest having no hitwindows new HitEvent(30, 1, HitResult.LargeTickHit, new SliderHeadCircle { ClassicSliderBehaviour = true }, null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), - new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null), ]; + for (int i = 0; i < 49; i++) + { + hitEvents.Add(new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null)); + } + foreach (var ev in hitEvents) ev.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); From 8de96201566175e3cc65dd9db3d61e66d2bf4285 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 1 Apr 2025 14:41:17 +0900 Subject: [PATCH 15/27] Isolate operation of multiplayer mod overlay --- .../Screens/OnlinePlay/Match/RoomSubScreen.cs | 26 +---- .../Match/MultiplayerUserModSelectOverlay.cs | 108 ++++++++++++++++++ .../Multiplayer/MultiplayerMatchSubScreen.cs | 36 ------ 3 files changed, 111 insertions(+), 59 deletions(-) create mode 100644 osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index 57e8aff151..c73a36617d 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Collections.Generic; using System.ComponentModel; using System.Linq; using osu.Framework.Allocation; @@ -27,6 +26,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Screens.Menu; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Screens.OnlinePlay.Multiplayer.Match; using osu.Game.Utils; using Container = osu.Framework.Graphics.Containers.Container; @@ -62,11 +62,6 @@ namespace osu.Game.Screens.OnlinePlay.Match private Sample? sampleStart; - /// - /// Any mods applied by/to the local user. - /// - protected readonly Bindable> UserMods = new Bindable>(Array.Empty()); - [Resolved(CanBeNull = true)] private IOverlayManager? overlayManager { get; set; } @@ -245,12 +240,7 @@ namespace osu.Game.Screens.OnlinePlay.Match } }; - LoadComponent(UserModsSelectOverlay = new RoomModSelectOverlay - { - SelectedItem = { BindTarget = SelectedItem }, - SelectedMods = { BindTarget = UserMods }, - IsValidMod = _ => false - }); + LoadComponent(UserModsSelectOverlay = new MultiplayerUserModSelectOverlay()); } protected override void LoadComplete() @@ -258,7 +248,6 @@ namespace osu.Game.Screens.OnlinePlay.Match base.LoadComplete(); SelectedItem.BindValueChanged(_ => updateSpecifics()); - UserMods.BindValueChanged(_ => updateSpecifics()); beatmapAvailabilityTracker.Availability.BindValueChanged(_ => updateSpecifics()); @@ -441,11 +430,6 @@ namespace osu.Game.Screens.OnlinePlay.Match ? rulesetInstance.AllMods.OfType().Where(m => ModUtils.IsValidFreeModForMatchType(m, Room.Type)).ToArray() : item.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); - // Remove any user mods that are no longer allowed. - Mod[] newUserMods = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray(); - if (!newUserMods.SequenceEqual(UserMods.Value)) - UserMods.Value = newUserMods; - // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info int beatmapId = GetGameplayBeatmap().OnlineID; var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", beatmapId); @@ -456,15 +440,11 @@ namespace osu.Game.Screens.OnlinePlay.Match Ruleset.Value = GetGameplayRuleset(); if (allowedMods.Length > 0) - { UserModsSection.Show(); - UserModsSelectOverlay.IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); - } else { UserModsSection.Hide(); UserModsSelectOverlay.Hide(); - UserModsSelectOverlay.IsValidMod = _ => false; } if (item.Freestyle) @@ -488,7 +468,7 @@ namespace osu.Game.Screens.OnlinePlay.Match UserStyleSection.Hide(); } - protected virtual APIMod[] GetGameplayMods() => UserMods.Value.Select(m => new APIMod(m)).Concat(SelectedItem.Value!.RequiredMods).ToArray(); + protected virtual APIMod[] GetGameplayMods() => SelectedItem.Value!.RequiredMods; protected virtual RulesetInfo GetGameplayRuleset() => Rulesets.GetRuleset(SelectedItem.Value!.RulesetID)!; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs new file mode 100644 index 0000000000..e5c447f038 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs @@ -0,0 +1,108 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Threading; +using osu.Game.Configuration; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; +using osu.Game.Screens.OnlinePlay.Match; +using osu.Game.Utils; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match +{ + public class MultiplayerUserModSelectOverlay : RoomModSelectOverlay + { + [Resolved] + private MultiplayerClient client { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + private ModSettingChangeTracker? modSettingChangeTracker; + private ScheduledDelegate? debouncedModSettingsUpdate; + + protected override void LoadComplete() + { + base.LoadComplete(); + + IsValidMod = _ => false; + + client.RoomUpdated += onRoomUpdated; + + SelectedItem.BindValueChanged(_ => updateSpecifics()); + SelectedMods.BindValueChanged(_ => updateSpecifics()); + SelectedMods.BindValueChanged(onSelectedModsChanged); + } + + private void onRoomUpdated() + { + if (client.Room == null) + return; + + SelectedItem.Value = new PlaylistItem(client.Room.CurrentPlaylistItem); + } + + private void onSelectedModsChanged(ValueChangedEvent> mods) + { + modSettingChangeTracker?.Dispose(); + + if (client.Room == null) + return; + + client.ChangeUserMods(mods.NewValue).FireAndForget(); + + modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); + modSettingChangeTracker.SettingChanged += _ => + { + // Debounce changes to mod settings so as to not thrash the network. + debouncedModSettingsUpdate?.Cancel(); + debouncedModSettingsUpdate = Scheduler.AddDelayed(() => + { + if (client.Room == null) + return; + + client.ChangeUserMods(SelectedMods.Value).FireAndForget(); + }, 500); + }; + } + + private void updateSpecifics() + { + if (client.Room == null || client.LocalUser == null) + return; + + MultiplayerPlaylistItem currentItem = client.Room.CurrentPlaylistItem; + Ruleset ruleset = rulesets.GetRuleset(client.LocalUser.RulesetId ?? currentItem.RulesetID)!.CreateInstance(); + Mod[] allowedMods = currentItem.Freestyle + ? ruleset.AllMods.OfType().Where(m => ModUtils.IsValidFreeModForMatchType(m, client.Room.Settings.MatchType)).ToArray() + : currentItem.AllowedMods.Select(m => m.ToMod(ruleset)).ToArray(); + + // Update the mod panels to reflect the ones which are valid for selection. + IsValidMod = allowedMods.Length > 0 + ? m => allowedMods.Any(a => a.GetType() == m.GetType()) + : _ => false; + + // Remove any mods that are no longer allowed. + Mod[] newUserMods = SelectedMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray(); + if (!newUserMods.SequenceEqual(SelectedMods.Value)) + SelectedMods.Value = newUserMods; + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (client.IsNotNull()) + client.RoomUpdated -= onRoomUpdated; + + modSettingChangeTracker?.Dispose(); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 08a469fa03..0cc033907f 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -1,7 +1,6 @@ // 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.Diagnostics; using System.Linq; using osu.Framework.Allocation; @@ -11,9 +10,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Screens; -using osu.Framework.Threading; using osu.Game.Beatmaps; -using osu.Game.Configuration; using osu.Game.Graphics.Cursor; using osu.Game.Online; using osu.Game.Online.API; @@ -23,7 +20,6 @@ using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Match.Components; @@ -64,7 +60,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer base.LoadComplete(); BeatmapAvailability.BindValueChanged(updateBeatmapAvailability, true); - UserMods.BindValueChanged(onUserModsChanged); client.LoadRequested += onLoadRequested; client.RoomUpdated += onRoomUpdated; @@ -306,35 +301,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer protected override void PartRoom() => client.LeaveRoom(); - private ModSettingChangeTracker? modSettingChangeTracker; - private ScheduledDelegate? debouncedModSettingsUpdate; - - private void onUserModsChanged(ValueChangedEvent> mods) - { - modSettingChangeTracker?.Dispose(); - - if (client.Room == null) - return; - - client.ChangeUserMods(mods.NewValue).FireAndForget(); - - modSettingChangeTracker = new ModSettingChangeTracker(mods.NewValue); - modSettingChangeTracker.SettingChanged += onModSettingsChanged; - } - - private void onModSettingsChanged(Mod mod) - { - // Debounce changes to mod settings so as to not thrash the network. - debouncedModSettingsUpdate?.Cancel(); - debouncedModSettingsUpdate = Scheduler.AddDelayed(() => - { - if (client.Room == null) - return; - - client.ChangeUserMods(UserMods.Value).FireAndForget(); - }, 500); - } - private void updateBeatmapAvailability(ValueChangedEvent availability) { if (client.Room == null) @@ -462,8 +428,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer client.RoomUpdated -= onRoomUpdated; client.LoadRequested -= onLoadRequested; } - - modSettingChangeTracker?.Dispose(); } public partial class AddItemButton : PurpleRoundedButton From 72efbbad2dec4565fa46003056a60d26b1d82c9f Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 1 Apr 2025 16:45:25 +0900 Subject: [PATCH 16/27] Remove inheritance on `RoomModSelectOverlay` --- .../Match/MultiplayerUserModSelectOverlay.cs | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs index e5c447f038..1ddcccc02c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs @@ -10,14 +10,15 @@ using osu.Framework.Threading; using osu.Game.Configuration; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; -using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Utils; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { - public class MultiplayerUserModSelectOverlay : RoomModSelectOverlay + public class MultiplayerUserModSelectOverlay : UserModSelectOverlay { [Resolved] private MultiplayerClient client { get; set; } = null!; @@ -28,26 +29,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private ModSettingChangeTracker? modSettingChangeTracker; private ScheduledDelegate? debouncedModSettingsUpdate; + public MultiplayerUserModSelectOverlay() + : base(OverlayColourScheme.Plum) + { + } + protected override void LoadComplete() { base.LoadComplete(); - IsValidMod = _ => false; - client.RoomUpdated += onRoomUpdated; - - SelectedItem.BindValueChanged(_ => updateSpecifics()); - SelectedMods.BindValueChanged(_ => updateSpecifics()); SelectedMods.BindValueChanged(onSelectedModsChanged); + + updateValidMods(); } - private void onRoomUpdated() - { - if (client.Room == null) - return; - - SelectedItem.Value = new PlaylistItem(client.Room.CurrentPlaylistItem); - } + private void onRoomUpdated() => Scheduler.AddOnce(updateValidMods); private void onSelectedModsChanged(ValueChangedEvent> mods) { @@ -73,7 +70,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match }; } - private void updateSpecifics() + private void updateValidMods() { if (client.Room == null || client.LocalUser == null) return; @@ -95,6 +92,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match SelectedMods.Value = newUserMods; } + protected override IReadOnlyList ComputeActiveMods() + { + if (client.Room == null || client.LocalUser == null) + return []; + + MultiplayerPlaylistItem currentItem = client.Room.CurrentPlaylistItem; + Ruleset ruleset = rulesets.GetRuleset(client.LocalUser.RulesetId ?? currentItem.RulesetID)!.CreateInstance(); + return currentItem.RequiredMods.Select(m => m.ToMod(ruleset)).Concat(base.ComputeActiveMods()).ToArray(); + } + protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); From fbc8469fc402c57e4fb146e13d162fab8030c119 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 1 Apr 2025 17:10:47 +0900 Subject: [PATCH 17/27] Partial class --- .../Multiplayer/Match/MultiplayerUserModSelectOverlay.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs index 1ddcccc02c..075b664028 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs @@ -18,7 +18,7 @@ using osu.Game.Utils; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { - public class MultiplayerUserModSelectOverlay : UserModSelectOverlay + public partial class MultiplayerUserModSelectOverlay : UserModSelectOverlay { [Resolved] private MultiplayerClient client { get; set; } = null!; From 452f36d77ab337f5150f84ac735a365000124def Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Tue, 1 Apr 2025 17:55:19 +0900 Subject: [PATCH 18/27] Fix active mods not updated --- .../Multiplayer/Match/MultiplayerUserModSelectOverlay.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs index 075b664028..c66e1a906c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs @@ -90,6 +90,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Mod[] newUserMods = SelectedMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray(); if (!newUserMods.SequenceEqual(SelectedMods.Value)) SelectedMods.Value = newUserMods; + + ActiveMods.Value = ComputeActiveMods(); } protected override IReadOnlyList ComputeActiveMods() From aa58fa58cb6f2d5a3679a60e5f8fb5f44fd7a8e0 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 2 Apr 2025 17:59:52 +0900 Subject: [PATCH 19/27] Add deduping for active mods, add documentation --- .../Multiplayer/Match/MultiplayerUserModSelectOverlay.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs index c66e1a906c..692ef0fd2f 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs @@ -91,7 +91,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match if (!newUserMods.SequenceEqual(SelectedMods.Value)) SelectedMods.Value = newUserMods; - ActiveMods.Value = ComputeActiveMods(); + // The active mods include the playlist item's required mods which change separately from the selected mods. + IReadOnlyList newActiveMods = ComputeActiveMods(); + if (!newActiveMods.SequenceEqual(ActiveMods.Value)) + ActiveMods.Value = ComputeActiveMods(); } protected override IReadOnlyList ComputeActiveMods() From d0de8e908d4cc64f8d9ebd4b155f37f9fdaf6214 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 2 Apr 2025 18:27:16 +0900 Subject: [PATCH 20/27] Fix duplicate `ComputeActiveMods()` call --- .../Multiplayer/Match/MultiplayerUserModSelectOverlay.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs index 692ef0fd2f..dc443f595b 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs @@ -94,7 +94,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match // The active mods include the playlist item's required mods which change separately from the selected mods. IReadOnlyList newActiveMods = ComputeActiveMods(); if (!newActiveMods.SequenceEqual(ActiveMods.Value)) - ActiveMods.Value = ComputeActiveMods(); + ActiveMods.Value = newActiveMods; } protected override IReadOnlyList ComputeActiveMods() From 56169d7ac4cb641e22d3f3abfb6bfcca7e10e100 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 2 Apr 2025 19:58:31 +0900 Subject: [PATCH 21/27] Add failing test --- .../TestSceneMultiplayerMatchSubScreen.cs | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index e51ea12e83..14e6a67d3a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.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.Linq; using NUnit.Framework; using osu.Framework.Allocation; @@ -336,6 +337,61 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("button hidden", () => this.ChildrenOfType().Single().ChangeSettingsButton.Alpha, () => Is.EqualTo(0)); } + [Test] + public void TestUserModSelectUpdatesWhenNotVisible() + { + AddStep("add playlist item", () => + { + room.Playlist = + [ + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID, + AllowedMods = [new APIMod(new OsuModFlashlight())] + } + ]; + }); + + ClickButtonWhenEnabled(); + AddUntilStep("wait for join", () => RoomJoined); + + // 1. Open the mod select overlay and enable flashlight + + ClickButtonWhenEnabled(); + AddUntilStep("mod select contents loaded", () => this.ChildrenOfType().Any() && this.ChildrenOfType().All(col => col.IsLoaded && col.ItemsLoaded)); + AddStep("click flashlight panel", () => + { + ModPanel panel = this.ChildrenOfType().Single(p => p.Mod is OsuModFlashlight); + InputManager.MoveMouseTo(panel); + InputManager.Click(MouseButton.Left); + }); + AddUntilStep("flashlight mod enabled", () => MultiplayerClient.ClientRoom!.Users[0].Mods.Any()); + + // 2. Close the mod select overlay, edit the playlist to disable allowed mods, and then edit it again to re-enable allowed mods. + + AddStep("close mod select overlay", () => this.ChildrenOfType().Single().Hide()); + AddUntilStep("mod select overlay not present", () => !this.ChildrenOfType().Single().IsPresent); + AddStep("disable allowed mods", () => MultiplayerClient.EditPlaylistItem(new MultiplayerPlaylistItem(new PlaylistItem(MultiplayerClient.ServerRoom!.Playlist[0]) + { + AllowedMods = [] + }))); + // This would normally be done as part of the above operation with an actual server. + AddStep("disable user mods", () => MultiplayerClient.ChangeUserMods(API.LocalUser.Value.OnlineID, Array.Empty())); + AddUntilStep("flashlight mod disabled", () => !MultiplayerClient.ClientRoom!.Users[0].Mods.Any()); + AddStep("re-enable allowed mods", () => MultiplayerClient.EditPlaylistItem(new MultiplayerPlaylistItem(new PlaylistItem(MultiplayerClient.ServerRoom!.Playlist[0]) + { + AllowedMods = [new APIMod(new OsuModFlashlight())] + }))); + AddAssert("flashlight mod still disabled", () => !MultiplayerClient.ClientRoom!.Users[0].Mods.Any()); + + // 3. Open the mod select overlay, check that the flashlight mod panel is deactivated. + + ClickButtonWhenEnabled(); + AddUntilStep("mod select contents loaded", () => this.ChildrenOfType().Any() && this.ChildrenOfType().All(col => col.IsLoaded && col.ItemsLoaded)); + AddAssert("flashlight mod still disabled", () => !MultiplayerClient.ClientRoom!.Users[0].Mods.Any()); + AddAssert("flashlight mod panel not activated", () => !this.ChildrenOfType().Single(p => p.Mod is OsuModFlashlight).Active.Value); + } + private partial class TestMultiplayerMatchSubScreen : MultiplayerMatchSubScreen { [Resolved(canBeNull: true)] From f120684b145f5f6b9892eca1566f22b13ba6f210 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 2 Apr 2025 20:02:39 +0900 Subject: [PATCH 22/27] Fix skipped updates leading to incorrect validation --- .../Multiplayer/Match/MultiplayerUserModSelectOverlay.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs index dc443f595b..8463a4720c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs @@ -44,7 +44,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match updateValidMods(); } - private void onRoomUpdated() => Scheduler.AddOnce(updateValidMods); + private void onRoomUpdated() + { + // Importantly, this is not scheduled because the client must not skip intermediate server states to validate the allowed mods. + updateValidMods(); + } private void onSelectedModsChanged(ValueChangedEvent> mods) { From dbd2fa63cddcaa5a52be189c0b7b23007c9fe0a7 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 3 Apr 2025 14:10:12 +0900 Subject: [PATCH 23/27] Fix letterbox showing above playfield border Closes #32652. No comment. --- osu.Game/Screens/Play/Player.cs | 41 ++++++++++++++++++++------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index a738a40993..612d66a896 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -135,6 +135,8 @@ namespace osu.Game.Screens.Play public BreakOverlay BreakOverlay; + private LetterboxOverlay letterboxOverlay; + /// /// Whether the gameplay is currently in a break. /// @@ -277,6 +279,12 @@ namespace osu.Game.Screens.Play var rulesetSkinProvider = new RulesetSkinProvidingContainer(ruleset, playableBeatmap, Beatmap.Value.Skin); GameplayClockContainer.Add(new GameplayScrollWheelHandling()); + // needs to exist in frame stable content, but is used by underlay layers so make sure assigned early. + breakTracker = new BreakTracker(DrawableRuleset.GameplayStartTime, ScoreProcessor) + { + Breaks = Beatmap.Value.Beatmap.Breaks + }; + // load the skinning hierarchy first. // this is intentionally done in two stages to ensure things are in a loaded state before exposing the ruleset to skin sources. GameplayClockContainer.Add(rulesetSkinProvider); @@ -292,7 +300,7 @@ namespace osu.Game.Screens.Play Children = new[] { // underlay and gameplay should have access to the skinning sources. - createUnderlayComponents(), + createUnderlayComponents(Beatmap.Value), createGameplayComponents(Beatmap.Value) } }, @@ -335,10 +343,13 @@ namespace osu.Game.Screens.Play dependencies.CacheAs(DrawableRuleset.FrameStableClock); dependencies.CacheAs(DrawableRuleset.FrameStableClock); + letterboxOverlay.Clock = DrawableRuleset.FrameStableClock; + letterboxOverlay.ProcessCustomClock = false; + // add the overlay components as a separate step as they proxy some elements from the above underlay/gameplay components. // also give the overlays the ruleset skin provider to allow rulesets to potentially override HUD elements (used to disable combo counters etc.) // we may want to limit this in the future to disallow rulesets from outright replacing elements the user expects to be there. - failAnimationContainer.Add(createOverlayComponents(Beatmap.Value)); + failAnimationContainer.Add(createOverlayComponents()); if (!DrawableRuleset.AllowGameplayOverlays) { @@ -409,14 +420,22 @@ namespace osu.Game.Screens.Play protected virtual GameplayClockContainer CreateGameplayClockContainer(WorkingBeatmap beatmap, double gameplayStart) => new MasterGameplayClockContainer(beatmap, gameplayStart); - private Drawable createUnderlayComponents() + private Drawable createUnderlayComponents(WorkingBeatmap working) { var container = new Container { RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - DimmableStoryboard = new DimmableStoryboard(GameplayState.Storyboard, GameplayState.Mods) { RelativeSizeAxes = Axes.Both }, + DimmableStoryboard = new DimmableStoryboard(GameplayState.Storyboard, GameplayState.Mods) + { + RelativeSizeAxes = Axes.Both + }, + letterboxOverlay = new LetterboxOverlay + { + BreakTracker = breakTracker, + Alpha = working.Beatmap.LetterboxInBreaks ? 1 : 0, + }, new KiaiGameplayFountains(), }, }; @@ -434,15 +453,12 @@ namespace osu.Game.Screens.Play ScoreProcessor, HealthProcessor, new ComboEffects(ScoreProcessor), - breakTracker = new BreakTracker(DrawableRuleset.GameplayStartTime, ScoreProcessor) - { - Breaks = working.Beatmap.Breaks - } + breakTracker, }), } }; - private Drawable createOverlayComponents(IWorkingBeatmap working) + private Drawable createOverlayComponents() { var container = new Container { @@ -450,13 +466,6 @@ namespace osu.Game.Screens.Play Children = new[] { DimmableStoryboard.OverlayLayerContainer.CreateProxy(), - new LetterboxOverlay - { - Clock = DrawableRuleset.FrameStableClock, - ProcessCustomClock = false, - BreakTracker = breakTracker, - Alpha = working.Beatmap.LetterboxInBreaks ? 1 : 0, - }, HUDOverlay = new HUDOverlay(DrawableRuleset, GameplayState.Mods, Configuration.AlwaysShowLeaderboard) { HoldToQuit = From 6ef3bf50e3977e9275b2054210753d6bb074453a Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 3 Apr 2025 14:33:52 +0900 Subject: [PATCH 24/27] Avoid writing out team acronyms to JSON As proposed in https://github.com/ppy/osu/discussions/32626. --- osu.Game.Tournament/Models/TournamentMatch.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game.Tournament/Models/TournamentMatch.cs b/osu.Game.Tournament/Models/TournamentMatch.cs index 0a700eb4d6..1d91febd1a 100644 --- a/osu.Game.Tournament/Models/TournamentMatch.cs +++ b/osu.Game.Tournament/Models/TournamentMatch.cs @@ -19,6 +19,7 @@ namespace osu.Game.Tournament.Models { public int ID; + [JsonIgnore] public List Acronyms { get From 9a4371a81748b821ae452f93c52763043d670097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 3 Apr 2025 10:03:06 +0200 Subject: [PATCH 25/27] Add `[JsonIgnore]` to `MultiplayerRoom.CurrentPlaylistItem` The lack of this is currently failing a unit test on `osu-server-spectator` current master: https://github.com/ppy/osu-server-spectator/actions/runs/14193158383/job/39762243965#step:4:28 I don't think the failure actually matters because I don't think we're using json serialisation on spectator server side anywhere (used to for iOS at least, but I don't think we do anymore?), but probably better to be safe than sorry. --- osu.Game/Online/Multiplayer/MultiplayerRoom.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs index db1722af8c..3c02565fa1 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs @@ -85,6 +85,7 @@ namespace osu.Game.Online.Multiplayer /// Retrieves the active as determined by the room's current settings. /// [IgnoreMember] + [JsonIgnore] public MultiplayerPlaylistItem CurrentPlaylistItem => Playlist.Single(item => item.ID == Settings.PlaylistItemId); /// From bb8a9c83453723cd3d4ad724fd733e83467b5c89 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 3 Apr 2025 17:41:13 +0900 Subject: [PATCH 26/27] Fix argon reverse arrow animating weirdly after hit --- osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs index 1fbdbafec4..bb5499b1a5 100644 --- a/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs +++ b/osu.Game.Rulesets.Osu/Skinning/Argon/ArgonReverseArrow.cs @@ -85,9 +85,12 @@ namespace osu.Game.Rulesets.Osu.Skinning.Argon { double animDuration = Math.Min(300, drawableRepeat.HitObject.SpanDuration); Scale = new Vector2(Interpolation.ValueAt(Time.Current, 1, 1.5f, drawableRepeat.HitStateUpdateTime, drawableRepeat.HitStateUpdateTime + animDuration, Easing.Out)); + + // When hit, don't animate further. This avoids a scale being applied on a scale and looking very weird. + return; } - else - Scale = Vector2.One; + + Scale = Vector2.One; const float move_distance = -12; const float scale_amount = 1.3f; From 332b08160388cc0eaef2d22f5197ce9e5ee910ba Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 3 Apr 2025 18:55:45 +0900 Subject: [PATCH 27/27] Rename some variables --- .../Play/PlayerSettings/BeatmapOffsetControl.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs index c2cd09c56f..23ccb3311b 100644 --- a/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs +++ b/osu.Game/Screens/Play/PlayerSettings/BeatmapOffsetControl.cs @@ -63,11 +63,11 @@ namespace osu.Game.Screens.Play.PlayerSettings [Resolved] private IGameplayClock? gameplayClock { get; set; } - private double lastPlayAverage; + private double lastPlayMedian; private double lastPlayBeatmapOffset; private HitEventTimingDistributionGraph? lastPlayGraph; - private SettingsButton? useAverageButton; + private SettingsButton? calibrateFromLastPlayButton; private IDisposable? beatmapOffsetSubscription; @@ -226,7 +226,7 @@ namespace osu.Game.Screens.Play.PlayerSettings return; } - lastPlayAverage = median; + lastPlayMedian = median; lastPlayBeatmapOffset = Current.Value; LinkFlowContainer globalOffsetText; @@ -239,7 +239,7 @@ namespace osu.Game.Screens.Play.PlayerSettings Height = 50, }, new AverageHitError(hitEvents), - useAverageButton = new SettingsButton + calibrateFromLastPlayButton = new SettingsButton { Text = BeatmapOffsetControlStrings.CalibrateUsingLastPlay, Action = () => @@ -247,7 +247,7 @@ namespace osu.Game.Screens.Play.PlayerSettings if (Current.Disabled) return; - Current.Value = lastPlayBeatmapOffset - lastPlayAverage; + Current.Value = lastPlayBeatmapOffset - lastPlayMedian; lastAppliedScore.Value = ReferenceScore.Value; }, }, @@ -281,8 +281,8 @@ namespace osu.Game.Screens.Play.PlayerSettings bool allow = allowOffsetAdjust; - if (useAverageButton != null) - useAverageButton.Enabled.Value = allow && !Precision.AlmostEquals(lastPlayAverage, adjustmentSinceLastPlay, Current.Precision / 2); + if (calibrateFromLastPlayButton != null) + calibrateFromLastPlayButton.Enabled.Value = allow && !Precision.AlmostEquals(lastPlayMedian, adjustmentSinceLastPlay, Current.Precision / 2); Current.Disabled = !allow; }