From ef0cf5143d1d14eb280240f62bc3d88e455182a5 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 4 Apr 2025 13:20:48 +0900 Subject: [PATCH 01/61] Allow specifying mod compatibility with freestyle --- .../Mods/ManiaModFadeIn.cs | 3 + osu.Game.Tests/Mods/ModUtilsTest.cs | 101 +++++++++++++++++- osu.Game/Rulesets/Mods/Mod.cs | 29 +++-- .../Rulesets/Mods/ModAccuracyChallenge.cs | 2 + osu.Game/Rulesets/Mods/ModDaycore.cs | 1 + osu.Game/Rulesets/Mods/ModDoubleTime.cs | 1 + osu.Game/Rulesets/Mods/ModEasy.cs | 1 + osu.Game/Rulesets/Mods/ModFlashlight.cs | 1 + osu.Game/Rulesets/Mods/ModHalfTime.cs | 1 + osu.Game/Rulesets/Mods/ModHardRock.cs | 1 + osu.Game/Rulesets/Mods/ModHidden.cs | 1 + osu.Game/Rulesets/Mods/ModMuted.cs | 1 + osu.Game/Rulesets/Mods/ModNightcore.cs | 1 + osu.Game/Rulesets/Mods/ModNoFail.cs | 1 + osu.Game/Rulesets/Mods/ModPerfect.cs | 1 + osu.Game/Rulesets/Mods/ModSuddenDeath.cs | 1 + osu.Game/Utils/ModUtils.cs | 22 ++++ 17 files changed, 157 insertions(+), 12 deletions(-) diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs index 54a0b8f36d..abcabf3826 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs @@ -15,6 +15,9 @@ namespace osu.Game.Rulesets.Mania.Mods public override LocalisableString Description => @"Keys appear out of nowhere!"; public override double ScoreMultiplier => 1; + // Ideally we'd allow this, but it's not easy to handle due to the change in acronym from the base class. + public override bool ValidForFreestyle => false; + public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(ManiaModHidden), diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index 2964ca9396..3b4206f5c5 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -2,14 +2,19 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using Moq; using NUnit.Framework; using osu.Framework.Localisation; using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Taiko; using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Utils; @@ -274,6 +279,34 @@ namespace osu.Game.Tests.Mods }, }; + private static readonly object[] invalid_freestyle_mod_test_scenarios = + { + // system mod. + new object[] + { + new Mod[] { new OsuModHidden(), new OsuModTouchDevice() }, + new[] { typeof(OsuModTouchDevice) } + }, + // multi mod. + new object[] + { + new Mod[] { new MultiMod(new OsuModSuddenDeath(), new OsuModPerfect()) }, + new[] { typeof(MultiMod) } + }, + // invalid freestyle mod. + new object[] + { + new Mod[] { new OsuModHidden(), new OsuModNoScope(), new InvalidFreestyleMod() }, + new[] { typeof(OsuModNoScope), typeof(InvalidFreestyleMod) } + }, + // valid pair. + new object[] + { + new Mod[] { new OsuModHidden(), new OsuModHardRock() }, + Array.Empty() + }, + }; + [TestCaseSource(nameof(invalid_mod_test_scenarios))] public void TestInvalidModScenarios(Mod[] inputMods, Type[] expectedInvalid) { @@ -300,6 +333,19 @@ namespace osu.Game.Tests.Mods Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); } + [TestCaseSource(nameof(invalid_freestyle_mod_test_scenarios))] + public void TestInvalidFreestyleModScenarios(Mod[] inputMods, Type[] expectedInvalid) + { + bool isValid = ModUtils.CheckValidModsForFreestyle(inputMods, out var invalid); + + Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0)); + + if (isValid) + Assert.IsNull(invalid); + else + Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); + } + [TestCaseSource(nameof(invalid_free_mod_test_scenarios))] public void TestInvalidFreeModScenarios(Mod[] inputMods, Type[] expectedInvalid) { @@ -377,6 +423,51 @@ namespace osu.Game.Tests.Mods Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModTouchDevice(), MatchType.HeadToHead)); } + [Test] + public void TestFreestyleModValidity() + { + Assert.IsTrue(ModUtils.IsValidModForFreestyleMode(new OsuModHardRock(), true)); + Assert.IsTrue(ModUtils.IsValidModForFreestyleMode(new OsuModHardRock(), false)); + Assert.IsTrue(ModUtils.IsValidModForFreestyleMode(new OsuModBarrelRoll(), false)); + Assert.IsFalse(ModUtils.IsValidModForFreestyleMode(new OsuModBarrelRoll(), true)); + } + + [Test] + public void TestFreestyleRulesetCompatibility() + { + Mod[] osuMods = ModUtils.FlattenMods(new OsuRuleset().CreateAllMods()).Where(m => m.ValidForFreestyle).ToArray(); + Ruleset[] otherRulesets = [new TaikoRuleset(), new CatchRuleset(), new ManiaRuleset()]; + + EqualityComparer validModComparer = EqualityComparer.Create((a, b) => + { + if (a == null || b == null) + return false; + + Type aType = a.GetType(); + + while (aType != typeof(Mod)) + { + if (aType.IsInstanceOfType(b)) + return string.Equals(a.Acronym, b.Acronym, StringComparison.Ordinal); + + aType = aType.BaseType!; + } + + return false; + }); + + Assert.Multiple(() => + { + foreach (var ruleset in otherRulesets) + { + Mod[] mods = ModUtils.FlattenMods(ruleset.CreateAllMods()).Where(m => m.ValidForFreestyle).ToArray(); + + foreach (var mod in mods) + Assert.That(osuMods, Contains.Item(mod).Using(validModComparer)); + } + }); + } + public abstract class CustomMod1 : Mod, IModCompatibilitySpecification { } @@ -385,7 +476,7 @@ namespace osu.Game.Tests.Mods { } - public class InvalidMultiplayerMod : Mod + private class InvalidMultiplayerMod : Mod { public override string Name => string.Empty; public override LocalisableString Description => string.Empty; @@ -406,14 +497,14 @@ namespace osu.Game.Tests.Mods public override bool ValidForMultiplayerAsFreeMod => false; } - public class EditableMod : Mod + public class InvalidFreestyleMod : Mod { public override string Name => string.Empty; public override LocalisableString Description => string.Empty; + public override double ScoreMultiplier => 1; public override string Acronym => string.Empty; - public override double ScoreMultiplier => Multiplier; - - public double Multiplier = 1; + public override bool HasImplementation => true; + public override bool ValidForFreestyle => false; } public interface IModCompatibilitySpecification diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index f23f16fd44..30e6b4762b 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -105,32 +105,47 @@ namespace osu.Game.Rulesets.Mods /// /// /// - /// is valid for multiplayer. + /// is valid for multiplayer. /// - /// is valid for multiplayer as long as it is a required mod, + /// is valid for multiplayer as long as it is a required mod, /// as that ensures the same duration of gameplay for all users in the room. /// /// - /// is not valid for multiplayer, as it leads to varying + /// is not valid for multiplayer, as it leads to varying /// gameplay duration depending on how the users in the room play. /// - /// is not valid for multiplayer. + /// is not valid for multiplayer. /// /// [JsonIgnore] public virtual bool ValidForMultiplayer => true; + /// + /// Whether this mod can be specified as a mod (either "required" or "allowed") on freestyle playlist items, + /// indicating that all rulesets contain an implementation of this mod. + /// + /// + /// + /// is valid as a freestyle mod. + /// + /// OsuModNoScope is not valid as a freestyle mod, + /// as it is only implemented in the osu! ruleset. + /// + /// + /// + public virtual bool ValidForFreestyle => false; + /// /// Whether this mod can be specified as a "free" or "allowed" mod in a multiplayer context. /// /// /// - /// is valid for multiplayer as a free mod. + /// is valid for multiplayer as a free mod. /// - /// is not valid for multiplayer as a free mod, + /// is not valid for multiplayer as a free mod, /// as it could to varying gameplay duration between users in the room depending on whether they picked it. /// - /// is not valid for multiplayer as a free mod. + /// is not valid for multiplayer as a free mod. /// /// [JsonIgnore] diff --git a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs index db16e771d3..182dc31987 100644 --- a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs +++ b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs @@ -34,6 +34,8 @@ namespace osu.Game.Rulesets.Mods public override bool Ranked => true; + public override bool ValidForFreestyle => true; + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription { get diff --git a/osu.Game/Rulesets/Mods/ModDaycore.cs b/osu.Game/Rulesets/Mods/ModDaycore.cs index 359f8a950c..b7646a7463 100644 --- a/osu.Game/Rulesets/Mods/ModDaycore.cs +++ b/osu.Game/Rulesets/Mods/ModDaycore.cs @@ -18,6 +18,7 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyReduction; public override LocalisableString Description => "Whoaaaaa..."; public override bool Ranked => UsesDefaultConfiguration; + public override bool ValidForFreestyle => true; [SettingSource("Speed decrease", "The actual decrease to apply", SettingControlType = typeof(MultiplierSettingsSlider))] public override BindableNumber SpeedChange { get; } = new BindableDouble(0.75) diff --git a/osu.Game/Rulesets/Mods/ModDoubleTime.cs b/osu.Game/Rulesets/Mods/ModDoubleTime.cs index fd5120a767..253fbed074 100644 --- a/osu.Game/Rulesets/Mods/ModDoubleTime.cs +++ b/osu.Game/Rulesets/Mods/ModDoubleTime.cs @@ -19,6 +19,7 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyIncrease; public override LocalisableString Description => "Zoooooooooom..."; public override bool Ranked => SpeedChange.IsDefault; + public override bool ValidForFreestyle => true; [SettingSource("Speed increase", "The actual increase to apply", SettingControlType = typeof(MultiplierSettingsSlider))] public override BindableNumber SpeedChange { get; } = new BindableDouble(1.5) diff --git a/osu.Game/Rulesets/Mods/ModEasy.cs b/osu.Game/Rulesets/Mods/ModEasy.cs index da43a6b294..a24e242bca 100644 --- a/osu.Game/Rulesets/Mods/ModEasy.cs +++ b/osu.Game/Rulesets/Mods/ModEasy.cs @@ -17,6 +17,7 @@ namespace osu.Game.Rulesets.Mods public override double ScoreMultiplier => 0.5; public override Type[] IncompatibleMods => new[] { typeof(ModHardRock), typeof(ModDifficultyAdjust) }; public override bool Ranked => UsesDefaultConfiguration; + public override bool ValidForFreestyle => true; public virtual void ReadFromDifficulty(BeatmapDifficulty difficulty) { diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index 64c193d25f..d5d526c027 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -37,6 +37,7 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyIncrease; public override LocalisableString Description => "Restricted view area."; public override bool Ranked => UsesDefaultConfiguration; + public override bool ValidForFreestyle => true; [SettingSource("Flashlight size", "Multiplier applied to the default flashlight size.")] public abstract BindableFloat SizeMultiplier { get; } diff --git a/osu.Game/Rulesets/Mods/ModHalfTime.cs b/osu.Game/Rulesets/Mods/ModHalfTime.cs index efdf0d6358..563730c84e 100644 --- a/osu.Game/Rulesets/Mods/ModHalfTime.cs +++ b/osu.Game/Rulesets/Mods/ModHalfTime.cs @@ -19,6 +19,7 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyReduction; public override LocalisableString Description => "Less zoom..."; public override bool Ranked => SpeedChange.IsDefault; + public override bool ValidForFreestyle => true; [SettingSource("Speed decrease", "The actual decrease to apply", SettingControlType = typeof(MultiplierSettingsSlider))] public override BindableNumber SpeedChange { get; } = new BindableDouble(0.75) diff --git a/osu.Game/Rulesets/Mods/ModHardRock.cs b/osu.Game/Rulesets/Mods/ModHardRock.cs index 1e99891b99..6af22cf516 100644 --- a/osu.Game/Rulesets/Mods/ModHardRock.cs +++ b/osu.Game/Rulesets/Mods/ModHardRock.cs @@ -18,6 +18,7 @@ namespace osu.Game.Rulesets.Mods public override LocalisableString Description => "Everything just got a bit harder..."; public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModDifficultyAdjust) }; public override bool Ranked => UsesDefaultConfiguration; + public override bool ValidForFreestyle => true; protected const float ADJUST_RATIO = 1.4f; diff --git a/osu.Game/Rulesets/Mods/ModHidden.cs b/osu.Game/Rulesets/Mods/ModHidden.cs index 2915cb9bea..9cacf16ee7 100644 --- a/osu.Game/Rulesets/Mods/ModHidden.cs +++ b/osu.Game/Rulesets/Mods/ModHidden.cs @@ -15,6 +15,7 @@ namespace osu.Game.Rulesets.Mods public override IconUsage? Icon => OsuIcon.ModHidden; public override ModType Type => ModType.DifficultyIncrease; public override bool Ranked => UsesDefaultConfiguration; + public override bool ValidForFreestyle => true; public virtual void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { diff --git a/osu.Game/Rulesets/Mods/ModMuted.cs b/osu.Game/Rulesets/Mods/ModMuted.cs index 7aefefc58d..1158172260 100644 --- a/osu.Game/Rulesets/Mods/ModMuted.cs +++ b/osu.Game/Rulesets/Mods/ModMuted.cs @@ -26,6 +26,7 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.Fun; public override double ScoreMultiplier => 1; public override bool Ranked => true; + public override bool ValidForFreestyle => true; } public abstract class ModMuted : ModMuted, IApplicableToDrawableRuleset, IApplicableToTrack, IApplicableToScoreProcessor diff --git a/osu.Game/Rulesets/Mods/ModNightcore.cs b/osu.Game/Rulesets/Mods/ModNightcore.cs index bb18940f8c..ab650348d5 100644 --- a/osu.Game/Rulesets/Mods/ModNightcore.cs +++ b/osu.Game/Rulesets/Mods/ModNightcore.cs @@ -29,6 +29,7 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyIncrease; public override LocalisableString Description => "Uguuuuuuuu..."; public override bool Ranked => UsesDefaultConfiguration; + public override bool ValidForFreestyle => true; [SettingSource("Speed increase", "The actual increase to apply", SettingControlType = typeof(MultiplierSettingsSlider))] public override BindableNumber SpeedChange { get; } = new BindableDouble(1.5) diff --git a/osu.Game/Rulesets/Mods/ModNoFail.cs b/osu.Game/Rulesets/Mods/ModNoFail.cs index 1aaef8eac4..f65cdb80d7 100644 --- a/osu.Game/Rulesets/Mods/ModNoFail.cs +++ b/osu.Game/Rulesets/Mods/ModNoFail.cs @@ -21,6 +21,7 @@ namespace osu.Game.Rulesets.Mods public override double ScoreMultiplier => 0.5; public override Type[] IncompatibleMods => new[] { typeof(ModFailCondition), typeof(ModCinema) }; public override bool Ranked => UsesDefaultConfiguration; + public override bool ValidForFreestyle => true; private readonly Bindable showHealthBar = new Bindable(); diff --git a/osu.Game/Rulesets/Mods/ModPerfect.cs b/osu.Game/Rulesets/Mods/ModPerfect.cs index 5bedf443da..9d46fedfe5 100644 --- a/osu.Game/Rulesets/Mods/ModPerfect.cs +++ b/osu.Game/Rulesets/Mods/ModPerfect.cs @@ -20,6 +20,7 @@ namespace osu.Game.Rulesets.Mods public override double ScoreMultiplier => 1; public override LocalisableString Description => "SS or quit."; public override bool Ranked => true; + public override bool ValidForFreestyle => true; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(ModSuddenDeath), typeof(ModAccuracyChallenge) }).ToArray(); diff --git a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs index d07ff6ce87..48925913c5 100644 --- a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs +++ b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs @@ -20,6 +20,7 @@ namespace osu.Game.Rulesets.Mods public override LocalisableString Description => "Miss and fail."; public override double ScoreMultiplier => 1; public override bool Ranked => true; + public override bool ValidForFreestyle => true; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModPerfect)).ToArray(); diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index ac24bf2130..e4c1d5c1fc 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -127,6 +127,15 @@ namespace osu.Game.Utils return checkValid(mods, m => m.HasImplementation, out invalidMods); } + /// + /// Checks that all s in a combination are valid as "required mods" in a freestyle room. + /// + /// The mods to check. + /// Invalid mods, if any were found. Will be null if all mods were valid. + /// Whether the input mods were all valid. If false, will contain all invalid entries. + public static bool CheckValidModsForFreestyle(IEnumerable mods, [NotNullWhen(false)] out List? invalidMods) + => checkValid(mods, m => m.Type != ModType.System && m.HasImplementation && m.ValidForFreestyle, out invalidMods); + /// /// Checks that all s in a combination are valid as "required mods" in a multiplayer match session. /// @@ -333,5 +342,18 @@ namespace osu.Game.Utils return mod.ValidForMultiplayerAsFreeMod; } } + + /// + /// Determines whether a mod can be applied in the given freestyle mode. + /// + /// The mod to test. + /// Whether freestyle is enabled. + public static bool IsValidModForFreestyleMode(Mod mod, bool freestyle) + { + if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation) + return false; + + return !freestyle || mod.ValidForFreestyle; + } } } From 30da954feeb3bd426fa16241be5ea1df291684f9 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 4 Apr 2025 14:48:28 +0900 Subject: [PATCH 02/61] Allow selecting mods/freemods with freestyle --- .../OnlinePlay/FooterButtonFreeMods.cs | 5 +- .../OnlinePlay/OnlinePlaySongSelect.cs | 53 +++++++++---------- 2 files changed, 26 insertions(+), 32 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs index 3605412b2b..f9e41a1403 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs @@ -24,7 +24,6 @@ namespace osu.Game.Screens.OnlinePlay public partial class FooterButtonFreeMods : FooterButton { public readonly Bindable> FreeMods = new Bindable>(); - public readonly IBindable Freestyle = new Bindable(); protected override bool IsActive => FreeMods.Value.Count > 0; @@ -94,8 +93,6 @@ namespace osu.Game.Screens.OnlinePlay protected override void LoadComplete() { base.LoadComplete(); - - Freestyle.BindValueChanged(_ => updateModDisplay()); FreeMods.BindValueChanged(_ => updateModDisplay(), true); } @@ -115,7 +112,7 @@ namespace osu.Game.Screens.OnlinePlay { int currentCount = FreeMods.Value.Count; - if (currentCount == allAvailableAndValidMods.Count() || Freestyle.Value) + if (currentCount == allAvailableAndValidMods.Count()) { count.Text = "all"; count.FadeColour(colours.Gray2, 200, Easing.OutQuint); diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 9bedecc221..44118b04a2 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -47,7 +47,6 @@ namespace osu.Game.Screens.OnlinePlay private readonly Room room; private readonly PlaylistItem? initialItem; private readonly FreeModSelectOverlay freeModSelect; - private FooterButton freeModsFooterButton = null!; private IDisposable? freeModSelectOverlayRegistration; @@ -115,7 +114,7 @@ namespace osu.Game.Screens.OnlinePlay Freestyle.Value = initialItem.Freestyle; } - Mods.BindValueChanged(onModsChanged); + Mods.BindValueChanged(onGlobalModsChanged); Ruleset.BindValueChanged(onRulesetChanged); Freestyle.BindValueChanged(onFreestyleChanged, true); @@ -124,36 +123,31 @@ namespace osu.Game.Screens.OnlinePlay private void onFreestyleChanged(ValueChangedEvent enabled) { - if (enabled.NewValue) - { - freeModsFooterButton.Enabled.Value = false; - freeModsFooterButton.Enabled.Value = false; - ModsFooterButton.Enabled.Value = false; + // If all free mods were previously selected, we'll need to reselect what may now be a larger selection. + bool allFreeModsSelected = FreeMods.Value.Count > 0 && freeModSelect.AllAvailableMods.Count(state => state.ValidForSelection.Value) == FreeMods.Value.Count; - ModSelect.Hide(); - freeModSelect.Hide(); + // Remove invalid mods and display the newly available mod panels. + Mods.Value = Mods.Value.Where(isValidGlobalMod).ToArray(); + ModSelect.IsValidMod = isValidGlobalMod; + FreeMods.Value = FreeMods.Value.Where(isValidFreeMod).ToArray(); + freeModSelect.IsValidMod = isValidFreeMod; - Mods.Value = []; - FreeMods.Value = []; - } - else - { - freeModsFooterButton.Enabled.Value = true; - ModsFooterButton.Enabled.Value = true; - } + // Reselect all free mods if they were all previously selected (prefer keeping free mods enabled). + if (allFreeModsSelected) + FreeMods.Value = freeModSelect.AllAvailableMods.Where(state => state.ValidForSelection.Value).Select(state => state.Mod).ToArray(); } - private void onModsChanged(ValueChangedEvent> mods) + private void onGlobalModsChanged(ValueChangedEvent> mods) { - FreeMods.Value = FreeMods.Value.Where(isValidFreeMod).ToList(); - - // Reset the validity delegate to update the overlay's display. + // Remove incompatible free mods and display the newly available mod panels. + FreeMods.Value = FreeMods.Value.Where(isValidFreeMod).ToArray(); freeModSelect.IsValidMod = isValidFreeMod; } private void onRulesetChanged(ValueChangedEvent ruleset) { - FreeMods.Value = Array.Empty(); + // Todo: We can probably attempt to preserve across rulesets like the global mods do. + FreeMods.Value = []; } protected sealed override bool OnStart() @@ -195,7 +189,7 @@ namespace osu.Game.Screens.OnlinePlay protected override ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay(OverlayColourScheme.Plum) { - IsValidMod = isValidMod + IsValidMod = isValidGlobalMod }; protected override IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons() @@ -206,10 +200,9 @@ namespace osu.Game.Screens.OnlinePlay baseButtons.InsertRange(baseButtons.FindIndex(b => b.button is FooterButtonMods) + 1, new (FooterButton, OverlayContainer?)[] { - (freeModsFooterButton = new FooterButtonFreeMods(freeModSelect) + (new FooterButtonFreeMods(freeModSelect) { - FreeMods = { BindTarget = FreeMods }, - Freestyle = { BindTarget = Freestyle } + FreeMods = { BindTarget = FreeMods } }, null), (new FooterButtonFreestyle { @@ -225,7 +218,9 @@ namespace osu.Game.Screens.OnlinePlay /// /// The to check. /// Whether is a valid mod for online play. - private bool isValidMod(Mod mod) => ModUtils.IsValidModForMatchType(mod, room.Type); + private bool isValidGlobalMod(Mod mod) => ModUtils.IsValidModForMatchType(mod, room.Type) + // Mod must be valid in the current freestyle mode. + && ModUtils.IsValidModForFreestyleMode(mod, Freestyle.Value); /// /// Checks whether a given is valid for per-player free-mod selection. @@ -236,7 +231,9 @@ namespace osu.Game.Screens.OnlinePlay // Mod must not be contained in the required mods. && Mods.Value.All(m => m.Acronym != mod.Acronym) // Mod must be compatible with all the required mods. - && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); + && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()) + // Mod must be valid in the current freestyle mode. + && ModUtils.IsValidModForFreestyleMode(mod, Freestyle.Value); protected override void Dispose(bool isDisposing) { From a813d53870122194b7281f835e82d50dc3547a64 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 4 Apr 2025 15:01:43 +0900 Subject: [PATCH 03/61] Select all freemods by default Doesn't match stable which disables freemods by default, but this probably fits user expectations better. --- osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 44118b04a2..33de8d55b8 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -118,6 +118,13 @@ namespace osu.Game.Screens.OnlinePlay Ruleset.BindValueChanged(onRulesetChanged); Freestyle.BindValueChanged(onFreestyleChanged, true); + if (initialItem == null) + { + // Enable all free mods if we're creating a new playlist item. + // Todo: This needs to be scheduled because mods aren't available until the nested LoadComplete(). Can we do this any better? + SchedulerAfterChildren.Add(() => FreeMods.Value = freeModSelect.AllAvailableMods.Where(state => state.ValidForSelection.Value).Select(state => state.Mod).ToArray()); + } + freeModSelectOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(freeModSelect); } From 235191c02900b10eb4bb35997ef5d63373350cda Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 4 Apr 2025 15:10:03 +0900 Subject: [PATCH 04/61] Update room implementations to support new behaviour --- .../Playlists/TestScenePlaylistsRoomSubScreen.cs | 12 ++++++++---- .../Match/MultiplayerUserModSelectOverlay.cs | 9 ++------- .../Multiplayer/MultiplayerMatchSubScreen.cs | 2 +- .../OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs | 4 ---- 4 files changed, 11 insertions(+), 16 deletions(-) diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs index 1841e2fd52..0eed6c9f5f 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomSubScreen.cs @@ -362,12 +362,14 @@ namespace osu.Game.Tests.Visual.Playlists new PlaylistItem(importedSet.Beatmaps[0]) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, - Freestyle = true + Freestyle = true, + AllowedMods = [new APIMod(new OsuModDoubleTime())] }, new PlaylistItem(importedSet.Beatmaps[0]) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, - Freestyle = true + Freestyle = true, + AllowedMods = [new APIMod(new OsuModDoubleTime())] }, ] }; @@ -452,12 +454,14 @@ namespace osu.Game.Tests.Visual.Playlists new PlaylistItem(importedSet.Beatmaps[0]) { RulesetID = new OsuRuleset().RulesetInfo.OnlineID, - Freestyle = true + Freestyle = true, + AllowedMods = [new APIMod(new OsuModDoubleTime())] }, new PlaylistItem(importedSet.Beatmaps[0]) { RulesetID = new TaikoRuleset().RulesetInfo.OnlineID, - Freestyle = true + Freestyle = true, + AllowedMods = [new APIMod(new TaikoModDoubleTime())] }, ] }; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs index 8463a4720c..55a85d2a1d 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs @@ -14,7 +14,6 @@ using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; -using osu.Game.Utils; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { @@ -81,14 +80,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match 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(); + Mod[] allowedMods = 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; + IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); // Remove any mods that are no longer allowed. Mod[] newUserMods = SelectedMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index d464362fda..e0048ac21d 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -604,7 +604,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Ruleset.Value = ruleset; Mods.Value = client.LocalUser.Mods.Concat(item.RequiredMods).Select(m => m.ToMod(rulesetInstance)).ToArray(); - bool freemods = item.Freestyle || item.AllowedMods.Any(); + bool freemods = item.AllowedMods.Any(); bool freestyle = item.Freestyle; if (freemods) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 305a81bdbe..92b06fc851 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -560,13 +560,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists return []; PlaylistItem item = SelectedItem.Value; - RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!; Ruleset rulesetInstance = gameplayRuleset.CreateInstance(); - if (item.Freestyle) - return rulesetInstance.AllMods.OfType().Where(m => ModUtils.IsValidFreeModForMatchType(m, room.Type)).ToArray(); - return item.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); } From 3a56f597495a979752d32f067a9d50a79525e510 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 4 Apr 2025 16:05:09 +0900 Subject: [PATCH 05/61] Allow more mods in freestyle --- osu.Game/Rulesets/Mods/ModDaycore.cs | 1 - osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs | 2 ++ osu.Game/Rulesets/Mods/ModDoubleTime.cs | 1 - osu.Game/Rulesets/Mods/ModHalfTime.cs | 1 - osu.Game/Rulesets/Mods/ModNightcore.cs | 1 - osu.Game/Rulesets/Mods/ModRateAdjust.cs | 1 + osu.Game/Rulesets/Mods/ModTimeRamp.cs | 1 + 7 files changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Game/Rulesets/Mods/ModDaycore.cs b/osu.Game/Rulesets/Mods/ModDaycore.cs index b7646a7463..359f8a950c 100644 --- a/osu.Game/Rulesets/Mods/ModDaycore.cs +++ b/osu.Game/Rulesets/Mods/ModDaycore.cs @@ -18,7 +18,6 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyReduction; public override LocalisableString Description => "Whoaaaaa..."; public override bool Ranked => UsesDefaultConfiguration; - public override bool ValidForFreestyle => true; [SettingSource("Speed decrease", "The actual decrease to apply", SettingControlType = typeof(MultiplierSettingsSlider))] public override BindableNumber SpeedChange { get; } = new BindableDouble(0.75) diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index cdde1b73b6..5da37629a3 100644 --- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -27,6 +27,8 @@ namespace osu.Game.Rulesets.Mods public override bool RequiresConfiguration => true; + public override bool ValidForFreestyle => true; + public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModHardRock) }; protected const int FIRST_SETTING_ORDER = 1; diff --git a/osu.Game/Rulesets/Mods/ModDoubleTime.cs b/osu.Game/Rulesets/Mods/ModDoubleTime.cs index 253fbed074..fd5120a767 100644 --- a/osu.Game/Rulesets/Mods/ModDoubleTime.cs +++ b/osu.Game/Rulesets/Mods/ModDoubleTime.cs @@ -19,7 +19,6 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyIncrease; public override LocalisableString Description => "Zoooooooooom..."; public override bool Ranked => SpeedChange.IsDefault; - public override bool ValidForFreestyle => true; [SettingSource("Speed increase", "The actual increase to apply", SettingControlType = typeof(MultiplierSettingsSlider))] public override BindableNumber SpeedChange { get; } = new BindableDouble(1.5) diff --git a/osu.Game/Rulesets/Mods/ModHalfTime.cs b/osu.Game/Rulesets/Mods/ModHalfTime.cs index 563730c84e..efdf0d6358 100644 --- a/osu.Game/Rulesets/Mods/ModHalfTime.cs +++ b/osu.Game/Rulesets/Mods/ModHalfTime.cs @@ -19,7 +19,6 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyReduction; public override LocalisableString Description => "Less zoom..."; public override bool Ranked => SpeedChange.IsDefault; - public override bool ValidForFreestyle => true; [SettingSource("Speed decrease", "The actual decrease to apply", SettingControlType = typeof(MultiplierSettingsSlider))] public override BindableNumber SpeedChange { get; } = new BindableDouble(0.75) diff --git a/osu.Game/Rulesets/Mods/ModNightcore.cs b/osu.Game/Rulesets/Mods/ModNightcore.cs index ab650348d5..bb18940f8c 100644 --- a/osu.Game/Rulesets/Mods/ModNightcore.cs +++ b/osu.Game/Rulesets/Mods/ModNightcore.cs @@ -29,7 +29,6 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyIncrease; public override LocalisableString Description => "Uguuuuuuuu..."; public override bool Ranked => UsesDefaultConfiguration; - public override bool ValidForFreestyle => true; [SettingSource("Speed increase", "The actual increase to apply", SettingControlType = typeof(MultiplierSettingsSlider))] public override BindableNumber SpeedChange { get; } = new BindableDouble(1.5) diff --git a/osu.Game/Rulesets/Mods/ModRateAdjust.cs b/osu.Game/Rulesets/Mods/ModRateAdjust.cs index 358034541c..3bbd24ffe9 100644 --- a/osu.Game/Rulesets/Mods/ModRateAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModRateAdjust.cs @@ -11,6 +11,7 @@ namespace osu.Game.Rulesets.Mods { public abstract class ModRateAdjust : Mod, IApplicableToRate { + public sealed override bool ValidForFreestyle => true; public sealed override bool ValidForMultiplayerAsFreeMod => false; public abstract BindableNumber SpeedChange { get; } diff --git a/osu.Game/Rulesets/Mods/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs index fd85709b52..e2210bf012 100644 --- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs +++ b/osu.Game/Rulesets/Mods/ModTimeRamp.cs @@ -32,6 +32,7 @@ namespace osu.Game.Rulesets.Mods [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] public abstract BindableBool AdjustPitch { get; } + public sealed override bool ValidForFreestyle => true; public sealed override bool ValidForMultiplayerAsFreeMod => false; public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModAdaptiveSpeed) }; From cf8a7cbbe85ead3c22771cc5f4c3941642b88a82 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 4 Apr 2025 16:33:13 +0900 Subject: [PATCH 06/61] Disallow mania cover mod --- osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs index eb243bfab7..db4e4c30bd 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs @@ -29,6 +29,9 @@ namespace osu.Game.Rulesets.Mania.Mods public override bool Ranked => false; + // Ideally we'd allow this, but it's not easy to handle due to the change in acronym from the base class. + public override bool ValidForFreestyle => false; + [SettingSource("Coverage", "The proportion of playfield height that notes will be hidden for.")] public override BindableNumber Coverage { get; } = new BindableFloat(0.5f) { From 05e70cd938bbef018be7c9f77d05608c2ac6431f Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 10 Apr 2025 13:02:25 +0900 Subject: [PATCH 07/61] Merge mod validity checks to closer match osu!web --- osu.Game.Tests/Mods/ModUtilsTest.cs | 54 ++++++++++--------- .../OnlinePlay/OnlinePlaySongSelect.cs | 10 ++-- osu.Game/Utils/ModUtils.cs | 49 ++++------------- 3 files changed, 44 insertions(+), 69 deletions(-) diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index 3b4206f5c5..44c5809878 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -392,44 +392,50 @@ namespace osu.Game.Tests.Mods [Test] public void TestRoomModValidity() { - Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModHardRock(), MatchType.Playlists)); - Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModDoubleTime(), MatchType.Playlists)); - Assert.IsTrue(ModUtils.IsValidModForMatchType(new ModAdaptiveSpeed(), MatchType.Playlists)); - Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModAutoplay(), MatchType.Playlists)); - Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModTouchDevice(), MatchType.Playlists)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), MatchType.Playlists, true, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), MatchType.Playlists, true, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), MatchType.Playlists, true, false)); + Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModAutoplay(), MatchType.Playlists, true, false)); + Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModTouchDevice(), MatchType.Playlists, true, false)); - Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModHardRock(), MatchType.HeadToHead)); - Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModDoubleTime(), MatchType.HeadToHead)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), MatchType.HeadToHead, true, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), MatchType.HeadToHead, true, false)); // For now, adaptive speed isn't allowed in multiplayer because it's a per-user rate adjustment. - Assert.IsFalse(ModUtils.IsValidModForMatchType(new ModAdaptiveSpeed(), MatchType.HeadToHead)); - Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModAutoplay(), MatchType.HeadToHead)); - Assert.IsFalse(ModUtils.IsValidModForMatchType(new OsuModTouchDevice(), MatchType.HeadToHead)); + Assert.IsFalse(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), MatchType.HeadToHead, true, false)); + Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModAutoplay(), MatchType.HeadToHead, true, false)); + Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModTouchDevice(), MatchType.HeadToHead, true, false)); } [Test] public void TestRoomFreeModValidity() { - Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new OsuModHardRock(), MatchType.Playlists)); - Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new OsuModDoubleTime(), MatchType.Playlists)); - Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new ModAdaptiveSpeed(), MatchType.Playlists)); - Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModAutoplay(), MatchType.Playlists)); - Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModTouchDevice(), MatchType.Playlists)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), MatchType.Playlists, false, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), MatchType.Playlists, false, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), MatchType.Playlists, false, false)); + Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModAutoplay(), MatchType.Playlists, false, false)); + Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModTouchDevice(), MatchType.Playlists, false, false)); - Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new OsuModHardRock(), MatchType.HeadToHead)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), MatchType.HeadToHead, false, false)); // For now, all rate adjustment mods aren't allowed as free mods in multiplayer. - Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModDoubleTime(), MatchType.HeadToHead)); - Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new ModAdaptiveSpeed(), MatchType.HeadToHead)); - Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModAutoplay(), MatchType.HeadToHead)); - Assert.IsFalse(ModUtils.IsValidFreeModForMatchType(new OsuModTouchDevice(), MatchType.HeadToHead)); + Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), MatchType.HeadToHead, false, false)); + Assert.IsFalse(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), MatchType.HeadToHead, false, false)); + Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModAutoplay(), MatchType.HeadToHead, false, false)); + Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModTouchDevice(), MatchType.HeadToHead, false, false)); } [Test] public void TestFreestyleModValidity() { - Assert.IsTrue(ModUtils.IsValidModForFreestyleMode(new OsuModHardRock(), true)); - Assert.IsTrue(ModUtils.IsValidModForFreestyleMode(new OsuModHardRock(), false)); - Assert.IsTrue(ModUtils.IsValidModForFreestyleMode(new OsuModBarrelRoll(), false)); - Assert.IsFalse(ModUtils.IsValidModForFreestyleMode(new OsuModBarrelRoll(), true)); + foreach (MatchType type in new[] { MatchType.Playlists, MatchType.HeadToHead }) + { + foreach (bool required in new[] { false, true }) + { + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), type, required, true)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), type, required, false)); + Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModBarrelRoll(), type, required, true)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModBarrelRoll(), type, required, false)); + } + } } [Test] diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 33de8d55b8..1d6b4940c3 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -225,22 +225,18 @@ namespace osu.Game.Screens.OnlinePlay /// /// The to check. /// Whether is a valid mod for online play. - private bool isValidGlobalMod(Mod mod) => ModUtils.IsValidModForMatchType(mod, room.Type) - // Mod must be valid in the current freestyle mode. - && ModUtils.IsValidModForFreestyleMode(mod, Freestyle.Value); + private bool isValidGlobalMod(Mod mod) => ModUtils.IsValidModForMatch(mod, room.Type, true, Freestyle.Value); /// /// Checks whether a given is valid for per-player free-mod selection. /// /// The to check. /// Whether is a selectable free-mod. - private bool isValidFreeMod(Mod mod) => ModUtils.IsValidFreeModForMatchType(mod, room.Type) + private bool isValidFreeMod(Mod mod) => ModUtils.IsValidModForMatch(mod, room.Type, false, Freestyle.Value) // Mod must not be contained in the required mods. && Mods.Value.All(m => m.Acronym != mod.Acronym) // Mod must be compatible with all the required mods. - && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()) - // Mod must be valid in the current freestyle mode. - && ModUtils.IsValidModForFreestyleMode(mod, Freestyle.Value); + && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); protected override void Dispose(bool isDisposing) { diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index e4c1d5c1fc..c46e0d9765 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -304,56 +304,29 @@ namespace osu.Game.Utils } /// - /// Determines whether a mod can be applied to playlist items in the given match type. + /// Determines whether a mod can be applied to playlist items in the match type. /// /// The mod to test. - /// The match type. - public static bool IsValidModForMatchType(Mod mod, MatchType type) + /// The room match type. + /// Whether the mod is intended as a "required" (room-global) mod. + /// Whether freestyle is enabled for the playlist item. + /// Related osu!web function. + public static bool IsValidModForMatch(Mod mod, MatchType matchType, bool isRequired, bool isFreestyle) { if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation) return false; - switch (type) + if (isFreestyle && !mod.ValidForFreestyle) + return false; + + switch (matchType) { case MatchType.Playlists: return true; default: - return mod.ValidForMultiplayer; + return isRequired ? mod.ValidForMultiplayer : mod.ValidForMultiplayerAsFreeMod; } } - - /// - /// Determines whether a mod can be applied as a free mod to playlist items in the given match type. - /// - /// The mod to test. - /// The match type. - public static bool IsValidFreeModForMatchType(Mod mod, MatchType type) - { - if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation) - return false; - - switch (type) - { - case MatchType.Playlists: - return true; - - default: - return mod.ValidForMultiplayerAsFreeMod; - } - } - - /// - /// Determines whether a mod can be applied in the given freestyle mode. - /// - /// The mod to test. - /// Whether freestyle is enabled. - public static bool IsValidModForFreestyleMode(Mod mod, bool freestyle) - { - if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation) - return false; - - return !freestyle || mod.ValidForFreestyle; - } } } From 69630263a8a8329df5febb9b6bff41e7f8d30239 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 10 Apr 2025 13:04:40 +0900 Subject: [PATCH 08/61] Update test with stricter assertions --- osu.Game.Tests/Mods/ModUtilsTest.cs | 38 +++++++++++------------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index 44c5809878..22814253eb 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.Linq; using Moq; using NUnit.Framework; +using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Localisation; using osu.Game.Online.Rooms; using osu.Game.Rulesets; @@ -441,35 +442,24 @@ namespace osu.Game.Tests.Mods [Test] public void TestFreestyleRulesetCompatibility() { - Mod[] osuMods = ModUtils.FlattenMods(new OsuRuleset().CreateAllMods()).Where(m => m.ValidForFreestyle).ToArray(); - Ruleset[] otherRulesets = [new TaikoRuleset(), new CatchRuleset(), new ManiaRuleset()]; + HashSet commonAcronyms = new HashSet(); - EqualityComparer validModComparer = EqualityComparer.Create((a, b) => - { - if (a == null || b == null) - return false; - - Type aType = a.GetType(); - - while (aType != typeof(Mod)) - { - if (aType.IsInstanceOfType(b)) - return string.Equals(a.Acronym, b.Acronym, StringComparison.Ordinal); - - aType = aType.BaseType!; - } - - return false; - }); + commonAcronyms.UnionWith(new OsuRuleset().CreateAllMods().Select(m => m.Acronym)); + commonAcronyms.IntersectWith(new TaikoRuleset().CreateAllMods().Select(m => m.Acronym)); + commonAcronyms.IntersectWith(new CatchRuleset().CreateAllMods().Select(m => m.Acronym)); + commonAcronyms.IntersectWith(new ManiaRuleset().CreateAllMods().Select(m => m.Acronym)); Assert.Multiple(() => { - foreach (var ruleset in otherRulesets) + foreach (var ruleset in new Ruleset[] { new OsuRuleset(), new TaikoRuleset(), new CatchRuleset(), new ManiaRuleset() }) { - Mod[] mods = ModUtils.FlattenMods(ruleset.CreateAllMods()).Where(m => m.ValidForFreestyle).ToArray(); - - foreach (var mod in mods) - Assert.That(osuMods, Contains.Item(mod).Using(validModComparer)); + foreach (var mod in ruleset.CreateAllMods()) + { + if (mod.ValidForFreestyle && mod.UserPlayable && !commonAcronyms.Contains(mod.Acronym)) + Assert.Fail($"{mod.GetType().ReadableName()} declares {nameof(Mod.ValidForFreestyle)} but does not exist in all four basic rulesets!"); + if (!mod.ValidForFreestyle && mod.UserPlayable && commonAcronyms.Contains(mod.Acronym)) + Assert.Fail($"{mod.GetType().ReadableName()} does not declare {nameof(Mod.ValidForFreestyle)} but exists in all four basic rulesets!"); + } } }); } From c6e368d3ea342cda914a9aa7b805a88c8fb848ac Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 10 Apr 2025 13:05:17 +0900 Subject: [PATCH 09/61] Allow classic mod in freestyle --- osu.Game/Rulesets/Mods/ModClassic.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/osu.Game/Rulesets/Mods/ModClassic.cs b/osu.Game/Rulesets/Mods/ModClassic.cs index b0f6ba9374..146647e3d9 100644 --- a/osu.Game/Rulesets/Mods/ModClassic.cs +++ b/osu.Game/Rulesets/Mods/ModClassic.cs @@ -30,5 +30,7 @@ namespace osu.Game.Rulesets.Mods /// - Sliders always gives combo for slider end, even on miss (https://github.com/ppy/osu/issues/11769). /// public sealed override bool Ranked => false; + + public sealed override bool ValidForFreestyle => true; } } From b4c7d7f4986af62e04281fb8bd8cd6c1468c2955 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 14 Apr 2025 18:16:06 +0900 Subject: [PATCH 10/61] Only apply freestyle validation to required mods --- osu.Game.Tests/Mods/ModUtilsTest.cs | 59 +++++++++++++++++------------ osu.Game/Rulesets/Mods/Mod.cs | 8 ++-- osu.Game/Utils/ModUtils.cs | 16 ++------ 3 files changed, 41 insertions(+), 42 deletions(-) diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index 22814253eb..b80f09c303 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -280,7 +280,7 @@ namespace osu.Game.Tests.Mods }, }; - private static readonly object[] invalid_freestyle_mod_test_scenarios = + private static readonly object[] invalid_freestyle_required_mod_test_scenarios = { // system mod. new object[] @@ -308,6 +308,28 @@ namespace osu.Game.Tests.Mods }, }; + private static readonly object[] invalid_freestyle_allowed_mod_test_scenarios = + { + // system mod. + new object[] + { + new Mod[] { new OsuModHidden(), new OsuModTouchDevice() }, + new[] { typeof(OsuModHidden), typeof(OsuModTouchDevice) } + }, + // multi mod. + new object[] + { + new Mod[] { new MultiMod(new OsuModSuddenDeath(), new OsuModPerfect()) }, + new[] { typeof(MultiMod) } + }, + // invalid freestyle mod. + new object[] + { + new Mod[] { new OsuModHidden() }, + new[] { typeof(OsuModHidden) } + }, + }; + [TestCaseSource(nameof(invalid_mod_test_scenarios))] public void TestInvalidModScenarios(Mod[] inputMods, Type[] expectedInvalid) { @@ -324,7 +346,7 @@ namespace osu.Game.Tests.Mods [TestCaseSource(nameof(invalid_multiplayer_mod_test_scenarios))] public void TestInvalidMultiplayerModScenarios(Mod[] inputMods, Type[] expectedInvalid) { - bool isValid = ModUtils.CheckValidRequiredModsForMultiplayer(inputMods, out var invalid); + bool isValid = ModUtils.CheckValidRequiredModsForMultiplayer(inputMods, false, out var invalid); Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0)); @@ -334,23 +356,10 @@ namespace osu.Game.Tests.Mods Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); } - [TestCaseSource(nameof(invalid_freestyle_mod_test_scenarios))] - public void TestInvalidFreestyleModScenarios(Mod[] inputMods, Type[] expectedInvalid) + [TestCaseSource(nameof(invalid_freestyle_required_mod_test_scenarios))] + public void TestInvalidFreestyleRequiredModScenarios(Mod[] inputMods, Type[] expectedInvalid) { - bool isValid = ModUtils.CheckValidModsForFreestyle(inputMods, out var invalid); - - Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0)); - - if (isValid) - Assert.IsNull(invalid); - else - Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); - } - - [TestCaseSource(nameof(invalid_free_mod_test_scenarios))] - public void TestInvalidFreeModScenarios(Mod[] inputMods, Type[] expectedInvalid) - { - bool isValid = ModUtils.CheckValidFreeModsForMultiplayer(inputMods, out var invalid); + bool isValid = ModUtils.CheckValidRequiredModsForMultiplayer(inputMods, true, out var invalid); Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0)); @@ -429,13 +438,13 @@ namespace osu.Game.Tests.Mods { foreach (MatchType type in new[] { MatchType.Playlists, MatchType.HeadToHead }) { - foreach (bool required in new[] { false, true }) - { - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), type, required, true)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), type, required, false)); - Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModBarrelRoll(), type, required, true)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModBarrelRoll(), type, required, false)); - } + // Required mods + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), type, true, true)); + Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModBarrelRoll(), type, true, true)); + + // Allowed mods + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), type, false, true)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModBarrelRoll(), type, false, true)); } } diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 30e6b4762b..2fc0db55cf 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -121,15 +121,13 @@ namespace osu.Game.Rulesets.Mods public virtual bool ValidForMultiplayer => true; /// - /// Whether this mod can be specified as a mod (either "required" or "allowed") on freestyle playlist items, - /// indicating that all rulesets contain an implementation of this mod. + /// Whether this mod can be specified as a "required" mod on freestyle playlist items, indicating that all rulesets contain an implementation of this mod. /// /// /// - /// is valid as a freestyle mod. + /// is valid as a freestyle required-mod. /// - /// OsuModNoScope is not valid as a freestyle mod, - /// as it is only implemented in the osu! ruleset. + /// OsuModNoScope is not valid, as it is only implemented in the osu! ruleset. /// /// /// diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index c46e0d9765..96a30b094d 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -127,22 +127,14 @@ namespace osu.Game.Utils return checkValid(mods, m => m.HasImplementation, out invalidMods); } - /// - /// Checks that all s in a combination are valid as "required mods" in a freestyle room. - /// - /// The mods to check. - /// Invalid mods, if any were found. Will be null if all mods were valid. - /// Whether the input mods were all valid. If false, will contain all invalid entries. - public static bool CheckValidModsForFreestyle(IEnumerable mods, [NotNullWhen(false)] out List? invalidMods) - => checkValid(mods, m => m.Type != ModType.System && m.HasImplementation && m.ValidForFreestyle, out invalidMods); - /// /// Checks that all s in a combination are valid as "required mods" in a multiplayer match session. /// /// The mods to check. + /// Whether freestyle is enabled for the playlist item. /// Invalid mods, if any were found. Will be null if all mods were valid. /// Whether the input mods were all valid. If false, will contain all invalid entries. - public static bool CheckValidRequiredModsForMultiplayer(IEnumerable mods, [NotNullWhen(false)] out List? invalidMods) + public static bool CheckValidRequiredModsForMultiplayer(IEnumerable mods, bool freestyle, [NotNullWhen(false)] out List? invalidMods) { mods = mods.ToArray(); @@ -154,7 +146,7 @@ namespace osu.Game.Utils if (!CheckCompatibleSet(mods, out invalidMods)) return false; - return checkValid(mods, m => m.Type != ModType.System && m.HasImplementation && m.ValidForMultiplayer, out invalidMods); + return checkValid(mods, m => m.Type != ModType.System && m.HasImplementation && m.ValidForMultiplayer && (!freestyle || m.ValidForFreestyle), out invalidMods); } /// @@ -316,7 +308,7 @@ namespace osu.Game.Utils if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation) return false; - if (isFreestyle && !mod.ValidForFreestyle) + if (isFreestyle && isRequired && !mod.ValidForFreestyle) return false; switch (matchType) From 35ee4b6f24577cb3aae2cccf0d4aa593b08c9ea3 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 14 Apr 2025 18:16:27 +0900 Subject: [PATCH 11/61] Adjust property name --- osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs | 2 +- osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs | 2 +- osu.Game.Tests/Mods/ModUtilsTest.cs | 10 +++++----- osu.Game/Rulesets/Mods/Mod.cs | 2 +- osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs | 2 +- osu.Game/Rulesets/Mods/ModClassic.cs | 2 +- osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs | 2 +- osu.Game/Rulesets/Mods/ModEasy.cs | 2 +- osu.Game/Rulesets/Mods/ModFlashlight.cs | 2 +- osu.Game/Rulesets/Mods/ModHardRock.cs | 2 +- osu.Game/Rulesets/Mods/ModHidden.cs | 2 +- osu.Game/Rulesets/Mods/ModMuted.cs | 2 +- osu.Game/Rulesets/Mods/ModNoFail.cs | 2 +- osu.Game/Rulesets/Mods/ModPerfect.cs | 2 +- osu.Game/Rulesets/Mods/ModRateAdjust.cs | 2 +- osu.Game/Rulesets/Mods/ModSuddenDeath.cs | 2 +- osu.Game/Rulesets/Mods/ModTimeRamp.cs | 2 +- osu.Game/Utils/ModUtils.cs | 4 ++-- 18 files changed, 23 insertions(+), 23 deletions(-) diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs index db4e4c30bd..bab88a269b 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs @@ -30,7 +30,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override bool Ranked => false; // Ideally we'd allow this, but it's not easy to handle due to the change in acronym from the base class. - public override bool ValidForFreestyle => false; + public override bool ValidForFreestyleAsRequiredMod => false; [SettingSource("Coverage", "The proportion of playfield height that notes will be hidden for.")] public override BindableNumber Coverage { get; } = new BindableFloat(0.5f) diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs index abcabf3826..bad895504e 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs @@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Mania.Mods public override double ScoreMultiplier => 1; // Ideally we'd allow this, but it's not easy to handle due to the change in acronym from the base class. - public override bool ValidForFreestyle => false; + public override bool ValidForFreestyleAsRequiredMod => false; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index b80f09c303..074d9438d4 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -464,10 +464,10 @@ namespace osu.Game.Tests.Mods { foreach (var mod in ruleset.CreateAllMods()) { - if (mod.ValidForFreestyle && mod.UserPlayable && !commonAcronyms.Contains(mod.Acronym)) - Assert.Fail($"{mod.GetType().ReadableName()} declares {nameof(Mod.ValidForFreestyle)} but does not exist in all four basic rulesets!"); - if (!mod.ValidForFreestyle && mod.UserPlayable && commonAcronyms.Contains(mod.Acronym)) - Assert.Fail($"{mod.GetType().ReadableName()} does not declare {nameof(Mod.ValidForFreestyle)} but exists in all four basic rulesets!"); + if (mod.ValidForFreestyleAsRequiredMod && mod.UserPlayable && !commonAcronyms.Contains(mod.Acronym)) + Assert.Fail($"{mod.GetType().ReadableName()} declares {nameof(Mod.ValidForFreestyleAsRequiredMod)} but does not exist in all four basic rulesets!"); + if (!mod.ValidForFreestyleAsRequiredMod && mod.UserPlayable && commonAcronyms.Contains(mod.Acronym)) + Assert.Fail($"{mod.GetType().ReadableName()} does not declare {nameof(Mod.ValidForFreestyleAsRequiredMod)} but exists in all four basic rulesets!"); } } }); @@ -509,7 +509,7 @@ namespace osu.Game.Tests.Mods public override double ScoreMultiplier => 1; public override string Acronym => string.Empty; public override bool HasImplementation => true; - public override bool ValidForFreestyle => false; + public override bool ValidForFreestyleAsRequiredMod => false; } public interface IModCompatibilitySpecification diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index 2fc0db55cf..bc1997a7b3 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -131,7 +131,7 @@ namespace osu.Game.Rulesets.Mods /// /// /// - public virtual bool ValidForFreestyle => false; + public virtual bool ValidForFreestyleAsRequiredMod => false; /// /// Whether this mod can be specified as a "free" or "allowed" mod in a multiplayer context. diff --git a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs index 182dc31987..83d5fb027e 100644 --- a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs +++ b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs @@ -34,7 +34,7 @@ namespace osu.Game.Rulesets.Mods public override bool Ranked => true; - public override bool ValidForFreestyle => true; + public override bool ValidForFreestyleAsRequiredMod => true; public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription { diff --git a/osu.Game/Rulesets/Mods/ModClassic.cs b/osu.Game/Rulesets/Mods/ModClassic.cs index 146647e3d9..e20ac5dfc7 100644 --- a/osu.Game/Rulesets/Mods/ModClassic.cs +++ b/osu.Game/Rulesets/Mods/ModClassic.cs @@ -31,6 +31,6 @@ namespace osu.Game.Rulesets.Mods /// public sealed override bool Ranked => false; - public sealed override bool ValidForFreestyle => true; + public sealed override bool ValidForFreestyleAsRequiredMod => true; } } diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index 5da37629a3..79fc918487 100644 --- a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs @@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Mods public override bool RequiresConfiguration => true; - public override bool ValidForFreestyle => true; + public override bool ValidForFreestyleAsRequiredMod => true; public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModHardRock) }; diff --git a/osu.Game/Rulesets/Mods/ModEasy.cs b/osu.Game/Rulesets/Mods/ModEasy.cs index a24e242bca..b0ac0d5cce 100644 --- a/osu.Game/Rulesets/Mods/ModEasy.cs +++ b/osu.Game/Rulesets/Mods/ModEasy.cs @@ -17,7 +17,7 @@ namespace osu.Game.Rulesets.Mods public override double ScoreMultiplier => 0.5; public override Type[] IncompatibleMods => new[] { typeof(ModHardRock), typeof(ModDifficultyAdjust) }; public override bool Ranked => UsesDefaultConfiguration; - public override bool ValidForFreestyle => true; + public override bool ValidForFreestyleAsRequiredMod => true; public virtual void ReadFromDifficulty(BeatmapDifficulty difficulty) { diff --git a/osu.Game/Rulesets/Mods/ModFlashlight.cs b/osu.Game/Rulesets/Mods/ModFlashlight.cs index d5d526c027..da45b7cc92 100644 --- a/osu.Game/Rulesets/Mods/ModFlashlight.cs +++ b/osu.Game/Rulesets/Mods/ModFlashlight.cs @@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.DifficultyIncrease; public override LocalisableString Description => "Restricted view area."; public override bool Ranked => UsesDefaultConfiguration; - public override bool ValidForFreestyle => true; + public override bool ValidForFreestyleAsRequiredMod => true; [SettingSource("Flashlight size", "Multiplier applied to the default flashlight size.")] public abstract BindableFloat SizeMultiplier { get; } diff --git a/osu.Game/Rulesets/Mods/ModHardRock.cs b/osu.Game/Rulesets/Mods/ModHardRock.cs index 6af22cf516..ce40e6e075 100644 --- a/osu.Game/Rulesets/Mods/ModHardRock.cs +++ b/osu.Game/Rulesets/Mods/ModHardRock.cs @@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Mods public override LocalisableString Description => "Everything just got a bit harder..."; public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModDifficultyAdjust) }; public override bool Ranked => UsesDefaultConfiguration; - public override bool ValidForFreestyle => true; + public override bool ValidForFreestyleAsRequiredMod => true; protected const float ADJUST_RATIO = 1.4f; diff --git a/osu.Game/Rulesets/Mods/ModHidden.cs b/osu.Game/Rulesets/Mods/ModHidden.cs index 9cacf16ee7..f7a1336fd2 100644 --- a/osu.Game/Rulesets/Mods/ModHidden.cs +++ b/osu.Game/Rulesets/Mods/ModHidden.cs @@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Mods public override IconUsage? Icon => OsuIcon.ModHidden; public override ModType Type => ModType.DifficultyIncrease; public override bool Ranked => UsesDefaultConfiguration; - public override bool ValidForFreestyle => true; + public override bool ValidForFreestyleAsRequiredMod => true; public virtual void ApplyToScoreProcessor(ScoreProcessor scoreProcessor) { diff --git a/osu.Game/Rulesets/Mods/ModMuted.cs b/osu.Game/Rulesets/Mods/ModMuted.cs index 1158172260..2eb243d565 100644 --- a/osu.Game/Rulesets/Mods/ModMuted.cs +++ b/osu.Game/Rulesets/Mods/ModMuted.cs @@ -26,7 +26,7 @@ namespace osu.Game.Rulesets.Mods public override ModType Type => ModType.Fun; public override double ScoreMultiplier => 1; public override bool Ranked => true; - public override bool ValidForFreestyle => true; + public override bool ValidForFreestyleAsRequiredMod => true; } public abstract class ModMuted : ModMuted, IApplicableToDrawableRuleset, IApplicableToTrack, IApplicableToScoreProcessor diff --git a/osu.Game/Rulesets/Mods/ModNoFail.cs b/osu.Game/Rulesets/Mods/ModNoFail.cs index f65cdb80d7..121524e594 100644 --- a/osu.Game/Rulesets/Mods/ModNoFail.cs +++ b/osu.Game/Rulesets/Mods/ModNoFail.cs @@ -21,7 +21,7 @@ namespace osu.Game.Rulesets.Mods public override double ScoreMultiplier => 0.5; public override Type[] IncompatibleMods => new[] { typeof(ModFailCondition), typeof(ModCinema) }; public override bool Ranked => UsesDefaultConfiguration; - public override bool ValidForFreestyle => true; + public override bool ValidForFreestyleAsRequiredMod => true; private readonly Bindable showHealthBar = new Bindable(); diff --git a/osu.Game/Rulesets/Mods/ModPerfect.cs b/osu.Game/Rulesets/Mods/ModPerfect.cs index 9d46fedfe5..e7957ac4c5 100644 --- a/osu.Game/Rulesets/Mods/ModPerfect.cs +++ b/osu.Game/Rulesets/Mods/ModPerfect.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Mods public override double ScoreMultiplier => 1; public override LocalisableString Description => "SS or quit."; public override bool Ranked => true; - public override bool ValidForFreestyle => true; + public override bool ValidForFreestyleAsRequiredMod => true; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(ModSuddenDeath), typeof(ModAccuracyChallenge) }).ToArray(); diff --git a/osu.Game/Rulesets/Mods/ModRateAdjust.cs b/osu.Game/Rulesets/Mods/ModRateAdjust.cs index 3bbd24ffe9..7f8413a69b 100644 --- a/osu.Game/Rulesets/Mods/ModRateAdjust.cs +++ b/osu.Game/Rulesets/Mods/ModRateAdjust.cs @@ -11,7 +11,7 @@ namespace osu.Game.Rulesets.Mods { public abstract class ModRateAdjust : Mod, IApplicableToRate { - public sealed override bool ValidForFreestyle => true; + public sealed override bool ValidForFreestyleAsRequiredMod => true; public sealed override bool ValidForMultiplayerAsFreeMod => false; public abstract BindableNumber SpeedChange { get; } diff --git a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs index 48925913c5..f82033938a 100644 --- a/osu.Game/Rulesets/Mods/ModSuddenDeath.cs +++ b/osu.Game/Rulesets/Mods/ModSuddenDeath.cs @@ -20,7 +20,7 @@ namespace osu.Game.Rulesets.Mods public override LocalisableString Description => "Miss and fail."; public override double ScoreMultiplier => 1; public override bool Ranked => true; - public override bool ValidForFreestyle => true; + public override bool ValidForFreestyleAsRequiredMod => true; public override Type[] IncompatibleMods => base.IncompatibleMods.Append(typeof(ModPerfect)).ToArray(); diff --git a/osu.Game/Rulesets/Mods/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs index e2210bf012..30c41c15f5 100644 --- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs +++ b/osu.Game/Rulesets/Mods/ModTimeRamp.cs @@ -32,7 +32,7 @@ namespace osu.Game.Rulesets.Mods [SettingSource("Adjust pitch", "Should pitch be adjusted with speed")] public abstract BindableBool AdjustPitch { get; } - public sealed override bool ValidForFreestyle => true; + public sealed override bool ValidForFreestyleAsRequiredMod => true; public sealed override bool ValidForMultiplayerAsFreeMod => false; public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModAdaptiveSpeed) }; diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index 96a30b094d..d90ba943d4 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -146,7 +146,7 @@ namespace osu.Game.Utils if (!CheckCompatibleSet(mods, out invalidMods)) return false; - return checkValid(mods, m => m.Type != ModType.System && m.HasImplementation && m.ValidForMultiplayer && (!freestyle || m.ValidForFreestyle), out invalidMods); + return checkValid(mods, m => m.Type != ModType.System && m.HasImplementation && m.ValidForMultiplayer && (!freestyle || m.ValidForFreestyleAsRequiredMod), out invalidMods); } /// @@ -308,7 +308,7 @@ namespace osu.Game.Utils if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation) return false; - if (isFreestyle && isRequired && !mod.ValidForFreestyle) + if (isFreestyle && isRequired && !mod.ValidForFreestyleAsRequiredMod) return false; switch (matchType) From 276e9238437a867731b5aeb6e62ec8776d4e9ec5 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 14 Apr 2025 18:54:07 +0900 Subject: [PATCH 12/61] Restore freemods/freestyle button exclusivity --- .../OnlinePlay/FooterButtonFreeMods.cs | 5 ++- .../Multiplayer/MultiplayerMatchSubScreen.cs | 2 +- .../OnlinePlay/OnlinePlaySongSelect.cs | 32 +++++++++++-------- .../Playlists/PlaylistsRoomSubScreen.cs | 2 +- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs index f9e41a1403..3605412b2b 100644 --- a/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs +++ b/osu.Game/Screens/OnlinePlay/FooterButtonFreeMods.cs @@ -24,6 +24,7 @@ namespace osu.Game.Screens.OnlinePlay public partial class FooterButtonFreeMods : FooterButton { public readonly Bindable> FreeMods = new Bindable>(); + public readonly IBindable Freestyle = new Bindable(); protected override bool IsActive => FreeMods.Value.Count > 0; @@ -93,6 +94,8 @@ namespace osu.Game.Screens.OnlinePlay protected override void LoadComplete() { base.LoadComplete(); + + Freestyle.BindValueChanged(_ => updateModDisplay()); FreeMods.BindValueChanged(_ => updateModDisplay(), true); } @@ -112,7 +115,7 @@ namespace osu.Game.Screens.OnlinePlay { int currentCount = FreeMods.Value.Count; - if (currentCount == allAvailableAndValidMods.Count()) + if (currentCount == allAvailableAndValidMods.Count() || Freestyle.Value) { count.Text = "all"; count.FadeColour(colours.Gray2, 200, Easing.OutQuint); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 67e2c28aaa..13eeb4e1f7 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -619,7 +619,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Ruleset.Value = ruleset; Mods.Value = client.LocalUser.Mods.Concat(item.RequiredMods).Select(m => m.ToMod(rulesetInstance)).ToArray(); - bool freemods = item.AllowedMods.Any(); + bool freemods = item.Freestyle || item.AllowedMods.Any(); bool freestyle = item.Freestyle; if (freemods) diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 1d6b4940c3..74db6a1b78 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -47,6 +47,7 @@ namespace osu.Game.Screens.OnlinePlay private readonly Room room; private readonly PlaylistItem? initialItem; private readonly FreeModSelectOverlay freeModSelect; + private FooterButton freeModsFooterButton = null!; private IDisposable? freeModSelectOverlayRegistration; @@ -118,30 +119,35 @@ namespace osu.Game.Screens.OnlinePlay Ruleset.BindValueChanged(onRulesetChanged); Freestyle.BindValueChanged(onFreestyleChanged, true); - if (initialItem == null) - { - // Enable all free mods if we're creating a new playlist item. - // Todo: This needs to be scheduled because mods aren't available until the nested LoadComplete(). Can we do this any better? - SchedulerAfterChildren.Add(() => FreeMods.Value = freeModSelect.AllAvailableMods.Where(state => state.ValidForSelection.Value).Select(state => state.Mod).ToArray()); - } - freeModSelectOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(freeModSelect); } private void onFreestyleChanged(ValueChangedEvent enabled) { - // If all free mods were previously selected, we'll need to reselect what may now be a larger selection. - bool allFreeModsSelected = FreeMods.Value.Count > 0 && freeModSelect.AllAvailableMods.Count(state => state.ValidForSelection.Value) == FreeMods.Value.Count; - // Remove invalid mods and display the newly available mod panels. Mods.Value = Mods.Value.Where(isValidGlobalMod).ToArray(); ModSelect.IsValidMod = isValidGlobalMod; FreeMods.Value = FreeMods.Value.Where(isValidFreeMod).ToArray(); freeModSelect.IsValidMod = isValidFreeMod; - // Reselect all free mods if they were all previously selected (prefer keeping free mods enabled). - if (allFreeModsSelected) + if (enabled.NewValue) + { + freeModsFooterButton.Enabled.Value = false; + freeModSelect.Hide(); + + // Freestyle allows all mods to be selected as freemods. This does not play nicely for some components: + // - We probably don't want to store a gigantic list of acronyms to the database. + // - The mod select overlay isn't built to handle duplicate mods/mods from all rulesets being shoved into it. + // Instead, freestyle inherently assumes this list is empty, and must be empty for server-side validation to pass. + FreeMods.Value = []; + } + else + { + freeModsFooterButton.Enabled.Value = true; + + // When disabling freestyle, enable freemods by default. FreeMods.Value = freeModSelect.AllAvailableMods.Where(state => state.ValidForSelection.Value).Select(state => state.Mod).ToArray(); + } } private void onGlobalModsChanged(ValueChangedEvent> mods) @@ -207,7 +213,7 @@ namespace osu.Game.Screens.OnlinePlay baseButtons.InsertRange(baseButtons.FindIndex(b => b.button is FooterButtonMods) + 1, new (FooterButton, OverlayContainer?)[] { - (new FooterButtonFreeMods(freeModSelect) + (freeModsFooterButton = new FooterButtonFreeMods(freeModSelect) { FreeMods = { BindTarget = FreeMods } }, null), diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 92b06fc851..3a3d2a9b72 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -598,7 +598,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Mods.Value = UserMods.Value.Concat(item.RequiredMods.Select(m => m.ToMod(rulesetInstance))).ToArray(); // Update UI elements to reflect the new selection. - bool freemods = allowedMods.Length > 0; + bool freemods = item.Freestyle || allowedMods.Length > 0; bool freestyle = item.Freestyle; if (freemods) From 3dcd1a9e744d8fc284b72bab2d42972ecd317cab Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 14 Apr 2025 20:23:00 +0900 Subject: [PATCH 13/61] Adjust logic to properly list selectable mods --- .../Match/MultiplayerUserModSelectOverlay.cs | 3 ++- .../Playlists/PlaylistsRoomSubScreen.cs | 25 ++++++----------- osu.Game/Utils/ModUtils.cs | 27 +++++++++++++++++++ 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs index 55a85d2a1d..75fc928e2a 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs @@ -14,6 +14,7 @@ using osu.Game.Overlays; using osu.Game.Overlays.Mods; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; +using osu.Game.Utils; namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { @@ -80,7 +81,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match MultiplayerPlaylistItem currentItem = client.Room.CurrentPlaylistItem; Ruleset ruleset = rulesets.GetRuleset(client.LocalUser.RulesetId ?? currentItem.RulesetID)!.CreateInstance(); - Mod[] allowedMods = currentItem.AllowedMods.Select(m => m.ToMod(ruleset)).ToArray(); + Mod[] allowedMods = ModUtils.ListUserSelectableFreeMods(client.Room.Settings.MatchType, currentItem.RequiredMods, currentItem.AllowedMods, currentItem.Freestyle, ruleset); // Update the mod panels to reflect the ones which are valid for selection. IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 3a3d2a9b72..bdd3785153 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -551,27 +551,18 @@ namespace osu.Game.Screens.OnlinePlay.Playlists updateGameplayState(); } - /// - /// Lists the s that are valid to be selected for the user mod style. - /// - private Mod[] listAllowedMods() - { - if (SelectedItem.Value == null) - return []; - - PlaylistItem item = SelectedItem.Value; - RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!; - Ruleset rulesetInstance = gameplayRuleset.CreateInstance(); - - return item.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray(); - } - /// /// Validates the user mod style against the selected item and ruleset style. /// private void validateUserMods() { - Mod[] allowedMods = listAllowedMods(); + if (SelectedItem.Value == null) + return; + + PlaylistItem item = SelectedItem.Value; + RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!; + Mod[] allowedMods = ModUtils.ListUserSelectableFreeMods(MatchType.Playlists, item.RequiredMods, item.AllowedMods, item.Freestyle, gameplayRuleset.CreateInstance()); + UserMods.Value = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray(); } @@ -588,7 +579,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists IBeatmapInfo gameplayBeatmap = UserBeatmap.Value ?? item.Beatmap; RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!; Ruleset rulesetInstance = gameplayRuleset.CreateInstance(); - Mod[] allowedMods = listAllowedMods(); + Mod[] allowedMods = ModUtils.ListUserSelectableFreeMods(MatchType.Playlists, item.RequiredMods, item.AllowedMods, item.Freestyle, gameplayRuleset.CreateInstance()); // Update global gameplay state to correspond to the new selection. // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index d90ba943d4..27396f95e4 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -320,5 +320,32 @@ namespace osu.Game.Utils return isRequired ? mod.ValidForMultiplayer : mod.ValidForMultiplayerAsFreeMod; } } + + /// + /// Given an online listing of mods and the user's preferred ruleset, gathers the mods which are selectable as free mods by the current user. + /// + /// The type of match being played. + /// The required mods for the playlist item. + /// The allowed mods for the playlist item. + /// Whether freestyle is enabled for the playlist item. + /// The user's preferred ruleset, which may differ from the playlist item's selection on freestyle playlist items. + public static Mod[] ListUserSelectableFreeMods(MatchType matchType, IEnumerable requiredMods, IEnumerable allowedMods, bool freestyle, Ruleset userRuleset) + { + if (freestyle) + { + Mod[] rulesetRequiredMods = requiredMods.Select(m => m.ToMod(userRuleset)).ToArray(); + + // In freestyle, the playlist item doesn't provide the allowed mods. Instead, all mods are unconditionally allowed by default. + return userRuleset.AllMods.OfType() + // But the mods must still be compatible with the room... + .Where(m => IsValidModForMatch(m, matchType, false, true)) + // ... And compatible with the required mods listing (this also handles de-duplication). + .Where(m => CheckCompatibleSet(rulesetRequiredMods.Append(m))) + .ToArray(); + } + + // Without freestyle, only the mods specified by the playlist item are valid. + return allowedMods.Select(m => m.ToMod(userRuleset)).ToArray(); + } } } From ac7014992a4b1ee62e2646e2a58dcde779761827 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Mon, 14 Apr 2025 20:53:11 +0900 Subject: [PATCH 14/61] Fix missing binding --- osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 74db6a1b78..657c9cb869 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -215,7 +215,8 @@ namespace osu.Game.Screens.OnlinePlay { (freeModsFooterButton = new FooterButtonFreeMods(freeModSelect) { - FreeMods = { BindTarget = FreeMods } + FreeMods = { BindTarget = FreeMods }, + Freestyle = { BindTarget = Freestyle } }, null), (new FooterButtonFreestyle { From 64aafa4e4c7c2e4f32ec2c646bd50f34cfb6338d Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 17 Apr 2025 02:19:31 +0900 Subject: [PATCH 15/61] Apply changes from reviews --- osu.Game.Tests/Mods/ModUtilsTest.cs | 301 +++++++++------------------- osu.Game/Rulesets/Mods/IMod.cs | 6 + osu.Game/Utils/ModUtils.cs | 11 +- 3 files changed, 105 insertions(+), 213 deletions(-) diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index 074d9438d4..a006a13477 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -188,148 +188,6 @@ namespace osu.Game.Tests.Mods }, }; - private static readonly object[] invalid_multiplayer_mod_test_scenarios = - { - // incompatible pair. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModApproachDifferent() }, - new[] { typeof(OsuModHidden), typeof(OsuModApproachDifferent) } - }, - // incompatible pair with derived class. - new object[] - { - new Mod[] { new OsuModDeflate(), new OsuModApproachDifferent() }, - new[] { typeof(OsuModDeflate), typeof(OsuModApproachDifferent) } - }, - // system mod. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModTouchDevice() }, - new[] { typeof(OsuModTouchDevice) } - }, - // multi mod. - new object[] - { - new Mod[] { new MultiMod(new OsuModSuddenDeath(), new OsuModPerfect()) }, - new[] { typeof(MultiMod) } - }, - // invalid multiplayer mod. - new object[] - { - new Mod[] { new OsuModHidden(), new InvalidMultiplayerMod() }, - new[] { typeof(InvalidMultiplayerMod) } - }, - // invalid free mod is valid for multiplayer global. - new object[] - { - new Mod[] { new OsuModHidden(), new InvalidMultiplayerFreeMod() }, - Array.Empty() - }, - // valid pair. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModHardRock() }, - Array.Empty() - }, - }; - - private static readonly object[] invalid_free_mod_test_scenarios = - { - // system mod. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModTouchDevice() }, - new[] { typeof(OsuModTouchDevice) } - }, - // multi mod. - new object[] - { - new Mod[] { new MultiMod(new OsuModSuddenDeath(), new OsuModPerfect()) }, - new[] { typeof(MultiMod) } - }, - // invalid multiplayer mod. - new object[] - { - new Mod[] { new OsuModHidden(), new InvalidMultiplayerMod() }, - new[] { typeof(InvalidMultiplayerMod) } - }, - // invalid free mod. - new object[] - { - new Mod[] { new OsuModHidden(), new InvalidMultiplayerFreeMod() }, - new[] { typeof(InvalidMultiplayerFreeMod) } - }, - // incompatible pair is valid for free mods. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModApproachDifferent() }, - Array.Empty(), - }, - // incompatible pair with derived class is valid for free mods. - new object[] - { - new Mod[] { new OsuModDeflate(), new OsuModSpinIn() }, - Array.Empty(), - }, - // valid pair. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModHardRock() }, - Array.Empty() - }, - }; - - private static readonly object[] invalid_freestyle_required_mod_test_scenarios = - { - // system mod. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModTouchDevice() }, - new[] { typeof(OsuModTouchDevice) } - }, - // multi mod. - new object[] - { - new Mod[] { new MultiMod(new OsuModSuddenDeath(), new OsuModPerfect()) }, - new[] { typeof(MultiMod) } - }, - // invalid freestyle mod. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModNoScope(), new InvalidFreestyleMod() }, - new[] { typeof(OsuModNoScope), typeof(InvalidFreestyleMod) } - }, - // valid pair. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModHardRock() }, - Array.Empty() - }, - }; - - private static readonly object[] invalid_freestyle_allowed_mod_test_scenarios = - { - // system mod. - new object[] - { - new Mod[] { new OsuModHidden(), new OsuModTouchDevice() }, - new[] { typeof(OsuModHidden), typeof(OsuModTouchDevice) } - }, - // multi mod. - new object[] - { - new Mod[] { new MultiMod(new OsuModSuddenDeath(), new OsuModPerfect()) }, - new[] { typeof(MultiMod) } - }, - // invalid freestyle mod. - new object[] - { - new Mod[] { new OsuModHidden() }, - new[] { typeof(OsuModHidden) } - }, - }; - [TestCaseSource(nameof(invalid_mod_test_scenarios))] public void TestInvalidModScenarios(Mod[] inputMods, Type[] expectedInvalid) { @@ -343,32 +201,6 @@ namespace osu.Game.Tests.Mods Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); } - [TestCaseSource(nameof(invalid_multiplayer_mod_test_scenarios))] - public void TestInvalidMultiplayerModScenarios(Mod[] inputMods, Type[] expectedInvalid) - { - bool isValid = ModUtils.CheckValidRequiredModsForMultiplayer(inputMods, false, out var invalid); - - Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0)); - - if (isValid) - Assert.IsNull(invalid); - else - Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); - } - - [TestCaseSource(nameof(invalid_freestyle_required_mod_test_scenarios))] - public void TestInvalidFreestyleRequiredModScenarios(Mod[] inputMods, Type[] expectedInvalid) - { - bool isValid = ModUtils.CheckValidRequiredModsForMultiplayer(inputMods, true, out var invalid); - - Assert.That(isValid, Is.EqualTo(expectedInvalid.Length == 0)); - - if (isValid) - Assert.IsNull(invalid); - else - Assert.That(invalid?.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid)); - } - [Test] public void TestModBelongsToRuleset() { @@ -399,53 +231,102 @@ namespace osu.Game.Tests.Mods Assert.AreEqual(ModUtils.FormatScoreMultiplier(1.055).ToString(), "1.06x"); } - [Test] - public void TestRoomModValidity() + private static readonly object[] multiplayer_mod_test_scenarios = { - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), MatchType.Playlists, true, false)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), MatchType.Playlists, true, false)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), MatchType.Playlists, true, false)); - Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModAutoplay(), MatchType.Playlists, true, false)); - Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModTouchDevice(), MatchType.Playlists, true, false)); + // valid - as allowed mod. + new MultiplayerTestScenario(false, false, [new OsuModBarrelRoll()], []), + new MultiplayerTestScenario(false, true, [new OsuModBarrelRoll()], []), + // valid - as allowed mod (incompatible pair). + new MultiplayerTestScenario(false, false, [new OsuModHardRock(), new OsuModEasy()], []), + new MultiplayerTestScenario(false, true, [new OsuModHardRock(), new OsuModEasy()], []), + // valid - as allowed mod (incompatible pair with derived classes). + new MultiplayerTestScenario(false, false, [new OsuModDeflate(), new OsuModApproachDifferent()], []), + new MultiplayerTestScenario(false, true, [new OsuModDeflate(), new OsuModApproachDifferent()], []), + // valid - as allowed mod (not implemented in all rulesets). + new MultiplayerTestScenario(false, false, [new OsuModBarrelRoll()], []), + new MultiplayerTestScenario(false, true, [new OsuModBarrelRoll()], []), + // valid - as required mod. + new MultiplayerTestScenario(true, false, [new OsuModStrictTracking()], []), + // valid - as required mod when not freestyle. + new MultiplayerTestScenario(true, false, [new InvalidFreestyleRequiredMod()], []), + // valid - as required mod when freestyle (implemented in all rulesets). + new MultiplayerTestScenario(true, true, [new OsuModEasy()], []), + new MultiplayerTestScenario(true, true, [new OsuModNoFail()], []), + new MultiplayerTestScenario(true, true, [new OsuModHalfTime()], []), + new MultiplayerTestScenario(true, true, [new OsuModDaycore()], []), + new MultiplayerTestScenario(true, true, [new OsuModHardRock()], []), + new MultiplayerTestScenario(true, true, [new OsuModSuddenDeath()], []), + new MultiplayerTestScenario(true, true, [new OsuModPerfect()], []), + new MultiplayerTestScenario(true, true, [new OsuModDoubleTime()], []), + new MultiplayerTestScenario(true, true, [new OsuModNightcore()], []), + new MultiplayerTestScenario(true, true, [new OsuModHidden()], []), + new MultiplayerTestScenario(true, true, [new OsuModFlashlight()], []), + new MultiplayerTestScenario(true, true, [new OsuModAccuracyChallenge()], []), + new MultiplayerTestScenario(true, true, [new OsuModDifficultyAdjust()], []), + new MultiplayerTestScenario(true, true, [new ModWindUp()], []), + new MultiplayerTestScenario(true, true, [new ModWindDown()], []), + new MultiplayerTestScenario(true, true, [new OsuModMuted()], []), - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), MatchType.HeadToHead, true, false)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), MatchType.HeadToHead, true, false)); - // For now, adaptive speed isn't allowed in multiplayer because it's a per-user rate adjustment. - Assert.IsFalse(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), MatchType.HeadToHead, true, false)); - Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModAutoplay(), MatchType.HeadToHead, true, false)); - Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModTouchDevice(), MatchType.HeadToHead, true, false)); + // invalid - always (system mod) + new MultiplayerTestScenario(false, false, [new OsuModTouchDevice()], [typeof(OsuModTouchDevice)]), + new MultiplayerTestScenario(true, false, [new OsuModTouchDevice()], [typeof(OsuModTouchDevice)]), + // invalid - always (multi mod). + new MultiplayerTestScenario(false, false, [new MultiMod()], [typeof(MultiMod)]), + new MultiplayerTestScenario(true, false, [new MultiMod()], [typeof(MultiMod)]), + // invalid - always (disallowed by mod) + new MultiplayerTestScenario(false, false, [new InvalidMultiplayerMod()], [typeof(InvalidMultiplayerMod)]), + new MultiplayerTestScenario(true, false, [new InvalidMultiplayerMod()], [typeof(InvalidMultiplayerMod)]), + new MultiplayerTestScenario(false, false, [new OsuModAutoplay()], [typeof(OsuModAutoplay)]), + new MultiplayerTestScenario(true, false, [new OsuModAutoplay()], [typeof(OsuModAutoplay)]), + // invalid - always (changes play length - for now not allowed in multiplayer). + new MultiplayerTestScenario(false, false, [new ModAdaptiveSpeed()], [typeof(ModAdaptiveSpeed)]), + new MultiplayerTestScenario(true, false, [new ModAdaptiveSpeed()], [typeof(ModAdaptiveSpeed)]), + // invalid - as allowed mod (disallowed by mod). + new MultiplayerTestScenario(false, false, [new InvalidMultiplayerFreeMod()], [typeof(InvalidMultiplayerFreeMod)]), + new MultiplayerTestScenario(false, true, [new InvalidMultiplayerFreeMod()], [typeof(InvalidMultiplayerFreeMod)]), + // invalid - as allowed mod (changes play length - for now not allowed in multiplayer). + new MultiplayerTestScenario(false, false, [new OsuModHalfTime()], [typeof(OsuModHalfTime)]), + new MultiplayerTestScenario(false, false, [new OsuModDaycore()], [typeof(OsuModDaycore)]), + new MultiplayerTestScenario(false, false, [new OsuModDoubleTime()], [typeof(OsuModDoubleTime)]), + new MultiplayerTestScenario(false, false, [new OsuModNightcore()], [typeof(OsuModNightcore)]), + // invalid - as required mod (incompatible pair) + new MultiplayerTestScenario(true, false, [new OsuModHidden(), new OsuModApproachDifferent()], [typeof(OsuModHidden), typeof(OsuModApproachDifferent)]), + new MultiplayerTestScenario(true, true, [new OsuModHidden(), new OsuModApproachDifferent()], [typeof(OsuModHidden), typeof(OsuModApproachDifferent)]), + new MultiplayerTestScenario(true, false, [new OsuModDeflate(), new OsuModApproachDifferent()], [typeof(OsuModDeflate), typeof(OsuModApproachDifferent)]), + new MultiplayerTestScenario(true, true, [new OsuModDeflate(), new OsuModApproachDifferent()], [typeof(OsuModDeflate), typeof(OsuModApproachDifferent)]), + // invalid - as required mod when freestyle (disallowed by mod). + new MultiplayerTestScenario(true, true, [new InvalidFreestyleRequiredMod()], [typeof(InvalidFreestyleRequiredMod)]), + // invalid - as required mod when freestyle (not implemented in all rulesets). + new MultiplayerTestScenario(true, true, [new OsuModStrictTracking()], [typeof(OsuModStrictTracking)]), + new MultiplayerTestScenario(true, true, [new OsuModBarrelRoll()], [typeof(OsuModBarrelRoll)]), + }; + + [TestCaseSource(nameof(multiplayer_mod_test_scenarios))] + public void TestMultiplayerModScenarios(MultiplayerTestScenario scenario) + { + List? invalidMods; + bool isValid = scenario.IsRequired + ? ModUtils.CheckValidRequiredModsForMultiplayer(scenario.Mods, scenario.IsFreestyle, out invalidMods) + : ModUtils.CheckValidAllowedModsForMultiplayer(scenario.Mods, scenario.IsFreestyle, out invalidMods); + + Assert.That(isValid, Is.EqualTo(scenario.InvalidTypes.Length == 0)); + + if (isValid) + Assert.IsNull(invalidMods); + else + Assert.That(invalidMods?.Select(t => t.GetType()), Is.EquivalentTo(scenario.InvalidTypes)); } [Test] - public void TestRoomFreeModValidity() + public void TestPlaylistsModScenarios() { + // The rest are tested by TestMultiplayerModScenarios. Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), MatchType.Playlists, false, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), MatchType.Playlists, true, false)); Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), MatchType.Playlists, false, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), MatchType.Playlists, true, false)); Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), MatchType.Playlists, false, false)); - Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModAutoplay(), MatchType.Playlists, false, false)); - Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModTouchDevice(), MatchType.Playlists, false, false)); - - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), MatchType.HeadToHead, false, false)); - // For now, all rate adjustment mods aren't allowed as free mods in multiplayer. - Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), MatchType.HeadToHead, false, false)); - Assert.IsFalse(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), MatchType.HeadToHead, false, false)); - Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModAutoplay(), MatchType.HeadToHead, false, false)); - Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModTouchDevice(), MatchType.HeadToHead, false, false)); - } - - [Test] - public void TestFreestyleModValidity() - { - foreach (MatchType type in new[] { MatchType.Playlists, MatchType.HeadToHead }) - { - // Required mods - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), type, true, true)); - Assert.IsFalse(ModUtils.IsValidModForMatch(new OsuModBarrelRoll(), type, true, true)); - - // Allowed mods - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), type, false, true)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModBarrelRoll(), type, false, true)); - } + Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), MatchType.Playlists, true, false)); } [Test] @@ -502,7 +383,7 @@ namespace osu.Game.Tests.Mods public override bool ValidForMultiplayerAsFreeMod => false; } - public class InvalidFreestyleMod : Mod + public class InvalidFreestyleRequiredMod : Mod { public override string Name => string.Empty; public override LocalisableString Description => string.Empty; @@ -512,8 +393,12 @@ namespace osu.Game.Tests.Mods public override bool ValidForFreestyleAsRequiredMod => false; } - public interface IModCompatibilitySpecification + public interface IModCompatibilitySpecification; + + public readonly record struct MultiplayerTestScenario(bool IsRequired, bool IsFreestyle, Mod[] Mods, Type[] InvalidTypes, MatchType Type = MatchType.HeadToHead) { + public override string ToString() + => $"{IsRequired}, {IsFreestyle}, [{string.Join(',', Mods.Select(m => m.GetType().ReadableName()))}], [{string.Join(',', InvalidTypes.Select(t => t.ReadableName()))}]"; } } } diff --git a/osu.Game/Rulesets/Mods/IMod.cs b/osu.Game/Rulesets/Mods/IMod.cs index 3a33d14835..75d2d699ad 100644 --- a/osu.Game/Rulesets/Mods/IMod.cs +++ b/osu.Game/Rulesets/Mods/IMod.cs @@ -53,6 +53,12 @@ namespace osu.Game.Rulesets.Mods /// bool ValidForMultiplayer { get; } + /// + /// Whether this mod is valid as a required mod on freestyle online play items. + /// Should be true for mods that are guaranteed to be implemented across all rulesets. + /// + bool ValidForFreestyleAsRequiredMod { get; } + /// /// Whether this mod is valid as a free mod in multiplayer matches. /// Should be false for mods that affect the gameplay duration (e.g. and ). diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index 27396f95e4..e483811dab 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -128,7 +128,7 @@ namespace osu.Game.Utils } /// - /// Checks that all s in a combination are valid as "required mods" in a multiplayer match session. + /// Checks whether the given combination of mods may be set as the required mods of a multiplayer playlist item. /// /// The mods to check. /// Whether freestyle is enabled for the playlist item. @@ -146,11 +146,11 @@ namespace osu.Game.Utils if (!CheckCompatibleSet(mods, out invalidMods)) return false; - return checkValid(mods, m => m.Type != ModType.System && m.HasImplementation && m.ValidForMultiplayer && (!freestyle || m.ValidForFreestyleAsRequiredMod), out invalidMods); + return checkValid(mods, m => IsValidModForMatch(m, MatchType.HeadToHead, true, freestyle), out invalidMods); } /// - /// Checks that all s in a combination are valid as "free mods" in a multiplayer match session. + /// Checks whether the given mods are valid to appear as allowed mods in a multiplayer playlist item. /// /// /// Note that this does not check compatibility between mods, @@ -158,10 +158,11 @@ namespace osu.Game.Utils /// not to be confused with the list of mods the user currently has selected for the multiplayer match. /// /// The mods to check. + /// Whether freestyle is enabled for the playlist item. /// Invalid mods, if any were found. Will be null if all mods were valid. /// Whether the input mods were all valid. If false, will contain all invalid entries. - public static bool CheckValidFreeModsForMultiplayer(IEnumerable mods, [NotNullWhen(false)] out List? invalidMods) - => checkValid(mods, m => m.Type != ModType.System && m.HasImplementation && m.ValidForMultiplayerAsFreeMod && !(m is MultiMod), out invalidMods); + public static bool CheckValidAllowedModsForMultiplayer(IEnumerable mods, bool freestyle, [NotNullWhen(false)] out List? invalidMods) + => checkValid(mods, m => IsValidModForMatch(m, MatchType.HeadToHead, false, freestyle), out invalidMods); private static bool checkValid(IEnumerable mods, Predicate valid, [NotNullWhen(false)] out List? invalidMods) { From c283859babb6c740af0abbb99dc4509d29e68ef7 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 17 Apr 2025 02:34:10 +0900 Subject: [PATCH 16/61] Add/improve xmldocs --- osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs | 15 ++++++++++++++- osu.Game/Online/Rooms/PlaylistItem.cs | 14 +++++++++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs index f58a67294e..d8ed20a3a8 100644 --- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs +++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs @@ -6,6 +6,8 @@ using System.Collections.Generic; using System.Linq; using MessagePack; using osu.Game.Online.API; +using osu.Game.Rulesets.Mods; +using osu.Game.Utils; namespace osu.Game.Online.Rooms { @@ -28,9 +30,20 @@ namespace osu.Game.Online.Rooms [Key(4)] public int RulesetID { get; set; } + /// + /// Mods that should be applied for every participant in the room. + /// [Key(5)] public IEnumerable RequiredMods { get; set; } = Enumerable.Empty(); + /// + /// Mods that participants are allowed to apply at their own discretion. + /// + /// + /// This will be empty when is true, but participants may still select any mods from their choice of ruleset, + /// provided the mod implementation indicates free-mod validity + /// and is compatible with the rest of the user's selection. + /// [Key(6)] public IEnumerable AllowedMods { get; set; } = Enumerable.Empty(); @@ -57,7 +70,7 @@ namespace osu.Game.Online.Rooms public double StarRating { get; set; } /// - /// Indicates whether participants in the room are able to pick their own choice of beatmap difficulty and ruleset. + /// Indicates whether participants in the room are able to pick their own choice of beatmap difficulty, ruleset, and mods. /// [Key(11)] public bool Freestyle { get; set; } diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index 427f31fc64..6c3cc49de4 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; using Newtonsoft.Json; @@ -9,6 +10,7 @@ using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Rulesets.Mods; using osu.Game.Utils; namespace osu.Game.Online.Rooms @@ -37,9 +39,19 @@ namespace osu.Game.Online.Rooms [JsonProperty("played_at")] public DateTimeOffset? PlayedAt { get; set; } + /// + /// Mods that participants are allowed to apply at their own discretion. + /// + /// + /// This will be empty when is true, but participants may still select any mods from their choice of ruleset, + /// provided the mod is compatible with the rest of the user's selection. + /// [JsonProperty("allowed_mods")] public APIMod[] AllowedMods { get; set; } = Array.Empty(); + /// + /// Mods that should be applied for every participant in the room. + /// [JsonProperty("required_mods")] public APIMod[] RequiredMods { get; set; } = Array.Empty(); @@ -68,7 +80,7 @@ namespace osu.Game.Online.Rooms } /// - /// Indicates whether participants in the room are able to pick their own choice of beatmap difficulty and ruleset. + /// Indicates whether participants in the room are able to pick their own choice of beatmap difficulty, ruleset, and mods. /// [JsonProperty("freestyle")] public bool Freestyle { get; set; } From 2a09572d099461327c996159d09fb1da704b3089 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 17 Apr 2025 02:36:55 +0900 Subject: [PATCH 17/61] Adjust method name --- .../Multiplayer/Match/MultiplayerUserModSelectOverlay.cs | 2 +- .../Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs | 4 ++-- osu.Game/Utils/ModUtils.cs | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs index 75fc928e2a..d2c964c967 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs @@ -81,7 +81,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match MultiplayerPlaylistItem currentItem = client.Room.CurrentPlaylistItem; Ruleset ruleset = rulesets.GetRuleset(client.LocalUser.RulesetId ?? currentItem.RulesetID)!.CreateInstance(); - Mod[] allowedMods = ModUtils.ListUserSelectableFreeMods(client.Room.Settings.MatchType, currentItem.RequiredMods, currentItem.AllowedMods, currentItem.Freestyle, ruleset); + Mod[] allowedMods = ModUtils.EnumerateUserSelectableFreeMods(client.Room.Settings.MatchType, currentItem.RequiredMods, currentItem.AllowedMods, currentItem.Freestyle, ruleset); // Update the mod panels to reflect the ones which are valid for selection. IsValidMod = m => allowedMods.Any(a => a.GetType() == m.GetType()); diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index bdd3785153..e02f6b5cc8 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -561,7 +561,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists PlaylistItem item = SelectedItem.Value; RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!; - Mod[] allowedMods = ModUtils.ListUserSelectableFreeMods(MatchType.Playlists, item.RequiredMods, item.AllowedMods, item.Freestyle, gameplayRuleset.CreateInstance()); + Mod[] allowedMods = ModUtils.EnumerateUserSelectableFreeMods(MatchType.Playlists, item.RequiredMods, item.AllowedMods, item.Freestyle, gameplayRuleset.CreateInstance()); UserMods.Value = UserMods.Value.Where(m => allowedMods.Any(a => m.GetType() == a.GetType())).ToArray(); } @@ -579,7 +579,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists IBeatmapInfo gameplayBeatmap = UserBeatmap.Value ?? item.Beatmap; RulesetInfo gameplayRuleset = UserRuleset.Value ?? rulesets.GetRuleset(item.RulesetID)!; Ruleset rulesetInstance = gameplayRuleset.CreateInstance(); - Mod[] allowedMods = ModUtils.ListUserSelectableFreeMods(MatchType.Playlists, item.RequiredMods, item.AllowedMods, item.Freestyle, gameplayRuleset.CreateInstance()); + Mod[] allowedMods = ModUtils.EnumerateUserSelectableFreeMods(MatchType.Playlists, item.RequiredMods, item.AllowedMods, item.Freestyle, gameplayRuleset.CreateInstance()); // Update global gameplay state to correspond to the new selection. // Retrieve the corresponding local beatmap, since we can't directly use the playlist's beatmap info diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index e483811dab..f9b4c7587f 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -330,7 +330,7 @@ namespace osu.Game.Utils /// The allowed mods for the playlist item. /// Whether freestyle is enabled for the playlist item. /// The user's preferred ruleset, which may differ from the playlist item's selection on freestyle playlist items. - public static Mod[] ListUserSelectableFreeMods(MatchType matchType, IEnumerable requiredMods, IEnumerable allowedMods, bool freestyle, Ruleset userRuleset) + public static Mod[] EnumerateUserSelectableFreeMods(MatchType matchType, IEnumerable requiredMods, IEnumerable allowedMods, bool freestyle, Ruleset userRuleset) { if (freestyle) { From 5bda93aac6cbcba0e06c1dfca687b19c851d96ef Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 17 Apr 2025 18:25:01 +0900 Subject: [PATCH 18/61] Remove no longer relevant comments --- osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs | 1 - osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs | 2 -- 2 files changed, 3 deletions(-) diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs index bab88a269b..b7b53587ab 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs @@ -29,7 +29,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override bool Ranked => false; - // Ideally we'd allow this, but it's not easy to handle due to the change in acronym from the base class. public override bool ValidForFreestyleAsRequiredMod => false; [SettingSource("Coverage", "The proportion of playfield height that notes will be hidden for.")] diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs index bad895504e..f340608fd1 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs @@ -14,8 +14,6 @@ namespace osu.Game.Rulesets.Mania.Mods public override string Acronym => "FI"; public override LocalisableString Description => @"Keys appear out of nowhere!"; public override double ScoreMultiplier => 1; - - // Ideally we'd allow this, but it's not easy to handle due to the change in acronym from the base class. public override bool ValidForFreestyleAsRequiredMod => false; public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] From 95cf4887e12bfc2f008dfa2785390e5e08aebec8 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 17 Apr 2025 18:32:26 +0900 Subject: [PATCH 19/61] Use `IMod` documentation where present --- osu.Game/Rulesets/Mods/Mod.cs | 55 ----------------------------------- 1 file changed, 55 deletions(-) diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index bc1997a7b3..727db913e2 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -87,69 +87,17 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual bool HasImplementation => this is IApplicableMod; - /// - /// Whether this mod can be played by a real human user. - /// Non-user-playable mods are not viable for single-player score submission. - /// - /// - /// - /// is user-playable. - /// is not user-playable. - /// - /// [JsonIgnore] public virtual bool UserPlayable => true; - /// - /// Whether this mod can be specified as a "required" mod in a multiplayer context. - /// - /// - /// - /// is valid for multiplayer. - /// - /// is valid for multiplayer as long as it is a required mod, - /// as that ensures the same duration of gameplay for all users in the room. - /// - /// - /// is not valid for multiplayer, as it leads to varying - /// gameplay duration depending on how the users in the room play. - /// - /// is not valid for multiplayer. - /// - /// [JsonIgnore] public virtual bool ValidForMultiplayer => true; - /// - /// Whether this mod can be specified as a "required" mod on freestyle playlist items, indicating that all rulesets contain an implementation of this mod. - /// - /// - /// - /// is valid as a freestyle required-mod. - /// - /// OsuModNoScope is not valid, as it is only implemented in the osu! ruleset. - /// - /// - /// public virtual bool ValidForFreestyleAsRequiredMod => false; - /// - /// Whether this mod can be specified as a "free" or "allowed" mod in a multiplayer context. - /// - /// - /// - /// is valid for multiplayer as a free mod. - /// - /// is not valid for multiplayer as a free mod, - /// as it could to varying gameplay duration between users in the room depending on whether they picked it. - /// - /// is not valid for multiplayer as a free mod. - /// - /// [JsonIgnore] public virtual bool ValidForMultiplayerAsFreeMod => true; - /// [JsonIgnore] public virtual bool AlwaysValidForSubmission => false; @@ -159,9 +107,6 @@ namespace osu.Game.Rulesets.Mods [JsonIgnore] public virtual bool RequiresConfiguration => false; - /// - /// Whether scores with this mod active can give performance points. - /// [JsonIgnore] public virtual bool Ranked => false; From 92267c8bb3ba11ae9bb16145c053bd4a5e81ea30 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 17 Apr 2025 18:32:35 +0900 Subject: [PATCH 20/61] Improve `IMod` documentation --- osu.Game/Rulesets/Mods/IMod.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/osu.Game/Rulesets/Mods/IMod.cs b/osu.Game/Rulesets/Mods/IMod.cs index 75d2d699ad..5d4cc5fd12 100644 --- a/osu.Game/Rulesets/Mods/IMod.cs +++ b/osu.Game/Rulesets/Mods/IMod.cs @@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Mods IconUsage? Icon { get; } /// - /// Whether this mod is playable by an end user. + /// Whether this mod is playable by a real human user. /// Should be false for cases where the user is not interacting with the game (so it can be excluded from multiplayer selection, for example). /// bool UserPlayable { get; } @@ -54,7 +54,7 @@ namespace osu.Game.Rulesets.Mods bool ValidForMultiplayer { get; } /// - /// Whether this mod is valid as a required mod on freestyle online play items. + /// Whether this mod is valid as a required mod when freestyle is enabled. /// Should be true for mods that are guaranteed to be implemented across all rulesets. /// bool ValidForFreestyleAsRequiredMod { get; } From f0a8ddd513afa01f01809a5a3f2f81aa54f1aaf5 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Thu, 17 Apr 2025 19:12:13 +0900 Subject: [PATCH 21/61] Reorder function parameters, hopefully improve documentation --- osu.Game.Tests/Mods/ModUtilsTest.cs | 14 +++++------ .../OnlinePlay/OnlinePlaySongSelect.cs | 4 ++-- osu.Game/Utils/ModUtils.cs | 23 +++++++++++-------- 3 files changed, 22 insertions(+), 19 deletions(-) diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs index a006a13477..6b3bc5f10f 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -321,12 +321,12 @@ namespace osu.Game.Tests.Mods public void TestPlaylistsModScenarios() { // The rest are tested by TestMultiplayerModScenarios. - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), MatchType.Playlists, false, false)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), MatchType.Playlists, true, false)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), MatchType.Playlists, false, false)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), MatchType.Playlists, true, false)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), MatchType.Playlists, false, false)); - Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), MatchType.Playlists, true, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), false, MatchType.Playlists, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModHardRock(), true, MatchType.Playlists, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), false, MatchType.Playlists, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new OsuModDoubleTime(), true, MatchType.Playlists, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), false, MatchType.Playlists, false)); + Assert.IsTrue(ModUtils.IsValidModForMatch(new ModAdaptiveSpeed(), true, MatchType.Playlists, false)); } [Test] @@ -395,7 +395,7 @@ namespace osu.Game.Tests.Mods public interface IModCompatibilitySpecification; - public readonly record struct MultiplayerTestScenario(bool IsRequired, bool IsFreestyle, Mod[] Mods, Type[] InvalidTypes, MatchType Type = MatchType.HeadToHead) + public readonly record struct MultiplayerTestScenario(bool IsRequired, bool IsFreestyle, Mod[] Mods, Type[] InvalidTypes) { public override string ToString() => $"{IsRequired}, {IsFreestyle}, [{string.Join(',', Mods.Select(m => m.GetType().ReadableName()))}], [{string.Join(',', InvalidTypes.Select(t => t.ReadableName()))}]"; diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 657c9cb869..9cc1505675 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -232,14 +232,14 @@ namespace osu.Game.Screens.OnlinePlay /// /// The to check. /// Whether is a valid mod for online play. - private bool isValidGlobalMod(Mod mod) => ModUtils.IsValidModForMatch(mod, room.Type, true, Freestyle.Value); + private bool isValidGlobalMod(Mod mod) => ModUtils.IsValidModForMatch(mod, true, room.Type, Freestyle.Value); /// /// Checks whether a given is valid for per-player free-mod selection. /// /// The to check. /// Whether is a selectable free-mod. - private bool isValidFreeMod(Mod mod) => ModUtils.IsValidModForMatch(mod, room.Type, false, Freestyle.Value) + private bool isValidFreeMod(Mod mod) => ModUtils.IsValidModForMatch(mod, false, room.Type, Freestyle.Value) // Mod must not be contained in the required mods. && Mods.Value.All(m => m.Acronym != mod.Acronym) // Mod must be compatible with all the required mods. diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index f9b4c7587f..e944b188f1 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -146,7 +146,7 @@ namespace osu.Game.Utils if (!CheckCompatibleSet(mods, out invalidMods)) return false; - return checkValid(mods, m => IsValidModForMatch(m, MatchType.HeadToHead, true, freestyle), out invalidMods); + return checkValid(mods, m => IsValidModForMatch(m, true, MatchType.HeadToHead, freestyle), out invalidMods); } /// @@ -162,7 +162,7 @@ namespace osu.Game.Utils /// Invalid mods, if any were found. Will be null if all mods were valid. /// Whether the input mods were all valid. If false, will contain all invalid entries. public static bool CheckValidAllowedModsForMultiplayer(IEnumerable mods, bool freestyle, [NotNullWhen(false)] out List? invalidMods) - => checkValid(mods, m => IsValidModForMatch(m, MatchType.HeadToHead, false, freestyle), out invalidMods); + => checkValid(mods, m => IsValidModForMatch(m, false, MatchType.HeadToHead, freestyle), out invalidMods); private static bool checkValid(IEnumerable mods, Predicate valid, [NotNullWhen(false)] out List? invalidMods) { @@ -297,19 +297,22 @@ namespace osu.Game.Utils } /// - /// Determines whether a mod can be applied to playlist items in the match type. + /// Determines whether a given mod is valid on a playlist item. /// /// The mod to test. - /// The room match type. - /// Whether the mod is intended as a "required" (room-global) mod. - /// Whether freestyle is enabled for the playlist item. + /// + /// true if the mod is intended as a required mod on the target playlist item. + /// false if it is intended as an allowed mod. + /// + /// The type of match being played. + /// Whether the target playlist item enables freestyle mode. /// Related osu!web function. - public static bool IsValidModForMatch(Mod mod, MatchType matchType, bool isRequired, bool isFreestyle) + public static bool IsValidModForMatch(Mod mod, bool required, MatchType matchType, bool freestyle) { if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation) return false; - if (isFreestyle && isRequired && !mod.ValidForFreestyleAsRequiredMod) + if (freestyle && required && !mod.ValidForFreestyleAsRequiredMod) return false; switch (matchType) @@ -318,7 +321,7 @@ namespace osu.Game.Utils return true; default: - return isRequired ? mod.ValidForMultiplayer : mod.ValidForMultiplayerAsFreeMod; + return required ? mod.ValidForMultiplayer : mod.ValidForMultiplayerAsFreeMod; } } @@ -339,7 +342,7 @@ namespace osu.Game.Utils // In freestyle, the playlist item doesn't provide the allowed mods. Instead, all mods are unconditionally allowed by default. return userRuleset.AllMods.OfType() // But the mods must still be compatible with the room... - .Where(m => IsValidModForMatch(m, matchType, false, true)) + .Where(m => IsValidModForMatch(m, false, matchType, true)) // ... And compatible with the required mods listing (this also handles de-duplication). .Where(m => CheckCompatibleSet(rulesetRequiredMods.Append(m))) .ToArray(); From 5208d8a0b26a5d1ece5d715e54d7f29ee4d6ed8e Mon Sep 17 00:00:00 2001 From: Jamie Taylor Date: Fri, 18 Apr 2025 19:45:30 +0900 Subject: [PATCH 22/61] Add audio feedback to BSS process --- .../TestSceneSubmissionStageProgress.cs | 96 +++++++++++++++++++ .../Submission/BeatmapSubmissionScreen.cs | 14 ++- .../Submission/SubmissionStageProgress.cs | 53 +++++++++- 3 files changed, 161 insertions(+), 2 deletions(-) diff --git a/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs index 47414bb24e..51627f0baf 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs @@ -1,10 +1,14 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Overlays; using osu.Game.Screens.Edit.Submission; using osuTK; @@ -16,6 +20,11 @@ namespace osu.Game.Tests.Visual.Editing [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + [Resolved] + private AudioManager audio { get; set; } = null!; + + private Sample? completeSample; + [Test] public void TestAppearance() { @@ -43,5 +52,92 @@ namespace osu.Game.Tests.Visual.Editing AddStep("failed with long message", () => progress.SetFailed("this is a very very very very VERY VEEEEEEEEEEEEEEEEEEEEEEEEERY long error message like you would never believe")); AddStep("canceled", () => progress.SetCanceled()); } + + [Test] + public void TestAudioSequence() + { + SubmissionStageProgress[] stages = new SubmissionStageProgress[4]; + Container? cardContainer = null; + + AddStep("prepare", () => + { + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(1), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.8f), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + stages[0] = new SubmissionStageProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + StageDescription = "Export...", + StageIndex = 0 + }, + stages[1] = new SubmissionStageProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + StageDescription = "CreateSet...", + StageIndex = 1 + }, + stages[2] = new SubmissionStageProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + StageDescription = "Upload...", + StageIndex = 2 + }, + stages[3] = new SubmissionStageProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + StageDescription = "Update...", + StageIndex = 3 + }, + cardContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + } + }; + + completeSample = audio.Samples.Get(@"UI/bss-complete"); + }); + + for (int i = 0; i < stages.Length; i++) + { + int step = i; + AddStep($"{step}: not started", () => stages[step].SetNotStarted()); + AddStep($"{step}: indeterminate progress", () => stages[step].SetInProgress()); + AddStep($"{step}: 70% progress", () => stages[step].SetInProgress(0.25f)); + AddStep($"{step}: completed", () => stages[step].SetCompleted()); + } + + AddStep("pause for timing", () => { }); + + AddStep("Sequence Complete", () => + { + var beatmapSet = CreateAPIBeatmapSet(Ruleset.Value); + beatmapSet.Beatmaps = Enumerable.Repeat(beatmapSet.Beatmaps.First(), 100).ToArray(); + LoadComponentAsync(new BeatmapCardExtra(beatmapSet, false), loaded => + { + cardContainer?.Add(loaded); + completeSample?.Play(); + }); + }); + } } } diff --git a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs index 2ea710d3ab..94ed813461 100644 --- a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs +++ b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs @@ -8,6 +8,8 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Development; using osu.Framework.Extensions; using osu.Framework.Graphics; @@ -81,8 +83,10 @@ namespace osu.Game.Screens.Edit.Submission private Live? importedSet; + private Sample completedSample = null!; + [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { AddRangeInternal(new Drawable[] { @@ -118,24 +122,28 @@ namespace osu.Game.Screens.Edit.Submission createSetStep = new SubmissionStageProgress { StageDescription = BeatmapSubmissionStrings.Preparing, + StageIndex = 0, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, }, exportStep = new SubmissionStageProgress { StageDescription = BeatmapSubmissionStrings.Exporting, + StageIndex = 1, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, }, uploadStep = new SubmissionStageProgress { StageDescription = BeatmapSubmissionStrings.Uploading, + StageIndex = 2, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, }, updateStep = new SubmissionStageProgress { StageDescription = BeatmapSubmissionStrings.Finishing, + StageIndex = 3, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, }, @@ -181,6 +189,8 @@ namespace osu.Game.Screens.Edit.Submission } } }); + + completedSample = audio.Samples.Get(@"UI/bss-complete"); } private void createBeatmapSet() @@ -382,6 +392,8 @@ namespace osu.Game.Screens.Edit.Submission successContainer.Add(loaded); flashLayer.FadeOutFromOne(2000, Easing.OutQuint); }); + + completedSample.Play(); }; api.Queue(getBeatmapSetRequest); diff --git a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs index 101313c627..c47aea8a0a 100644 --- a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs +++ b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs @@ -1,13 +1,17 @@ // 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 osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -21,6 +25,8 @@ namespace osu.Game.Screens.Edit.Submission { public LocalisableString StageDescription { get; init; } + public int StageIndex { get; init; } + private Bindable status { get; } = new Bindable(); private Bindable progress { get; } = new Bindable(); @@ -33,8 +39,19 @@ namespace osu.Game.Screens.Edit.Submission [Resolved] private OsuColour colours { get; set; } = null!; + private Sample progressSample = null!; + + private const int stage_done_sample_count = 4; + private Sample stageDoneSample = null!; + + private Sample errorSample = null!; + private Sample cancelSample = null!; + + private double? lastSamplePlayback; + private float? previousPercent; + [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) + private void load(OverlayColourProvider colourProvider, AudioManager audio) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -111,6 +128,13 @@ namespace osu.Game.Screens.Edit.Submission } } }; + + errorSample = audio.Samples.Get(@"UI/generic-error"); + cancelSample = audio.Samples.Get(@"UI/notification-cancel"); + progressSample = audio.Samples.Get(@"UI/bss-progress"); + + int stageSample = Math.Min(stage_done_sample_count - 1, StageIndex); + stageDoneSample = audio.Samples.Get(@$"UI/bss-stage-{stageSample}"); } protected override void LoadComplete() @@ -119,6 +143,25 @@ namespace osu.Game.Screens.Edit.Submission status.BindValueChanged(_ => Scheduler.AddOnce(updateStatus), true); progress.BindValueChanged(_ => Scheduler.AddOnce(updateProgress), true); + + // Binding to `progressBar` updates instead of `progress` for more frequent/granular updates + progressBar.OnUpdate += playProgressSound; + } + + private void playProgressSound(Drawable box) + { + float width = box.Width; + SampleChannel sampleChannel = progressSample.GetChannel(); + + if (Precision.AlmostEquals(previousPercent ?? 0f, width) || (lastSamplePlayback != null && Time.Current - lastSamplePlayback < 10)) + return; + + sampleChannel.Frequency.Value = 0.5f + (width * 1.5f); + sampleChannel.Volume.Value = 0.25f + (width / 2f) * .75f; + sampleChannel.Play(); + + lastSamplePlayback = Time.Current; + previousPercent = width; } public void SetNotStarted() => status.Value = StageStatusType.NotStarted; @@ -176,6 +219,12 @@ namespace osu.Game.Screens.Edit.Submission }; iconContainer.Colour = colours.Green1; iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint); + + // manually set progress value, as to trigger sample playback for the final section + progress.Value = 1; + + stageDoneSample.Play(); + break; case StageStatusType.Failed: @@ -186,6 +235,7 @@ namespace osu.Game.Screens.Edit.Submission }; iconContainer.Colour = colours.Red1; iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint); + errorSample.Play(); break; case StageStatusType.Canceled: @@ -196,6 +246,7 @@ namespace osu.Game.Screens.Edit.Submission }; iconContainer.Colour = colours.Gray8; iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint); + cancelSample.Play(); break; } } From e5940a41b9ea8f4cac80e1f179af84fd770acca3 Mon Sep 17 00:00:00 2001 From: Jamie Taylor Date: Fri, 18 Apr 2025 20:42:49 +0900 Subject: [PATCH 23/61] More explicitly define arithmatic precedence --- osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs index c47aea8a0a..208e06d917 100644 --- a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs +++ b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs @@ -157,7 +157,7 @@ namespace osu.Game.Screens.Edit.Submission return; sampleChannel.Frequency.Value = 0.5f + (width * 1.5f); - sampleChannel.Volume.Value = 0.25f + (width / 2f) * .75f; + sampleChannel.Volume.Value = 0.25f + ((width / 2f) * .75f); sampleChannel.Play(); lastSamplePlayback = Time.Current; From a46a1f569b54175b4dd5e4547f1181b0c90114ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 21 Apr 2025 12:12:57 +0200 Subject: [PATCH 24/61] Add test coverage for mania hit windows with various mods active --- .../TestSceneLegacyReplayPlayback.cs | 434 +++++++++++++++--- 1 file changed, 361 insertions(+), 73 deletions(-) diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs index ea66386c9a..2a7f2dc7ea 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs @@ -297,34 +297,202 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { 3.1f, -123d, HitResult.Miss }, }; + private static readonly object[][] score_v1_non_convert_hard_rock_test_cases = + { + // OD = 5 test cases. + // This leads to "effective" OD of 7. + // PERFECT hit window is [-11ms, 11ms] + // GREAT hit window is [-35ms, 35ms] + // GOOD hit window is [-58ms, 58ms] + // OK hit window is [-80ms, 80ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-97ms, ----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 5f, -10d, HitResult.Perfect }, + new object[] { 5f, -11d, HitResult.Perfect }, + new object[] { 5f, -12d, HitResult.Great }, + new object[] { 5f, -13d, HitResult.Great }, + new object[] { 5f, -34d, HitResult.Great }, + new object[] { 5f, -35d, HitResult.Great }, + new object[] { 5f, -36d, HitResult.Good }, + new object[] { 5f, -37d, HitResult.Good }, + new object[] { 5f, -57d, HitResult.Good }, + new object[] { 5f, -58d, HitResult.Good }, + new object[] { 5f, -59d, HitResult.Ok }, + new object[] { 5f, -60d, HitResult.Ok }, + new object[] { 5f, -79d, HitResult.Ok }, + new object[] { 5f, -80d, HitResult.Ok }, + new object[] { 5f, -81d, HitResult.Meh }, + new object[] { 5f, -82d, HitResult.Meh }, + new object[] { 5f, -96d, HitResult.Meh }, + new object[] { 5f, -97d, HitResult.Meh }, + new object[] { 5f, -98d, HitResult.Miss }, + new object[] { 5f, -99d, HitResult.Miss }, + new object[] { 5f, 79d, HitResult.Ok }, + new object[] { 5f, 80d, HitResult.Miss }, + new object[] { 5f, 81d, HitResult.Miss }, + new object[] { 5f, 82d, HitResult.Miss }, + new object[] { 5f, 96d, HitResult.Miss }, + new object[] { 5f, 97d, HitResult.Miss }, + new object[] { 5f, 98d, HitResult.Miss }, + new object[] { 5f, 99d, HitResult.Miss }, + + // OD = 9.3 test cases. + // This leads to "effective" OD of 13.02. + // Note that contrary to other rulesets this does NOT cap out to OD 10! + // PERFECT hit window is [-11ms, 11ms] + // GREAT hit window is [-25ms, 25ms] + // GOOD hit window is [-49ms, 49ms] + // OK hit window is [-70ms, 70ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-87ms, ----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 9.3f, 10d, HitResult.Perfect }, + new object[] { 9.3f, 11d, HitResult.Perfect }, + new object[] { 9.3f, 12d, HitResult.Great }, + new object[] { 9.3f, 13d, HitResult.Great }, + new object[] { 9.3f, 24d, HitResult.Great }, + new object[] { 9.3f, 25d, HitResult.Great }, + new object[] { 9.3f, 26d, HitResult.Good }, + new object[] { 9.3f, 27d, HitResult.Good }, + new object[] { 9.3f, 48d, HitResult.Good }, + new object[] { 9.3f, 49d, HitResult.Good }, + new object[] { 9.3f, 50d, HitResult.Ok }, + new object[] { 9.3f, 51d, HitResult.Ok }, + new object[] { 9.3f, 69d, HitResult.Ok }, + new object[] { 9.3f, 70d, HitResult.Miss }, + new object[] { 9.3f, 71d, HitResult.Miss }, + new object[] { 9.3f, 72d, HitResult.Miss }, + new object[] { 9.3f, 86d, HitResult.Miss }, + new object[] { 9.3f, 87d, HitResult.Miss }, + new object[] { 9.3f, 88d, HitResult.Miss }, + new object[] { 9.3f, 89d, HitResult.Miss }, + new object[] { 9.3f, -69d, HitResult.Ok }, + new object[] { 9.3f, -70d, HitResult.Ok }, + new object[] { 9.3f, -71d, HitResult.Meh }, + new object[] { 9.3f, -72d, HitResult.Meh }, + new object[] { 9.3f, -86d, HitResult.Meh }, + new object[] { 9.3f, -87d, HitResult.Meh }, + new object[] { 9.3f, -88d, HitResult.Miss }, + new object[] { 9.3f, -89d, HitResult.Miss }, + }; + + private static readonly object[][] score_v1_non_convert_easy_test_cases = + { + // Assume OD = 5 (other values are not tested, even OD 5 is enough to exercise the flooring logic). + // PERFECT hit window is [ -22ms, 22ms] + // GREAT hit window is [ -68ms, 68ms] + // GOOD hit window is [-114ms, 114ms] + // OK hit window is [-156ms, 156ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-190ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 5f, -21d, HitResult.Perfect }, + new object[] { 5f, -22d, HitResult.Perfect }, + new object[] { 5f, -23d, HitResult.Great }, + new object[] { 5f, -24d, HitResult.Great }, + new object[] { 5f, -67d, HitResult.Great }, + new object[] { 5f, -68d, HitResult.Great }, + new object[] { 5f, -69d, HitResult.Good }, + new object[] { 5f, -70d, HitResult.Good }, + new object[] { 5f, -113d, HitResult.Good }, + new object[] { 5f, -114d, HitResult.Good }, + new object[] { 5f, -115d, HitResult.Ok }, + new object[] { 5f, -116d, HitResult.Ok }, + new object[] { 5f, -155d, HitResult.Ok }, + new object[] { 5f, -156d, HitResult.Ok }, + new object[] { 5f, -157d, HitResult.Meh }, + new object[] { 5f, -158d, HitResult.Meh }, + new object[] { 5f, -189d, HitResult.Meh }, + new object[] { 5f, -190d, HitResult.Meh }, + new object[] { 5f, -191d, HitResult.Miss }, + new object[] { 5f, -192d, HitResult.Miss }, + new object[] { 5f, 155d, HitResult.Ok }, + new object[] { 5f, 156d, HitResult.Miss }, + new object[] { 5f, 157d, HitResult.Miss }, + new object[] { 5f, 158d, HitResult.Miss }, + new object[] { 5f, 189d, HitResult.Miss }, + new object[] { 5f, 190d, HitResult.Miss }, + new object[] { 5f, 191d, HitResult.Miss }, + new object[] { 5f, 192d, HitResult.Miss }, + }; + + private static readonly object[][] score_v1_non_convert_double_time_test_cases = + { + // Assume OD = 5 (other values are not tested, even OD 5 is enough to exercise the flooring logic). + // PERFECT hit window is [ -24ms, 24ms] + // GREAT hit window is [ -73ms, 73ms] + // GOOD hit window is [-123ms, 123ms] + // OK hit window is [-168ms, 168ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-204ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 5f, -23d, HitResult.Perfect }, + new object[] { 5f, -24d, HitResult.Perfect }, + new object[] { 5f, -25d, HitResult.Great }, + new object[] { 5f, -26d, HitResult.Great }, + new object[] { 5f, -72d, HitResult.Great }, + new object[] { 5f, -73d, HitResult.Great }, + new object[] { 5f, -74d, HitResult.Good }, + new object[] { 5f, -75d, HitResult.Good }, + new object[] { 5f, -122d, HitResult.Good }, + new object[] { 5f, -123d, HitResult.Good }, + new object[] { 5f, -124d, HitResult.Ok }, + new object[] { 5f, -125d, HitResult.Ok }, + new object[] { 5f, -167d, HitResult.Ok }, + new object[] { 5f, -168d, HitResult.Ok }, + new object[] { 5f, -169d, HitResult.Meh }, + new object[] { 5f, -170d, HitResult.Meh }, + new object[] { 5f, -203d, HitResult.Meh }, + new object[] { 5f, -204d, HitResult.Meh }, + new object[] { 5f, -205d, HitResult.Miss }, + new object[] { 5f, -206d, HitResult.Miss }, + new object[] { 5f, 167d, HitResult.Ok }, + new object[] { 5f, 168d, HitResult.Miss }, + new object[] { 5f, 169d, HitResult.Miss }, + new object[] { 5f, 170d, HitResult.Miss }, + new object[] { 5f, 203d, HitResult.Miss }, + new object[] { 5f, 204d, HitResult.Miss }, + new object[] { 5f, 205d, HitResult.Miss }, + new object[] { 5f, 206d, HitResult.Miss }, + }; + + private static readonly object[][] score_v1_non_convert_half_time_test_cases = + { + // Assume OD = 5 (other values are not tested, even OD 5 is enough to exercise the flooring logic). + // PERFECT hit window is [ -12ms, 12ms] + // GREAT hit window is [ -36ms, 36ms] + // GOOD hit window is [ -61ms, 61ms] + // OK hit window is [ -84ms, 84ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-102ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 5f, -11d, HitResult.Perfect }, + new object[] { 5f, -12d, HitResult.Perfect }, + new object[] { 5f, -13d, HitResult.Great }, + new object[] { 5f, -14d, HitResult.Great }, + new object[] { 5f, -35d, HitResult.Great }, + new object[] { 5f, -36d, HitResult.Great }, + new object[] { 5f, -37d, HitResult.Good }, + new object[] { 5f, -38d, HitResult.Good }, + new object[] { 5f, -60d, HitResult.Good }, + new object[] { 5f, -61d, HitResult.Good }, + new object[] { 5f, -62d, HitResult.Ok }, + new object[] { 5f, -63d, HitResult.Ok }, + new object[] { 5f, -83d, HitResult.Ok }, + new object[] { 5f, -84d, HitResult.Ok }, + new object[] { 5f, -85d, HitResult.Meh }, + new object[] { 5f, -86d, HitResult.Meh }, + new object[] { 5f, -101d, HitResult.Meh }, + new object[] { 5f, -102d, HitResult.Meh }, + new object[] { 5f, -103d, HitResult.Miss }, + new object[] { 5f, -104d, HitResult.Miss }, + new object[] { 5f, 83d, HitResult.Ok }, + new object[] { 5f, 84d, HitResult.Miss }, + new object[] { 5f, 85d, HitResult.Miss }, + new object[] { 5f, 86d, HitResult.Miss }, + new object[] { 5f, 101d, HitResult.Miss }, + new object[] { 5f, 102d, HitResult.Miss }, + new object[] { 5f, 103d, HitResult.Miss }, + new object[] { 5f, 104d, HitResult.Miss }, + }; + + private const double note_time = 300; + [TestCaseSource(nameof(score_v2_test_cases))] public void TestHitWindowTreatmentWithScoreV2(float overallDifficulty, double hitOffset, HitResult expectedResult) { - const double note_time = 300; - - var cpi = new ControlPointInfo(); - cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); - var beatmap = new ManiaBeatmap(new StageDefinition(1)) - { - HitObjects = - { - new Note - { - StartTime = note_time, - Column = 0, - } - }, - Difficulty = new BeatmapDifficulty - { - OverallDifficulty = overallDifficulty, - CircleSize = 1, - }, - BeatmapInfo = - { - Ruleset = new ManiaRuleset().RulesetInfo, - }, - ControlPointInfo = cpi, - }; + var beatmap = createNonConvertBeatmap(overallDifficulty); var replay = new Replay { @@ -352,31 +520,7 @@ namespace osu.Game.Rulesets.Mania.Tests [TestCaseSource(nameof(score_v1_non_convert_test_cases))] public void TestHitWindowTreatmentWithScoreV1NonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) { - const double note_time = 300; - - var cpi = new ControlPointInfo(); - cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); - var beatmap = new ManiaBeatmap(new StageDefinition(1)) - { - HitObjects = - { - new Note - { - StartTime = note_time, - Column = 0, - } - }, - Difficulty = new BeatmapDifficulty - { - OverallDifficulty = overallDifficulty, - CircleSize = 1, - }, - BeatmapInfo = - { - Ruleset = new ManiaRuleset().RulesetInfo, - }, - ControlPointInfo = cpi, - }; + var beatmap = createNonConvertBeatmap(overallDifficulty); var replay = new Replay { @@ -403,29 +547,7 @@ namespace osu.Game.Rulesets.Mania.Tests [TestCaseSource(nameof(score_v1_convert_test_cases))] public void TestHitWindowTreatmentWithScoreV1Convert(float overallDifficulty, double hitOffset, HitResult expectedResult) { - const double note_time = 300; - - var cpi = new ControlPointInfo(); - cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); - var beatmap = new Beatmap - { - HitObjects = - { - new FakeCircle - { - StartTime = note_time, - } - }, - Difficulty = new BeatmapDifficulty - { - OverallDifficulty = overallDifficulty, - }, - BeatmapInfo = - { - Ruleset = new RulesetInfo { OnlineID = 0 } - }, - ControlPointInfo = cpi, - }; + var beatmap = createConvertBeatmap(overallDifficulty); var replay = new Replay { @@ -450,6 +572,172 @@ namespace osu.Game.Rulesets.Mania.Tests RunTest($@"SV1 convert single note @ OD{overallDifficulty}", beatmap, $@"SV1 convert {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); } + [TestCaseSource(nameof(score_v1_non_convert_hard_rock_test_cases))] + public void TestHitWindowTreatmentWithScoreV1AndHardRockNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createNonConvertBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + new ManiaReplayFrame(0), + new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1), + new ManiaReplayFrame(note_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new ManiaModHardRock()], + } + }; + + RunTest($@"SV1+HR single note @ OD{overallDifficulty}", beatmap, $@"SV1+HR {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(score_v1_non_convert_easy_test_cases))] + public void TestHitWindowTreatmentWithScoreV1AndEasyNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createNonConvertBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + new ManiaReplayFrame(0), + new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1), + new ManiaReplayFrame(note_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new ManiaModEasy()], + } + }; + + RunTest($@"SV1+EZ single note @ OD{overallDifficulty}", beatmap, $@"SV1+EZ {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(score_v1_non_convert_double_time_test_cases))] + public void TestHitWindowTreatmentWithScoreV1AndDoubleTimeNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createNonConvertBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + new ManiaReplayFrame(0), + new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1), + new ManiaReplayFrame(note_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new ManiaModDoubleTime()], + } + }; + + RunTest($@"SV1+DT single note @ OD{overallDifficulty}", beatmap, $@"SV1+DT {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(score_v1_non_convert_half_time_test_cases))] + public void TestHitWindowTreatmentWithScoreV1AndHalfTimeNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createNonConvertBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + new ManiaReplayFrame(0), + new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1), + new ManiaReplayFrame(note_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new ManiaModHalfTime()], + } + }; + + RunTest($@"SV1+HT single note @ OD{overallDifficulty}", beatmap, $@"SV1+HT {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + private static ManiaBeatmap createNonConvertBeatmap(float overallDifficulty) + { + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); + var beatmap = new ManiaBeatmap(new StageDefinition(1)) + { + HitObjects = + { + new Note + { + StartTime = note_time, + Column = 0, + } + }, + Difficulty = new BeatmapDifficulty + { + OverallDifficulty = overallDifficulty, + CircleSize = 1, + }, + BeatmapInfo = + { + Ruleset = new ManiaRuleset().RulesetInfo, + }, + ControlPointInfo = cpi, + }; + return beatmap; + } + + private static Beatmap createConvertBeatmap(float overallDifficulty) + { + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); + var beatmap = new Beatmap + { + HitObjects = + { + new FakeCircle + { + StartTime = note_time, + } + }, + Difficulty = new BeatmapDifficulty + { + OverallDifficulty = overallDifficulty, + }, + BeatmapInfo = + { + Ruleset = new RulesetInfo { OnlineID = 0 } + }, + ControlPointInfo = cpi, + }; + return beatmap; + } + private class FakeCircle : HitObject, IHasPosition { public float X From 1736f2d05668582b0d9450b6bdc2e1876cc99a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 21 Apr 2025 12:33:49 +0200 Subject: [PATCH 25/61] Add test coverage for taiko hit windows with various mods active --- .../TestSceneLegacyReplayPlayback.cs | 155 +++++++++++++++--- 1 file changed, 132 insertions(+), 23 deletions(-) diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs index 459312f2b4..5e71f974d8 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs @@ -7,6 +7,7 @@ using osu.Game.Beatmaps.ControlPoints; using osu.Game.Replays; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Beatmaps; +using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Replays; using osu.Game.Scoring; @@ -21,7 +22,7 @@ namespace osu.Game.Rulesets.Taiko.Tests protected override Ruleset CreateRuleset() => new TaikoRuleset(); - private static readonly object[][] test_cases = + private static readonly object[][] no_mod_test_cases = { // With respect to notation, // square brackets `[]` represent *closed* or *inclusive* bounds, @@ -52,30 +53,58 @@ namespace osu.Game.Rulesets.Taiko.Tests new object[] { 7.8f, -64d, HitResult.Miss }, }; - [TestCaseSource(nameof(test_cases))] + private static readonly object[][] hard_rock_test_cases = + { + // OD = 5 test cases. + // This leads to "effective" OD of 7. + // GREAT hit window is (-29ms, 29ms) + // OK hit window is (-68ms, 68ms) + new object[] { 5f, -27d, HitResult.Great }, + new object[] { 5f, -28d, HitResult.Great }, + new object[] { 5f, -29d, HitResult.Ok }, + new object[] { 5f, -30d, HitResult.Ok }, + new object[] { 5f, -66d, HitResult.Ok }, + new object[] { 5f, -67d, HitResult.Ok }, + new object[] { 5f, -68d, HitResult.Miss }, + new object[] { 5f, -69d, HitResult.Miss }, + + // OD = 7.8 test cases. + // This would lead to "effective" OD of 10.92, + // but the effects are capped to OD 10. + // GREAT hit window is (-20ms, 20ms) + // OK hit window is (-50ms, 50ms) + new object[] { 7.8f, -18d, HitResult.Great }, + new object[] { 7.8f, -19d, HitResult.Great }, + new object[] { 7.8f, -20d, HitResult.Ok }, + new object[] { 7.8f, -21d, HitResult.Ok }, + new object[] { 7.8f, -48d, HitResult.Ok }, + new object[] { 7.8f, -49d, HitResult.Ok }, + new object[] { 7.8f, -50d, HitResult.Miss }, + new object[] { 7.8f, -51d, HitResult.Miss }, + }; + + private static readonly object[][] easy_test_cases = + { + // OD = 5 test cases. + // This leads to "effective" OD of 2.5. + // GREAT hit window is ( -42ms, 42ms) + // OK hit window is (-100ms, 100ms) + new object[] { 5f, -40d, HitResult.Great }, + new object[] { 5f, -41d, HitResult.Great }, + new object[] { 5f, -42d, HitResult.Ok }, + new object[] { 5f, -43d, HitResult.Ok }, + new object[] { 5f, -98d, HitResult.Ok }, + new object[] { 5f, -99d, HitResult.Ok }, + new object[] { 5f, -100d, HitResult.Miss }, + new object[] { 5f, -101d, HitResult.Miss }, + }; + + private const double hit_time = 100; + + [TestCaseSource(nameof(no_mod_test_cases))] public void TestHitWindowTreatment(float overallDifficulty, double hitOffset, HitResult expectedResult) { - const double hit_time = 100; - - var cpi = new ControlPointInfo(); - cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); - var beatmap = new TaikoBeatmap - { - HitObjects = - { - new Hit - { - StartTime = hit_time, - Type = HitType.Centre, - } - }, - Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty }, - BeatmapInfo = - { - Ruleset = new TaikoRuleset().RulesetInfo, - }, - ControlPointInfo = cpi, - }; + var beatmap = createBeatmap(overallDifficulty); var replay = new Replay { @@ -98,5 +127,85 @@ namespace osu.Game.Rulesets.Taiko.Tests RunTest($@"single hit @ OD{overallDifficulty}", beatmap, $@"{hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); } + + [TestCaseSource(nameof(hard_rock_test_cases))] + public void TestHitWindowTreatmentWithHardRock(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time + hitOffset, TaikoAction.LeftCentre), + new TaikoReplayFrame(hit_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new TaikoModHardRock()] + } + }; + + RunTest($@"HR single hit @ OD{overallDifficulty}", beatmap, $@"HR {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(easy_test_cases))] + public void TestHitWindowTreatmentWithEasy(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time + hitOffset, TaikoAction.LeftCentre), + new TaikoReplayFrame(hit_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new TaikoModHardRock()] + } + }; + + RunTest($@"EZ single hit @ OD{overallDifficulty}", beatmap, $@"EZ {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + private static TaikoBeatmap createBeatmap(float overallDifficulty) + { + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); + var beatmap = new TaikoBeatmap + { + HitObjects = + { + new Hit + { + StartTime = hit_time, + Type = HitType.Centre, + } + }, + Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty }, + BeatmapInfo = + { + Ruleset = new TaikoRuleset().RulesetInfo, + }, + ControlPointInfo = cpi, + }; + return beatmap; + } } } From cff16ed02965bab5790c61c36db9f9115ae955b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Mon, 21 Apr 2025 12:41:02 +0200 Subject: [PATCH 26/61] Add test coverage for osu! hit windows with various mods active --- .../TestSceneLegacyReplayPlayback.cs | 176 +++++++++++++++--- 1 file changed, 153 insertions(+), 23 deletions(-) diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs index c22255bbdf..379699b276 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs @@ -6,6 +6,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Replays; using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Osu.UI; @@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Tests protected override string? ExportLocation => null; - private static readonly object[][] test_cases = + private static readonly object[][] no_mod_test_cases = { // With respect to notation, // square brackets `[]` represent *closed* or *inclusive* bounds, @@ -65,30 +66,73 @@ namespace osu.Game.Rulesets.Osu.Tests new object[] { 5.7f, 144d, HitResult.Miss }, }; - [TestCaseSource(nameof(test_cases))] + private static readonly object[][] hard_rock_test_cases = + { + // OD = 5 test cases. + // This leads to "effective" OD of 7. + // GREAT hit window is ( -38ms, 38ms) + // OK hit window is ( -84ms, 84ms) + // MEH hit window is (-130ms, 130ms) + new object[] { 5f, 36d, HitResult.Great }, + new object[] { 5f, 37d, HitResult.Great }, + new object[] { 5f, 38d, HitResult.Ok }, + new object[] { 5f, 39d, HitResult.Ok }, + new object[] { 5f, 82d, HitResult.Ok }, + new object[] { 5f, 83d, HitResult.Ok }, + new object[] { 5f, 84d, HitResult.Meh }, + new object[] { 5f, 85d, HitResult.Meh }, + new object[] { 5f, 128d, HitResult.Meh }, + new object[] { 5f, 129d, HitResult.Meh }, + new object[] { 5f, 130d, HitResult.Miss }, + new object[] { 5f, 131d, HitResult.Miss }, + + // OD = 8 test cases. + // This would lead to "effective" OD of 11.2, + // but the effects are capped to OD 10. + // GREAT hit window is ( -20ms, 20ms) + // OK hit window is ( -60ms, 60ms) + // MEH hit window is (-100ms, 100ms) + new object[] { 8f, 18d, HitResult.Great }, + new object[] { 8f, 19d, HitResult.Great }, + new object[] { 8f, 20d, HitResult.Ok }, + new object[] { 8f, 21d, HitResult.Ok }, + new object[] { 8f, 58d, HitResult.Ok }, + new object[] { 8f, 59d, HitResult.Ok }, + new object[] { 8f, 60d, HitResult.Meh }, + new object[] { 8f, 61d, HitResult.Meh }, + new object[] { 8f, 98d, HitResult.Meh }, + new object[] { 8f, 99d, HitResult.Meh }, + new object[] { 8f, 100d, HitResult.Miss }, + new object[] { 8f, 101d, HitResult.Miss }, + }; + + private static readonly object[][] easy_test_cases = + { + // OD = 5 test cases. + // This leads to "effective" OD of 2.5. + // GREAT hit window is ( -65ms, 65ms) + // OK hit window is (-120ms, 120ms) + // MEH hit window is (-175ms, 175ms) + new object[] { 5f, 63d, HitResult.Great }, + new object[] { 5f, 64d, HitResult.Great }, + new object[] { 5f, 65d, HitResult.Ok }, + new object[] { 5f, 66d, HitResult.Ok }, + new object[] { 5f, 118d, HitResult.Ok }, + new object[] { 5f, 119d, HitResult.Ok }, + new object[] { 5f, 120d, HitResult.Meh }, + new object[] { 5f, 121d, HitResult.Meh }, + new object[] { 5f, 173d, HitResult.Meh }, + new object[] { 5f, 174d, HitResult.Meh }, + new object[] { 5f, 175d, HitResult.Miss }, + new object[] { 5f, 176d, HitResult.Miss }, + }; + + private const double hit_circle_time = 100; + + [TestCaseSource(nameof(no_mod_test_cases))] public void TestHitWindowTreatment(float overallDifficulty, double hitOffset, HitResult expectedResult) { - const double hit_circle_time = 100; - - var cpi = new ControlPointInfo(); - cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); - var beatmap = new OsuBeatmap - { - HitObjects = - { - new HitCircle - { - StartTime = hit_circle_time, - Position = OsuPlayfield.BASE_SIZE / 2 - } - }, - Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty }, - BeatmapInfo = - { - Ruleset = new OsuRuleset().RulesetInfo, - }, - ControlPointInfo = cpi, - }; + var beatmap = createBeatmap(overallDifficulty); var replay = new Replay { @@ -114,5 +158,91 @@ namespace osu.Game.Rulesets.Osu.Tests RunTest($@"single circle @ OD{overallDifficulty}", beatmap, $@"{hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); } + + [TestCaseSource(nameof(hard_rock_test_cases))] + public void TestHitWindowTreatmentWithHardRock(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + // required for correct playback in stable + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, OsuPlayfield.BASE_SIZE / 2), + new OsuReplayFrame(hit_circle_time + hitOffset, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton), + new OsuReplayFrame(hit_circle_time + hitOffset + 20, OsuPlayfield.BASE_SIZE / 2), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new OsuModHardRock()] + } + }; + + RunTest($@"HR single circle @ OD{overallDifficulty}", beatmap, $@"HR {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(easy_test_cases))] + public void TestHitWindowTreatmentWithEasy(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + // required for correct playback in stable + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, OsuPlayfield.BASE_SIZE / 2), + new OsuReplayFrame(hit_circle_time + hitOffset, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton), + new OsuReplayFrame(hit_circle_time + hitOffset + 20, OsuPlayfield.BASE_SIZE / 2), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new OsuModEasy()] + } + }; + + RunTest($@"EZ single circle @ OD{overallDifficulty}", beatmap, $@"EZ {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + private static OsuBeatmap createBeatmap(float overallDifficulty) + { + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); + var beatmap = new OsuBeatmap + { + HitObjects = + { + new HitCircle + { + StartTime = hit_circle_time, + Position = OsuPlayfield.BASE_SIZE / 2 + } + }, + Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty }, + BeatmapInfo = + { + Ruleset = new OsuRuleset().RulesetInfo, + }, + ControlPointInfo = cpi, + }; + return beatmap; + } } } From cb807c3c24d7155bab209cd654f338588dc23c41 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 22 Apr 2025 03:00:37 +0900 Subject: [PATCH 27/61] Update resources --- osu.Game/osu.Game.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 54a2820a62..4ba48a2c0a 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,7 +36,7 @@ - + From 3472b6af91e15b1634d3ac461fe4c35cf24f76e4 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 18 Apr 2025 19:48:56 +0900 Subject: [PATCH 28/61] Add test coverage of carousel update scenarios --- .../TestSceneBeatmapCarouselUpdateHandling.cs | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs new file mode 100644 index 0000000000..236bd59772 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs @@ -0,0 +1,137 @@ +// 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; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Extensions; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + [TestFixture] + public partial class TestSceneBeatmapCarouselUpdateHandling : BeatmapCarouselTestScene + { + private BeatmapSetInfo baseTestBeatmap = null!; + + [SetUpSteps] + public void SetUpSteps() + { + RemoveAllBeatmaps(); + CreateCarousel(); + AddBeatmaps(1, 3); + AddStep("generate and add test beatmap", () => + { + baseTestBeatmap = TestResources.CreateTestBeatmapSetInfo(); + + var metadata = new BeatmapMetadata + { + Artist = "update test", + Title = "beatmap", + }; + + foreach (var b in baseTestBeatmap.Beatmaps) + b.Metadata = metadata; + BeatmapSets.Add(baseTestBeatmap); + }); + + WaitForSorting(); + } + + [Test] + public void TestBeatmapSetUpdatedNoop() + { + List originalDrawables = new List(); + + AddStep("store drawable references", () => + { + originalDrawables.Clear(); + originalDrawables.AddRange(Carousel.ChildrenOfType().ToList()); + }); + + AddStep("update beatmap with same reference", () => BeatmapSets.ReplaceRange(1, 1, [baseTestBeatmap])); + + WaitForSorting(); + AddAssert("drawables unchanged", () => Carousel.ChildrenOfType(), () => Is.EqualTo(originalDrawables)); + } + + [Test] + public void TestBeatmapSetMetadataUpdated() + { + var metadata = new BeatmapMetadata + { + Artist = "updated test", + Title = "new beatmap title", + }; + + List originalDrawables = new List(); + + AddStep("store drawable references", () => + { + originalDrawables.Clear(); + originalDrawables.AddRange(Carousel.ChildrenOfType().ToList()); + }); + + updateBeatmap(b => b.Metadata = metadata); + + WaitForSorting(); + AddAssert("drawables changed", () => Carousel.ChildrenOfType(), () => Is.Not.EqualTo(originalDrawables)); + } + + private void updateBeatmap(Action? updateBeatmap = null, Action? updateSet = null) + { + AddStep("update beatmap with different reference", () => + { + var updatedSet = new BeatmapSetInfo + { + ID = baseTestBeatmap.ID, + OnlineID = baseTestBeatmap.OnlineID, + DateAdded = baseTestBeatmap.DateAdded, + DateSubmitted = baseTestBeatmap.DateSubmitted, + DateRanked = baseTestBeatmap.DateRanked, + Status = baseTestBeatmap.Status, + StatusInt = baseTestBeatmap.StatusInt, + DeletePending = baseTestBeatmap.DeletePending, + Hash = baseTestBeatmap.Hash, + Protected = baseTestBeatmap.Protected, + }; + + updateSet?.Invoke(updatedSet); + + var updatedBeatmaps = baseTestBeatmap.Beatmaps.Select(b => + { + var updatedBeatmap = new BeatmapInfo + { + ID = b.ID, + Metadata = b.Metadata, + Ruleset = b.Ruleset, + DifficultyName = b.DifficultyName, + BeatmapSet = updatedSet, + Status = b.Status, + OnlineID = b.OnlineID, + Length = b.Length, + BPM = b.BPM, + Hash = b.Hash, + StarRating = b.StarRating, + MD5Hash = b.MD5Hash, + OnlineMD5Hash = b.OnlineMD5Hash, + }; + + updateBeatmap?.Invoke(updatedBeatmap); + + return updatedBeatmap; + }).ToList(); + + updatedSet.Beatmaps.AddRange(updatedBeatmaps); + + int originalIndex = BeatmapSets.IndexOf(baseTestBeatmap); + + BeatmapSets.ReplaceRange(originalIndex, 1, [updatedSet]); + }); + } + } +} From ac6747343318f0a03b990f55f115380be9f28f69 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 18 Apr 2025 19:22:09 +0900 Subject: [PATCH 29/61] Change equality to allow non-reference comparisons This is required to hold selection when beatmaps are updates, as one important case. --- osu.Game/Graphics/Carousel/Carousel.cs | 16 ++++++++++++---- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 15 +++++++++++++-- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index 3a02eb7119..bbd469800c 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -166,6 +166,11 @@ namespace osu.Game.Graphics.Carousel /// protected virtual Task FilterAsync() => filterTask = performFilter(); + /// + /// Check whether two models are the same for display purposes. + /// + protected virtual bool CheckModelEquality(object x, object y) => ReferenceEquals(x, y); + /// /// Create a drawable for the given carousel item so it can be displayed. /// @@ -490,10 +495,10 @@ namespace osu.Game.Graphics.Carousel updateItemYPosition(item, ref lastVisible, ref yPos); - if (ReferenceEquals(item.Model, currentKeyboardSelection.Model)) + if (CheckModelEquality(item.Model, currentKeyboardSelection.Model!)) currentKeyboardSelection = new Selection(item.Model, item, item.CarouselYPosition, i); - if (ReferenceEquals(item.Model, currentSelection.Model)) + if (CheckModelEquality(item.Model, currentSelection.Model!)) currentSelection = new Selection(item.Model, item, item.CarouselYPosition, i); } @@ -578,7 +583,7 @@ namespace osu.Game.Graphics.Carousel panel.X = GetPanelXOffset(panel); - c.Selected.Value = c.Item == currentSelection?.CarouselItem; + c.Selected.Value = currentSelection?.CarouselItem != null && CheckModelEquality(c.Item, currentSelection.CarouselItem); c.KeyboardSelected.Value = c.Item == currentKeyboardSelection?.CarouselItem; c.Expanded.Value = c.Item.IsExpanded; } @@ -644,7 +649,10 @@ namespace osu.Game.Graphics.Carousel // The case where we're intending to display this panel, but it's already displayed. // Note that we **must compare the model here** as the CarouselItems may be fresh instances due to a filter operation. - var existing = toDisplay.FirstOrDefault(i => i.Model == carouselPanel.Item!.Model); + // + // Reference equality is used here instead of CheckModelEquality intentionally. In order to switch to `CheckModelEquality`, + // we need a way to signal to the drawable panels that there is an update. + var existing = toDisplay.FirstOrDefault(i => ReferenceEquals(i.Model, carouselPanel.Item!.Model)); if (existing != null) { diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 9cb7d152de..1e33e4e04b 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -132,7 +132,7 @@ namespace osu.Game.Screens.SelectV2 return; case BeatmapInfo beatmapInfo: - if (ReferenceEquals(CurrentSelection, beatmapInfo)) + if (CurrentSelection != null && CheckModelEquality(CurrentSelection, beatmapInfo)) { RequestPresentBeatmap?.Invoke(beatmapInfo); return; @@ -155,7 +155,7 @@ namespace osu.Game.Screens.SelectV2 case BeatmapInfo beatmapInfo: // Find any containing group. There should never be too many groups so iterating is efficient enough. - GroupDefinition? containingGroup = grouping.GroupItems.SingleOrDefault(kvp => kvp.Value.Any(i => ReferenceEquals(i.Model, beatmapInfo))).Key; + GroupDefinition? containingGroup = grouping.GroupItems.SingleOrDefault(kvp => kvp.Value.Any(i => CheckModelEquality(i.Model, beatmapInfo))).Key; if (containingGroup != null) setExpandedGroup(containingGroup); @@ -311,6 +311,17 @@ namespace osu.Game.Screens.SelectV2 AddInternal(setPanelPool); } + protected override bool CheckModelEquality(object x, object y) + { + if (x is BeatmapSetInfo beatmapSetX && y is BeatmapSetInfo beatmapSetY) + return beatmapSetX.Equals(beatmapSetY); + + if (x is BeatmapInfo beatmapX && y is BeatmapInfo beatmapY) + return beatmapX.Equals(beatmapY); + + return base.CheckModelEquality(x, y); + } + protected override Drawable GetDrawableForDisplay(CarouselItem item) { switch (item.Model) From 6f97667889e0b5ec320a068a767d12719e3adad7 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 22 Apr 2025 19:46:21 +0900 Subject: [PATCH 30/61] Add basic support for beatmap updates in `BeatmapCarousel` --- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 47 +++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 1e33e4e04b..6b486d7da6 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using System.Threading; using osu.Framework.Allocation; @@ -74,18 +75,17 @@ namespace osu.Game.Screens.SelectV2 { // TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider. // right now we are managing this locally which is a bit of added overhead. - IEnumerable? newBeatmapSets = changed.NewItems?.Cast(); - IEnumerable? beatmapSetInfos = changed.OldItems?.Cast(); + IEnumerable? newItems = changed.NewItems?.Cast(); + IEnumerable? oldItems = changed.OldItems?.Cast(); switch (changed.Action) { case NotifyCollectionChangedAction.Add: - Items.AddRange(newBeatmapSets!.SelectMany(s => s.Beatmaps)); + Items.AddRange(newItems!.SelectMany(s => s.Beatmaps)); break; case NotifyCollectionChangedAction.Remove: - - foreach (var set in beatmapSetInfos!) + foreach (var set in oldItems!) { foreach (var beatmap in set.Beatmaps) Items.RemoveAll(i => i is BeatmapInfo bi && beatmap.Equals(bi)); @@ -94,8 +94,43 @@ namespace osu.Game.Screens.SelectV2 break; case NotifyCollectionChangedAction.Move: + // We can ignore move operations as we are applying our own sort in all cases. + break; + case NotifyCollectionChangedAction.Replace: - throw new NotImplementedException(); + var oldSetBeatmaps = oldItems!.Single().Beatmaps; + var newSetBeatmaps = newItems!.Single().Beatmaps.ToList(); + + // Handling replace operations is a touch manual, as we need to locally diff the beatmaps of each version of the beatmap set. + // Matching is done based on difficulty names as these are the most stable thing between updates (which are usually triggered + // by users editing the beatmap or by difficulty/metadata recomputation). + // + // In the case of difficulty reprocessing, this will trigger multiple times per beatmap as it's always triggering a set update. + // We may want to look to improve this in the future either here or at the source (only trigger an update after all difficulties + // have been processed) if it becomes an issue for animation or performance reasons. + foreach (var beatmap in oldSetBeatmaps) + { + int previousIndex = Items.IndexOf(beatmap); + Debug.Assert(previousIndex >= 0); + + BeatmapInfo? matchingNewBeatmap = newSetBeatmaps.SingleOrDefault(b => b.DifficultyName == beatmap.DifficultyName && b.Ruleset.Equals(beatmap.Ruleset)); + + if (matchingNewBeatmap != null) + { + Items.ReplaceRange(previousIndex, 1, [matchingNewBeatmap]); + newSetBeatmaps.Remove(matchingNewBeatmap); + } + else + { + Items.RemoveAt(previousIndex); + } + } + + // Add any items which weren't found in the previous pass (difficulty names didn't match). + foreach (var beatmap in newSetBeatmaps) + Items.Add(beatmap); + + break; case NotifyCollectionChangedAction.Reset: Items.Clear(); From 615c7b29b59d5f9660d38b6f53c76d37bdc039aa Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Tue, 22 Apr 2025 21:01:36 +0900 Subject: [PATCH 31/61] Ensure selection is retained over beatmap update --- .../TestSceneBeatmapCarouselUpdateHandling.cs | 20 +++++++++++++++++-- osu.Game/Graphics/Carousel/Carousel.cs | 4 ++-- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 6 ++++++ 3 files changed, 26 insertions(+), 4 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs index 236bd59772..d1d73e141a 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs @@ -26,7 +26,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddBeatmaps(1, 3); AddStep("generate and add test beatmap", () => { - baseTestBeatmap = TestResources.CreateTestBeatmapSetInfo(); + baseTestBeatmap = TestResources.CreateTestBeatmapSetInfo(3); var metadata = new BeatmapMetadata { @@ -82,6 +82,22 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddAssert("drawables changed", () => Carousel.ChildrenOfType(), () => Is.Not.EqualTo(originalDrawables)); } + [Test] + public void TestSelectionHeld() + { + SelectPrevGroup(); + + WaitForSelection(1, 0); + AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + + updateBeatmap(); + WaitForSorting(); + + AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + } + private void updateBeatmap(Action? updateBeatmap = null, Action? updateSet = null) { AddStep("update beatmap with different reference", () => @@ -89,7 +105,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 var updatedSet = new BeatmapSetInfo { ID = baseTestBeatmap.ID, - OnlineID = baseTestBeatmap.OnlineID, + OnlineID = 99999, // this is just for tracking / debug purposes at the moment. DateAdded = baseTestBeatmap.DateAdded, DateSubmitted = baseTestBeatmap.DateSubmitted, DateRanked = baseTestBeatmap.DateRanked, diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index bbd469800c..8d8289422b 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -496,10 +496,10 @@ namespace osu.Game.Graphics.Carousel updateItemYPosition(item, ref lastVisible, ref yPos); if (CheckModelEquality(item.Model, currentKeyboardSelection.Model!)) - currentKeyboardSelection = new Selection(item.Model, item, item.CarouselYPosition, i); + currentKeyboardSelection = new Selection(currentKeyboardSelection.Model, item, item.CarouselYPosition, i); if (CheckModelEquality(item.Model, currentSelection.Model!)) - currentSelection = new Selection(item.Model, item, item.CarouselYPosition, i); + currentSelection = new Selection(currentSelection.Model, item, item.CarouselYPosition, i); } // If a keyboard selection is currently made, we want to keep the view stable around the selection. diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 6b486d7da6..3294b9e8a2 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -117,6 +117,11 @@ namespace osu.Game.Screens.SelectV2 if (matchingNewBeatmap != null) { + // TODO: should this exist in song select instead of here? + // we need to ensure the global beatmap is also updated alongside changes. + if (CurrentSelection != null && CheckModelEquality(beatmap, CurrentSelection)) + CurrentSelection = matchingNewBeatmap; + Items.ReplaceRange(previousIndex, 1, [matchingNewBeatmap]); newSetBeatmaps.Remove(matchingNewBeatmap); } @@ -348,6 +353,7 @@ namespace osu.Game.Screens.SelectV2 protected override bool CheckModelEquality(object x, object y) { + // TODO: this doesn't check online ID. probably need to account for that. if (x is BeatmapSetInfo beatmapSetX && y is BeatmapSetInfo beatmapSetY) return beatmapSetX.Equals(beatmapSetY); From a6921ad56618522caf62222f74a923e3d95e36bc Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 23 Apr 2025 13:37:45 +0900 Subject: [PATCH 32/61] Apply suggestions from code review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bartłomiej Dach --- .../Visual/Editing/TestSceneSubmissionStageProgress.cs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs index 51627f0baf..693b88a12f 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs @@ -122,11 +122,12 @@ namespace osu.Game.Tests.Visual.Editing int step = i; AddStep($"{step}: not started", () => stages[step].SetNotStarted()); AddStep($"{step}: indeterminate progress", () => stages[step].SetInProgress()); - AddStep($"{step}: 70% progress", () => stages[step].SetInProgress(0.25f)); + AddStep($"{step}: 25% progress", () => stages[step].SetInProgress(0.25f)); + AddStep($"{step}: 70% progress", () => stages[step].SetInProgress(0.7f)); AddStep($"{step}: completed", () => stages[step].SetCompleted()); } - AddStep("pause for timing", () => { }); + AddWaitStep("pause for timing", 1); AddStep("Sequence Complete", () => { From f23eb995276a5bcf9b0370ef50640299bd3801cb Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 23 Apr 2025 15:04:44 +0900 Subject: [PATCH 33/61] Rename methods --- .../OnlinePlay/OnlinePlaySongSelect.cs | 34 +++++++++---------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 9cc1505675..07cdb1a172 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -67,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay freeModSelect = new FreeModSelectOverlay { SelectedMods = { BindTarget = FreeMods }, - IsValidMod = isValidFreeMod, + IsValidMod = isValidAllowedMod, }; } @@ -125,10 +125,10 @@ namespace osu.Game.Screens.OnlinePlay private void onFreestyleChanged(ValueChangedEvent enabled) { // Remove invalid mods and display the newly available mod panels. - Mods.Value = Mods.Value.Where(isValidGlobalMod).ToArray(); - ModSelect.IsValidMod = isValidGlobalMod; - FreeMods.Value = FreeMods.Value.Where(isValidFreeMod).ToArray(); - freeModSelect.IsValidMod = isValidFreeMod; + Mods.Value = Mods.Value.Where(isValidRequiredMod).ToArray(); + ModSelect.IsValidMod = isValidRequiredMod; + FreeMods.Value = FreeMods.Value.Where(isValidAllowedMod).ToArray(); + freeModSelect.IsValidMod = isValidAllowedMod; if (enabled.NewValue) { @@ -153,8 +153,8 @@ namespace osu.Game.Screens.OnlinePlay private void onGlobalModsChanged(ValueChangedEvent> mods) { // Remove incompatible free mods and display the newly available mod panels. - FreeMods.Value = FreeMods.Value.Where(isValidFreeMod).ToArray(); - freeModSelect.IsValidMod = isValidFreeMod; + FreeMods.Value = FreeMods.Value.Where(isValidAllowedMod).ToArray(); + freeModSelect.IsValidMod = isValidAllowedMod; } private void onRulesetChanged(ValueChangedEvent ruleset) @@ -202,7 +202,7 @@ namespace osu.Game.Screens.OnlinePlay protected override ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay(OverlayColourScheme.Plum) { - IsValidMod = isValidGlobalMod + IsValidMod = isValidRequiredMod }; protected override IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons() @@ -228,22 +228,20 @@ namespace osu.Game.Screens.OnlinePlay } /// - /// Checks whether a given is valid for global selection. + /// Checks whether a given is valid to be selected as a required mod. /// /// The to check. - /// Whether is a valid mod for online play. - private bool isValidGlobalMod(Mod mod) => ModUtils.IsValidModForMatch(mod, true, room.Type, Freestyle.Value); + private bool isValidRequiredMod(Mod mod) => ModUtils.IsValidModForMatch(mod, true, room.Type, Freestyle.Value); /// - /// Checks whether a given is valid for per-player free-mod selection. + /// Checks whether a given is valid to be selected as an allowed mod. /// /// The to check. - /// Whether is a selectable free-mod. - private bool isValidFreeMod(Mod mod) => ModUtils.IsValidModForMatch(mod, false, room.Type, Freestyle.Value) - // Mod must not be contained in the required mods. - && Mods.Value.All(m => m.Acronym != mod.Acronym) - // Mod must be compatible with all the required mods. - && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); + private bool isValidAllowedMod(Mod mod) => ModUtils.IsValidModForMatch(mod, false, room.Type, Freestyle.Value) + // Mod must not be contained in the required mods. + && Mods.Value.All(m => m.Acronym != mod.Acronym) + // Mod must be compatible with all the required mods. + && ModUtils.CheckCompatibleSet(Mods.Value.Append(mod).ToArray()); protected override void Dispose(bool isDisposing) { From 3b80c0af0abf85211f68b5dd0cb686249c5d8138 Mon Sep 17 00:00:00 2001 From: Jamie Taylor Date: Wed, 23 Apr 2025 15:30:16 +0900 Subject: [PATCH 34/61] Fix renamed translation keys --- osu.Game/Overlays/Rankings/RankingsScope.cs | 4 ++-- osu.Game/Screens/Edit/Setup/MetadataSection.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/osu.Game/Overlays/Rankings/RankingsScope.cs b/osu.Game/Overlays/Rankings/RankingsScope.cs index 0740c17e8c..658732a1b1 100644 --- a/osu.Game/Overlays/Rankings/RankingsScope.cs +++ b/osu.Game/Overlays/Rankings/RankingsScope.cs @@ -8,10 +8,10 @@ namespace osu.Game.Overlays.Rankings { public enum RankingsScope { - [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypePerformance))] + [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.StatPerformance))] Performance, - [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeScore))] + [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.StatRankedScore))] Score, [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeCountry))] diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs index 735204e2f4..4e8aed8c58 100644 --- a/osu.Game/Screens/Edit/Setup/MetadataSection.cs +++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs @@ -44,7 +44,7 @@ namespace osu.Game.Screens.Edit.Setup creatorTextBox = createTextBox(EditorSetupStrings.Creator), difficultyTextBox = createTextBox(EditorSetupStrings.DifficultyName), sourceTextBox = createTextBox(BeatmapsetsStrings.ShowInfoSource), - tagsTextBox = createTextBox(BeatmapsetsStrings.ShowInfoTags) + tagsTextBox = createTextBox(BeatmapsetsStrings.ShowInfoMapperTags) }; if (setupScreen != null) From 43386a193b6f5da2eb3a4efa7e2e537b60b42238 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 23 Apr 2025 15:32:17 +0900 Subject: [PATCH 35/61] Fix initial song select states The core freestyle-changed code business logic doesn't need to run on load (or maybe _should not_ be run), but some of the housekeeping code it also runs _does_ need to be run immediately. Extracting said housekeeping code fulfills both requirements. --- .../OnlinePlay/OnlinePlaySongSelect.cs | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 07cdb1a172..cdd2d141aa 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -117,24 +117,21 @@ namespace osu.Game.Screens.OnlinePlay Mods.BindValueChanged(onGlobalModsChanged); Ruleset.BindValueChanged(onRulesetChanged); - Freestyle.BindValueChanged(onFreestyleChanged, true); + Freestyle.BindValueChanged(onFreestyleChanged); freeModSelectOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(freeModSelect); + + updateFooterButtons(); + updateValidMods(); } private void onFreestyleChanged(ValueChangedEvent enabled) { - // Remove invalid mods and display the newly available mod panels. - Mods.Value = Mods.Value.Where(isValidRequiredMod).ToArray(); - ModSelect.IsValidMod = isValidRequiredMod; - FreeMods.Value = FreeMods.Value.Where(isValidAllowedMod).ToArray(); - freeModSelect.IsValidMod = isValidAllowedMod; + updateFooterButtons(); + updateValidMods(); if (enabled.NewValue) { - freeModsFooterButton.Enabled.Value = false; - freeModSelect.Hide(); - // Freestyle allows all mods to be selected as freemods. This does not play nicely for some components: // - We probably don't want to store a gigantic list of acronyms to the database. // - The mod select overlay isn't built to handle duplicate mods/mods from all rulesets being shoved into it. @@ -143,8 +140,6 @@ namespace osu.Game.Screens.OnlinePlay } else { - freeModsFooterButton.Enabled.Value = true; - // When disabling freestyle, enable freemods by default. FreeMods.Value = freeModSelect.AllAvailableMods.Where(state => state.ValidForSelection.Value).Select(state => state.Mod).ToArray(); } @@ -152,9 +147,7 @@ namespace osu.Game.Screens.OnlinePlay private void onGlobalModsChanged(ValueChangedEvent> mods) { - // Remove incompatible free mods and display the newly available mod panels. - FreeMods.Value = FreeMods.Value.Where(isValidAllowedMod).ToArray(); - freeModSelect.IsValidMod = isValidAllowedMod; + updateValidMods(); } private void onRulesetChanged(ValueChangedEvent ruleset) @@ -163,6 +156,26 @@ namespace osu.Game.Screens.OnlinePlay FreeMods.Value = []; } + private void updateFooterButtons() + { + if (Freestyle.Value) + { + freeModsFooterButton.Enabled.Value = false; + freeModSelect.Hide(); + } + else + freeModsFooterButton.Enabled.Value = true; + } + + private void updateValidMods() + { + // Remove invalid mods and display the newly available mod panels. + Mods.Value = Mods.Value.Where(isValidRequiredMod).ToArray(); + ModSelect.IsValidMod = isValidRequiredMod; + FreeMods.Value = FreeMods.Value.Where(isValidAllowedMod).ToArray(); + freeModSelect.IsValidMod = isValidAllowedMod; + } + protected sealed override bool OnStart() { var item = new PlaylistItem(Beatmap.Value.BeatmapInfo) From 8d4d9b7befb621d18c4395c19a08bc04f6a18c30 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 23 Apr 2025 15:40:39 +0900 Subject: [PATCH 36/61] Fix tablet settings adjusting with too much precision Closes https://github.com/ppy/osu/issues/32920. I don't know if there's going to be other cases of breakage, but from what I can tell these settings *not* showing infinite precision representations was a bug with the previous implementation of number formatting. --- .../Overlays/Settings/Sections/Input/TabletSettings.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index e104bb7e39..3ce546785a 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -37,13 +37,13 @@ namespace osu.Game.Overlays.Settings.Sections.Input private readonly Bindable areaSize = new Bindable(); private readonly IBindable tablet = new Bindable(); - private readonly BindableNumber offsetX = new BindableNumber { MinValue = 0 }; - private readonly BindableNumber offsetY = new BindableNumber { MinValue = 0 }; + private readonly BindableNumber offsetX = new BindableNumber { MinValue = 0, Precision = 1 }; + private readonly BindableNumber offsetY = new BindableNumber { MinValue = 0, Precision = 1 }; - private readonly BindableNumber sizeX = new BindableNumber { MinValue = 10 }; - private readonly BindableNumber sizeY = new BindableNumber { MinValue = 10 }; + private readonly BindableNumber sizeX = new BindableNumber { MinValue = 10, Precision = 1 }; + private readonly BindableNumber sizeY = new BindableNumber { MinValue = 10, Precision = 1 }; - private readonly BindableNumber rotation = new BindableNumber { MinValue = 0, MaxValue = 360 }; + private readonly BindableNumber rotation = new BindableNumber { MinValue = 0, MaxValue = 360, Precision = 1 }; private readonly BindableNumber pressureThreshold = new BindableNumber { MinValue = 0.0f, MaxValue = 1.0f, Precision = 0.005f }; From 65cf1a4b7c8f31f3dadccd1499c1120244cecdfe Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 23 Apr 2025 16:28:44 +0900 Subject: [PATCH 37/61] Show true beatmap background when viewing historical multiplayer results --- .../Playlists/PlaylistItemUserBestResultsScreen.cs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs index 866b094178..c5cea5fef1 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs @@ -2,9 +2,12 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; +using osu.Framework.Allocation; +using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Scoring; +using osu.Game.Screens.Backgrounds; namespace osu.Game.Screens.OnlinePlay.Playlists { @@ -14,6 +17,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists public partial class PlaylistItemUserBestResultsScreen : PlaylistItemResultsScreen { private readonly int userId; + private WorkingBeatmap itemBeatmap = null!; public PlaylistItemUserBestResultsScreen(long roomId, PlaylistItem playlistItem, int userId) : base(null, roomId, playlistItem) @@ -21,6 +25,13 @@ namespace osu.Game.Screens.OnlinePlay.Playlists this.userId = userId; } + [BackgroundDependencyLoader] + private void load(BeatmapManager beatmaps) + { + var localBeatmap = beatmaps.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", PlaylistItem.Beatmap.OnlineID); + itemBeatmap = beatmaps.GetWorkingBeatmap(localBeatmap); + } + protected override APIRequest CreateScoreRequest() => new ShowPlaylistUserScoreRequest(RoomId, PlaylistItem.ID, userId); protected override void OnScoresAdded(ScoreInfo[] scores) @@ -30,5 +41,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists // Prefer selecting the local user's score, or otherwise default to the first visible score. SelectedScore.Value ??= scores.FirstOrDefault(s => s.UserID == userId) ?? scores.FirstOrDefault(); } + + protected override BackgroundScreen CreateBackground() => new BackgroundScreenBeatmap(itemBeatmap); } } From 414150e9e07c4131c3e6be1814a582552e5071fd Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 23 Apr 2025 17:53:40 +0900 Subject: [PATCH 38/61] Maintain selection using `OnlineID` as a priority --- .../TestSceneBeatmapCarouselUpdateHandling.cs | 34 ++++++++++++++++++- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 6 ++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs index d1d73e141a..31aa1b6f94 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs @@ -98,6 +98,38 @@ namespace osu.Game.Tests.Visual.SongSelectV2 AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); } + [Test] // Checks that we keep selection based on online ID where possible. + public void TestSelectionHeldDifficultyNameChanged() + { + SelectPrevGroup(); + + WaitForSelection(1, 0); + AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + + updateBeatmap(b => b.DifficultyName = "new name"); + WaitForSorting(); + + AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + } + + [Test] // Checks that we fallback to keeping selection based on difficulty name. + public void TestSelectionHeldDifficultyOnlineIDChanged() + { + SelectPrevGroup(); + + WaitForSelection(1, 0); + AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + + updateBeatmap(b => b.OnlineID = b.OnlineID + 1); + WaitForSorting(); + + AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + } + private void updateBeatmap(Action? updateBeatmap = null, Action? updateSet = null) { AddStep("update beatmap with different reference", () => @@ -105,7 +137,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 var updatedSet = new BeatmapSetInfo { ID = baseTestBeatmap.ID, - OnlineID = 99999, // this is just for tracking / debug purposes at the moment. + OnlineID = baseTestBeatmap.OnlineID, DateAdded = baseTestBeatmap.DateAdded, DateSubmitted = baseTestBeatmap.DateSubmitted, DateRanked = baseTestBeatmap.DateRanked, diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 3294b9e8a2..9574a05762 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -102,7 +102,7 @@ namespace osu.Game.Screens.SelectV2 var newSetBeatmaps = newItems!.Single().Beatmaps.ToList(); // Handling replace operations is a touch manual, as we need to locally diff the beatmaps of each version of the beatmap set. - // Matching is done based on difficulty names as these are the most stable thing between updates (which are usually triggered + // Matching is done based on online IDs, then difficulty names as these are the most stable thing between updates (which are usually triggered // by users editing the beatmap or by difficulty/metadata recomputation). // // In the case of difficulty reprocessing, this will trigger multiple times per beatmap as it's always triggering a set update. @@ -113,7 +113,9 @@ namespace osu.Game.Screens.SelectV2 int previousIndex = Items.IndexOf(beatmap); Debug.Assert(previousIndex >= 0); - BeatmapInfo? matchingNewBeatmap = newSetBeatmaps.SingleOrDefault(b => b.DifficultyName == beatmap.DifficultyName && b.Ruleset.Equals(beatmap.Ruleset)); + BeatmapInfo? matchingNewBeatmap = + newSetBeatmaps.SingleOrDefault(b => b.OnlineID == beatmap.OnlineID) ?? + newSetBeatmaps.SingleOrDefault(b => b.DifficultyName == beatmap.DifficultyName && b.Ruleset.Equals(beatmap.Ruleset)); if (matchingNewBeatmap != null) { From 73773aa69a1c2368c9e9126c87dad860451ec9a0 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 23 Apr 2025 18:13:13 +0900 Subject: [PATCH 39/61] Remove online ID equality TODO and add explanation as to why it's not required --- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 9574a05762..80006fddd9 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -355,7 +355,13 @@ namespace osu.Game.Screens.SelectV2 protected override bool CheckModelEquality(object x, object y) { - // TODO: this doesn't check online ID. probably need to account for that. + // In the confines of the carousel logic, we assume that CurrentSelection (and all items) are using non-stale + // BeatmapInfo reference, and that we can match based on beatmap / beatmapset (GU)IDs. + // + // If there's a case where updates don't come in as expected, diagnosis should start from BeatmapStore, ensuring + // it is doing a Replace operation on the list. If it is, then check the local handling in beatmapSetsChanged + // before changing matching requirements here. + if (x is BeatmapSetInfo beatmapSetX && y is BeatmapSetInfo beatmapSetY) return beatmapSetX.Equals(beatmapSetY); From e36c6db008d5caadb002d218976f2f1de7be75cd Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 23 Apr 2025 18:17:27 +0900 Subject: [PATCH 40/61] Fix stack overflow due to bindable equality --- .../Screens/OnlinePlay/OnlinePlaySongSelect.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index cdd2d141aa..bb6d75fa3b 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -167,12 +167,21 @@ namespace osu.Game.Screens.OnlinePlay freeModsFooterButton.Enabled.Value = true; } + /// + /// Removes invalid mods from and , + /// and updates mod selection overlays to display the new mods valid for selection. + /// private void updateValidMods() { - // Remove invalid mods and display the newly available mod panels. - Mods.Value = Mods.Value.Where(isValidRequiredMod).ToArray(); + Mod[] validMods = Mods.Value.Where(isValidRequiredMod).ToArray(); + if (!validMods.SequenceEqual(Mods.Value)) + Mods.Value = validMods; + + Mod[] validFreeMods = FreeMods.Value.Where(isValidAllowedMod).ToArray(); + if (!validFreeMods.SequenceEqual(FreeMods.Value)) + FreeMods.Value = validFreeMods; + ModSelect.IsValidMod = isValidRequiredMod; - FreeMods.Value = FreeMods.Value.Where(isValidAllowedMod).ToArray(); freeModSelect.IsValidMod = isValidAllowedMod; } From 1488a49dae187f978313d19e1a43307a233d1eaf Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 23 Apr 2025 18:21:17 +0900 Subject: [PATCH 41/61] Ensure online ID has a valid online value before preferring it --- osu.Game/Screens/SelectV2/BeatmapCarousel.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 80006fddd9..4af5e759a7 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -114,7 +114,7 @@ namespace osu.Game.Screens.SelectV2 Debug.Assert(previousIndex >= 0); BeatmapInfo? matchingNewBeatmap = - newSetBeatmaps.SingleOrDefault(b => b.OnlineID == beatmap.OnlineID) ?? + newSetBeatmaps.SingleOrDefault(b => b.OnlineID > 0 && b.OnlineID == beatmap.OnlineID) ?? newSetBeatmaps.SingleOrDefault(b => b.DifficultyName == beatmap.DifficultyName && b.Ruleset.Equals(beatmap.Ruleset)); if (matchingNewBeatmap != null) From 655861752dad598ca3edc3b8daa444ecdc011d74 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 23 Apr 2025 18:27:59 +0900 Subject: [PATCH 42/61] Move implementation to base class --- .../Playlists/PlaylistItemResultsScreen.cs | 8 ++++++++ .../Playlists/PlaylistItemUserBestResultsScreen.cs | 13 ------------- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs index 0e539936d8..e994299606 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs @@ -19,6 +19,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Scoring; +using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Ranking; namespace osu.Game.Screens.OnlinePlay.Playlists @@ -34,6 +35,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists private MultiplayerScores? higherScores; private MultiplayerScores? lowerScores; + private WorkingBeatmap itemBeatmap = null!; [Resolved] protected IAPIProvider API { get; private set; } = null!; @@ -60,6 +62,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [BackgroundDependencyLoader] private void load() { + var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", + PlaylistItem.Beatmap.OnlineID); + itemBeatmap = beatmapManager.GetWorkingBeatmap(localBeatmap); + AddInternal(new Container { RelativeSizeAxes = Axes.Both, @@ -307,6 +313,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists } } + protected override BackgroundScreen CreateBackground() => new BackgroundScreenBeatmap(itemBeatmap); + private partial class PanelListLoadingSpinner : LoadingSpinner { private readonly ScorePanelList list; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs index c5cea5fef1..866b094178 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemUserBestResultsScreen.cs @@ -2,12 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; -using osu.Framework.Allocation; -using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Scoring; -using osu.Game.Screens.Backgrounds; namespace osu.Game.Screens.OnlinePlay.Playlists { @@ -17,7 +14,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists public partial class PlaylistItemUserBestResultsScreen : PlaylistItemResultsScreen { private readonly int userId; - private WorkingBeatmap itemBeatmap = null!; public PlaylistItemUserBestResultsScreen(long roomId, PlaylistItem playlistItem, int userId) : base(null, roomId, playlistItem) @@ -25,13 +21,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists this.userId = userId; } - [BackgroundDependencyLoader] - private void load(BeatmapManager beatmaps) - { - var localBeatmap = beatmaps.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", PlaylistItem.Beatmap.OnlineID); - itemBeatmap = beatmaps.GetWorkingBeatmap(localBeatmap); - } - protected override APIRequest CreateScoreRequest() => new ShowPlaylistUserScoreRequest(RoomId, PlaylistItem.ID, userId); protected override void OnScoresAdded(ScoreInfo[] scores) @@ -41,7 +30,5 @@ namespace osu.Game.Screens.OnlinePlay.Playlists // Prefer selecting the local user's score, or otherwise default to the first visible score. SelectedScore.Value ??= scores.FirstOrDefault(s => s.UserID == userId) ?? scores.FirstOrDefault(); } - - protected override BackgroundScreen CreateBackground() => new BackgroundScreenBeatmap(itemBeatmap); } } From 618ab4fec67c917e3c9416b313ec690d96c439d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 23 Apr 2025 12:20:17 +0200 Subject: [PATCH 43/61] Fix beatmap wedge test failures Started failing after 5ad28a792b683191e5d21bbff04299766b4eb3b5, I'm guessing. --- .../Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs index 6a14ddc147..8b89de5fce 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs @@ -79,8 +79,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 selectBeatmap(null); AddAssert("check default title", () => titleWedge.DisplayedTitle == Beatmap.Default.BeatmapInfo.Metadata.Title); AddAssert("check default artist", () => titleWedge.DisplayedArtist == Beatmap.Default.BeatmapInfo.Metadata.Artist); - AddAssert("check empty version", () => string.IsNullOrEmpty(difficultyDisplay.DisplayedVersion.ToString())); - AddAssert("check empty author", () => string.IsNullOrEmpty(difficultyDisplay.DisplayedAuthor.ToString())); AddAssert("check no statistics", () => difficultyDisplay.ChildrenOfType().All(d => !d.Statistics.Any())); } From f6f098a0dcb2412315055955832189096cb3a562 Mon Sep 17 00:00:00 2001 From: Jamie Taylor Date: Thu, 24 Apr 2025 10:48:32 +0900 Subject: [PATCH 44/61] Move playback logic to `Update()` --- .../Editing/TestSceneSubmissionStageProgress.cs | 2 +- .../Edit/Submission/SubmissionStageProgress.cs | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs index 693b88a12f..1598584144 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs @@ -127,7 +127,7 @@ namespace osu.Game.Tests.Visual.Editing AddStep($"{step}: completed", () => stages[step].SetCompleted()); } - AddWaitStep("pause for timing", 1); + AddWaitStep("pause for timing", 2); AddStep("Sequence Complete", () => { diff --git a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs index 208e06d917..eddc057ba1 100644 --- a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs +++ b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs @@ -143,19 +143,22 @@ namespace osu.Game.Screens.Edit.Submission status.BindValueChanged(_ => Scheduler.AddOnce(updateStatus), true); progress.BindValueChanged(_ => Scheduler.AddOnce(updateProgress), true); - - // Binding to `progressBar` updates instead of `progress` for more frequent/granular updates - progressBar.OnUpdate += playProgressSound; } - private void playProgressSound(Drawable box) + protected override void Update() { - float width = box.Width; - SampleChannel sampleChannel = progressSample.GetChannel(); + base.Update(); + + if (!(progressBarContainer.Alpha > 0)) + return; + + float width = progressBar.Width; if (Precision.AlmostEquals(previousPercent ?? 0f, width) || (lastSamplePlayback != null && Time.Current - lastSamplePlayback < 10)) return; + SampleChannel sampleChannel = progressSample.GetChannel(); + sampleChannel.Frequency.Value = 0.5f + (width * 1.5f); sampleChannel.Volume.Value = 0.25f + ((width / 2f) * .75f); sampleChannel.Play(); From 15a4ffe3443b27cd1a8170be4fd84c8346b749c4 Mon Sep 17 00:00:00 2001 From: Jamie Taylor Date: Thu, 24 Apr 2025 11:31:02 +0900 Subject: [PATCH 45/61] Change samples to be declared nullable --- .../Submission/SubmissionStageProgress.cs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs index eddc057ba1..de173929b5 100644 --- a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs +++ b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs @@ -39,13 +39,13 @@ namespace osu.Game.Screens.Edit.Submission [Resolved] private OsuColour colours { get; set; } = null!; - private Sample progressSample = null!; + private Sample? progressSample; private const int stage_done_sample_count = 4; - private Sample stageDoneSample = null!; + private Sample? stageDoneSample; - private Sample errorSample = null!; - private Sample cancelSample = null!; + private Sample? errorSample; + private Sample? cancelSample; private double? lastSamplePlayback; private float? previousPercent; @@ -157,7 +157,10 @@ namespace osu.Game.Screens.Edit.Submission if (Precision.AlmostEquals(previousPercent ?? 0f, width) || (lastSamplePlayback != null && Time.Current - lastSamplePlayback < 10)) return; - SampleChannel sampleChannel = progressSample.GetChannel(); + SampleChannel? sampleChannel = progressSample?.GetChannel(); + + if (sampleChannel == null) + return; sampleChannel.Frequency.Value = 0.5f + (width * 1.5f); sampleChannel.Volume.Value = 0.25f + ((width / 2f) * .75f); @@ -226,7 +229,7 @@ namespace osu.Game.Screens.Edit.Submission // manually set progress value, as to trigger sample playback for the final section progress.Value = 1; - stageDoneSample.Play(); + stageDoneSample?.Play(); break; @@ -238,7 +241,7 @@ namespace osu.Game.Screens.Edit.Submission }; iconContainer.Colour = colours.Red1; iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint); - errorSample.Play(); + errorSample?.Play(); break; case StageStatusType.Canceled: @@ -249,7 +252,7 @@ namespace osu.Game.Screens.Edit.Submission }; iconContainer.Colour = colours.Gray8; iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint); - cancelSample.Play(); + cancelSample?.Play(); break; } } From c52dce0f386386817be9ed36ffd9654702e5dd2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 24 Apr 2025 08:42:09 +0200 Subject: [PATCH 46/61] Fix presenting score potentially dying due to deleted beatmap - Closes https://github.com/ppy/osu/issues/27168 - Closes https://github.com/ppy/osu/issues/32930 It's a little manual (if you perform any of the scenarios in the issues above on this branch, the first click will re-import the beatmap but not start the replay, and only the second will play it), but maybe fine? --- .../TestSceneMissingBeatmapNotification.cs | 2 +- .../Database/MissingBeatmapNotification.cs | 20 +++++++++---- osu.Game/OsuGame.cs | 30 +++++++++++++------ osu.Game/Scoring/ScoreImporter.cs | 2 +- 4 files changed, 38 insertions(+), 16 deletions(-) diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneMissingBeatmapNotification.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneMissingBeatmapNotification.cs index f5506edf3b..b7d58a633d 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneMissingBeatmapNotification.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneMissingBeatmapNotification.cs @@ -27,7 +27,7 @@ namespace osu.Game.Tests.Visual.UserInterface AutoSizeAxes = Axes.Y, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Child = new MissingBeatmapNotification(CreateAPIBeatmapSet(Ruleset.Value).Beatmaps.First(), new ImportScoreTest.TestArchiveReader(), "deadbeef") + Child = new MissingBeatmapNotification(CreateAPIBeatmapSet(Ruleset.Value).Beatmaps.First(), "deadbeef", new ImportScoreTest.TestArchiveReader()) }; } } diff --git a/osu.Game/Database/MissingBeatmapNotification.cs b/osu.Game/Database/MissingBeatmapNotification.cs index 584b2675f3..fff2448f3f 100644 --- a/osu.Game/Database/MissingBeatmapNotification.cs +++ b/osu.Game/Database/MissingBeatmapNotification.cs @@ -28,7 +28,7 @@ namespace osu.Game.Database [Resolved] private RealmAccess realm { get; set; } = null!; - private readonly ArchiveReader scoreArchive; + private readonly ArchiveReader? scoreArchive; private readonly APIBeatmapSet beatmapSetInfo; private readonly string beatmapHash; @@ -38,7 +38,13 @@ namespace osu.Game.Database private IDisposable? realmSubscription; - public MissingBeatmapNotification(APIBeatmap beatmap, ArchiveReader scoreArchive, string beatmapHash) + /// + /// Creates a new notification about a missing beatmap that needs to be downloaded to proceed with an action. + /// + /// The online-retrieved beatmap to download. + /// The hash of the beatmap that is required to proceed. + /// Optional archive with a score. If not , a re-import of this archive will be attempted after the missing beatmap is downloaded. + public MissingBeatmapNotification(APIBeatmap beatmap, string beatmapHash, ArchiveReader? scoreArchive) { beatmapSetInfo = beatmap.BeatmapSet!; @@ -86,9 +92,13 @@ namespace osu.Game.Database if (sender.Any(s => s.Beatmaps.Any(b => b.MD5Hash == beatmapHash))) { - string name = scoreArchive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase)); - var importTask = new ImportTask(scoreArchive.GetStream(name), name); - scoreManager.Import(new[] { importTask }); + if (scoreArchive != null) + { + string name = scoreArchive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase)); + var importTask = new ImportTask(scoreArchive.GetStream(name), name); + scoreManager.Import(new[] { importTask }); + } + realmSubscription?.Dispose(); Close(false); } diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index cbb2d44a9a..9d2dae2f4a 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -46,6 +46,7 @@ using osu.Game.Input.Bindings; using osu.Game.IO; using osu.Game.Localisation; using osu.Game.Online; +using osu.Game.Online.API.Requests; using osu.Game.Online.Chat; using osu.Game.Online.Leaderboards; using osu.Game.Online.Rooms; @@ -59,6 +60,7 @@ using osu.Game.Overlays.SkinEditor; using osu.Game.Overlays.Toolbar; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; using osu.Game.Screens; using osu.Game.Screens.Edit; using osu.Game.Screens.Footer; @@ -742,23 +744,33 @@ namespace osu.Game { Logger.Log($"Beginning {nameof(PresentScore)} with score {score}"); - var databasedScore = ScoreManager.GetScore(score); + Score databasedScore = null; + + try + { + databasedScore = ScoreManager.GetScore(score); + } + catch (LegacyScoreDecoder.BeatmapNotFoundException notFound) + { + Logger.Log("The replay cannot be played because the beatmap is missing.", LoggingTarget.Information); + + var req = new GetBeatmapRequest(new BeatmapInfo { MD5Hash = notFound.Hash }); + req.Success += res => Notifications.Post(new MissingBeatmapNotification(res, notFound.Hash, null)); + API.Queue(req); + + return; + } if (databasedScore == null) return; if (databasedScore.Replay == null) { - Logger.Log("The loaded score has no replay data.", LoggingTarget.Information); + Logger.Log("The loaded score has no replay data.", LoggingTarget.Information, LogLevel.Important); return; } - var databasedBeatmap = BeatmapManager.QueryBeatmap(b => b.ID == databasedScore.ScoreInfo.BeatmapInfo.ID); - - if (databasedBeatmap == null) - { - Logger.Log("Tried to load a score for a beatmap we don't have!", LoggingTarget.Information); - return; - } + var databasedBeatmap = databasedScore.ScoreInfo.BeatmapInfo; + Debug.Assert(databasedBeatmap != null); // This should be able to be performed from song select always, but that is disabled for now // due to the weird decoupled ruleset logic (which can cause a crash in certain filter scenarios). diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs index 4b3f4a5e63..55b172526f 100644 --- a/osu.Game/Scoring/ScoreImporter.cs +++ b/osu.Game/Scoring/ScoreImporter.cs @@ -59,7 +59,7 @@ namespace osu.Game.Scoring { // In the case of a missing beatmap, let's attempt to resolve it and show a prompt to the user to download the required beatmap. var req = new GetBeatmapRequest(new BeatmapInfo { MD5Hash = notFound.Hash }); - req.Success += res => PostNotification?.Invoke(new MissingBeatmapNotification(res, archive, notFound.Hash)); + req.Success += res => PostNotification?.Invoke(new MissingBeatmapNotification(res, notFound.Hash, archive)); api.Queue(req); } From 72dd3513fc0e1c3bbc9b2c80e2f845584b08f383 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 24 Apr 2025 08:54:10 +0200 Subject: [PATCH 47/61] Fix code quality --- osu.Game/OsuGame.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 9d2dae2f4a..962718b564 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -744,7 +744,7 @@ namespace osu.Game { Logger.Log($"Beginning {nameof(PresentScore)} with score {score}"); - Score databasedScore = null; + Score databasedScore; try { From 5c04f427a584546fe1abd26e8233ef54ffc42a10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 24 Apr 2025 09:22:42 +0200 Subject: [PATCH 48/61] Reset sample ternary states on deselection Closes https://github.com/ppy/osu/issues/32928. Appears to match stable: https://github.com/peppy/osu-stable-reference/blob/996648fba06baf4e7d2e0b248959399444017895/osu!/GameModes/Edit/Modes/EditorModeCompose.cs#L4137-L4143 https://github.com/peppy/osu-stable-reference/blob/996648fba06baf4e7d2e0b248959399444017895/osu!/GameModes/Edit/Modes/EditorModeCompose.cs#L1615-L1618 https://github.com/peppy/osu-stable-reference/blob/996648fba06baf4e7d2e0b248959399444017895/osu!/GameModes/Edit/Modes/EditorModeCompose.cs#L4323 --- .../Screens/Edit/Compose/Components/EditorSelectionHandler.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs index e90936e38a..2eff5bae5f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -281,6 +281,8 @@ namespace osu.Game.Screens.Edit.Compose.Components SelectionAdditionBanksEnabled.Value = true; SelectionBankStates[HIT_BANK_AUTO].Value = TernaryState.True; SelectionAdditionBankStates[HIT_BANK_AUTO].Value = TernaryState.True; + foreach (var (_, sampleState) in SelectionSampleStates) + sampleState.Value = TernaryState.False; } /// From 5a3ff11710dfbc2983313477b051d06408a727f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 24 Apr 2025 09:24:33 +0200 Subject: [PATCH 49/61] Fix deselecting single item from a multiple selection not updating ternary states correctly I'm not sure why this condition was written this obtusely, but importantly it was also wrong. It fires when the selection contains multiple items and only some are removed from it, like when ctrl-click-unselecting an item from a multiple selection. --- .../Screens/Edit/Compose/Components/EditorSelectionHandler.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs index 2eff5bae5f..a258016da5 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -318,7 +318,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private void onSelectedItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) { // Reset the ternary states when the selection is cleared. - if (e.OldStartingIndex >= 0 && e.NewStartingIndex < 0) + if (SelectedItems.Count == 0) Scheduler.AddOnce(resetTernaryStates); else Scheduler.AddOnce(UpdateTernaryStates); From 33f45b0a6d348ca2c2e9c6a9c6e6527e4eb52a2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 24 Apr 2025 11:39:09 +0200 Subject: [PATCH 50/61] Add test scene coverage of HUD layouts on various ruleset/skin combinations As time goes on, default skin layouts are getting more and more complicated because of per-ruleset overrides. This was already sort of apparent in https://github.com/ppy/osu/pull/31527, and I'm about to make it worse, so before I do, this is a test scene that is supposed to make it easier to check all possible combinations at a glance. --- .../Gameplay/TestSceneHUDOverlayLayouts.cs | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayLayouts.cs diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayLayouts.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayLayouts.cs new file mode 100644 index 0000000000..3b9fcd1102 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayLayouts.cs @@ -0,0 +1,161 @@ +// 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 NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Configuration; +using osu.Game.Database; +using osu.Game.IO; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Spectator; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Skinning; +using osu.Game.Tests.Gameplay; +using osu.Game.Tests.Visual.Spectator; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [System.ComponentModel.Description(@"Exercises the appearance of the HUD overlay on various skin and ruleset combinations.")] + public partial class TestSceneHUDOverlayRulesetLayouts : OsuTestScene, IStorageResourceProvider + { + private readonly Dictionary skins = new Dictionary(); + + [Resolved] + private GameHost host { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [Resolved] + private OsuConfigManager configManager { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + skins["argon"] = new ArgonSkin(this); + skins["triangles"] = new TrianglesSkin(this); + skins["legacy"] = new DefaultLegacySkin(this); + } + + [Test] + public void TestLayout( + [Values("argon", "triangles", "legacy")] + string skinName, + [Values("osu", "taiko", "fruits", "mania")] + string rulesetName) + { + AddStep("create content", () => + { + var rulesetInfo = rulesets.GetRuleset(rulesetName); + var ruleset = rulesetInfo!.CreateInstance(); + var beatmap = ruleset.CreateBeatmapConverter(new Beatmap()).Convert(); + var drawableRuleset = ruleset.CreateDrawableRulesetWith(beatmap); + + var skin = skins[skinName]; + ISkin provider = ruleset.CreateSkinTransformer(skin, beatmap) ?? skin; + + var gameplayState = TestGameplayState.Create(ruleset); + ((Bindable)gameplayState.PlayingState).Value = LocalUserPlayingState.Playing; + var spectatorClient = new TestSpectatorClient(); + + for (int i = 0; i < 15; ++i) + { + ((ISpectatorClient)spectatorClient).UserStartedWatching([ + new SpectatorUser + { + OnlineID = i, + Username = $"User {i}" + } + ]); + } + + GameplayClockContainer gameplayClock; + + List<(Type, object)> dependencies = + [ + (typeof(GameplayState), gameplayState), + (typeof(ScoreProcessor), gameplayState.ScoreProcessor), + (typeof(HealthProcessor), gameplayState.HealthProcessor), + (typeof(IGameplayClock), gameplayClock = new GameplayClockContainer(new TrackVirtual(60000), false, false)), + (typeof(SpectatorClient), spectatorClient), + (typeof(IGameplayLeaderboardProvider), new TestGameplayLeaderboardProvider()), + ]; + + if (drawableRuleset is IDrawableScrollingRuleset scrolling) + dependencies.Add((typeof(IScrollingInfo), scrolling.ScrollingInfo)); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = dependencies.ToArray(), + Children = new Drawable[] + { + spectatorClient, + new SkinProvidingContainer(provider) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + drawableRuleset, + new HUDOverlay(drawableRuleset, []) + { + RelativeSizeAxes = Axes.Both, + } + } + } + } + }; + + gameplayClock.Start(); + }); + } + + private class TestGameplayLeaderboardProvider : IGameplayLeaderboardProvider + { + IBindableList IGameplayLeaderboardProvider.Scores => Scores; + public BindableList Scores { get; } = new BindableList(); + public bool IsPartial { get; } = false; + + public TestGameplayLeaderboardProvider() + { + for (int i = 0; i < 20; ++i) + { + Scores.Add(new GameplayLeaderboardScore(new ScoreInfo + { + User = new APIUser { Username = $"User {i}" }, + TotalScore = (20 - i) * 50_000, + Accuracy = i * 0.05, + Combo = i * 50 + }, i == 19)); + } + } + } + + #region IResourceStorageProvider + + public IRenderer Renderer => host.Renderer; + public AudioManager AudioManager => Audio; + public IResourceStore Files => null!; + public new IResourceStore Resources => base.Resources; + public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => host.CreateTextureLoaderStore(underlyingStore); + RealmAccess IStorageResourceProvider.RealmAccess => null!; + + #endregion + } +} From 9818f859cbc59c62724b7702d9821348fc7862a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Thu, 24 Apr 2025 13:19:31 +0200 Subject: [PATCH 51/61] Remove (currently) unused resolved dependency --- osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayLayouts.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayLayouts.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayLayouts.cs index 3b9fcd1102..ae6e297f96 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayLayouts.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayLayouts.cs @@ -14,7 +14,6 @@ using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Platform; using osu.Game.Beatmaps; -using osu.Game.Configuration; using osu.Game.Database; using osu.Game.IO; using osu.Game.Online.API.Requests.Responses; @@ -42,9 +41,6 @@ namespace osu.Game.Tests.Visual.Gameplay [Resolved] private RulesetStore rulesets { get; set; } = null!; - [Resolved] - private OsuConfigManager configManager { get; set; } = null!; - [BackgroundDependencyLoader] private void load() { From 9c56ee3e19d9605ba40dce21c439ef5fcece61e4 Mon Sep 17 00:00:00 2001 From: Jamie Taylor Date: Thu, 24 Apr 2025 20:34:18 +0900 Subject: [PATCH 52/61] Rework logic to use a looping sample for progress instead --- .../Submission/SubmissionStageProgress.cs | 83 ++++++++++++------- 1 file changed, 54 insertions(+), 29 deletions(-) diff --git a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs index de173929b5..905f654d04 100644 --- a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs +++ b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Containers; @@ -47,8 +48,11 @@ namespace osu.Game.Screens.Edit.Submission private Sample? errorSample; private Sample? cancelSample; - private double? lastSamplePlayback; - private float? previousPercent; + private SampleChannel? progressSampleChannel; + + private const int fadeout_duration = 100; + private ScheduledDelegate? progressSampleFadeDelegate; + private ScheduledDelegate? progressSampleStopDelegate; [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider, AudioManager audio) @@ -143,31 +147,8 @@ namespace osu.Game.Screens.Edit.Submission status.BindValueChanged(_ => Scheduler.AddOnce(updateStatus), true); progress.BindValueChanged(_ => Scheduler.AddOnce(updateProgress), true); - } - protected override void Update() - { - base.Update(); - - if (!(progressBarContainer.Alpha > 0)) - return; - - float width = progressBar.Width; - - if (Precision.AlmostEquals(previousPercent ?? 0f, width) || (lastSamplePlayback != null && Time.Current - lastSamplePlayback < 10)) - return; - - SampleChannel? sampleChannel = progressSample?.GetChannel(); - - if (sampleChannel == null) - return; - - sampleChannel.Frequency.Value = 0.5f + (width * 1.5f); - sampleChannel.Volume.Value = 0.25f + ((width / 2f) * .75f); - sampleChannel.Play(); - - lastSamplePlayback = Time.Current; - previousPercent = width; + progressSampleChannel = progressSample?.GetChannel(); } public void SetNotStarted() => status.Value = StageStatusType.NotStarted; @@ -176,6 +157,13 @@ namespace osu.Game.Screens.Edit.Submission { this.progress.Value = progress; status.Value = StageStatusType.InProgress; + + if (progressSampleChannel == null) + return; + + progressSampleChannel.Frequency.Value = 0.5f; + progressSampleChannel.Volume.Value = 0.25f; + progressSampleChannel.Looping = true; } public void SetCompleted() => status.Value = StageStatusType.Completed; @@ -188,14 +176,51 @@ namespace osu.Game.Screens.Edit.Submission public void SetCanceled() => status.Value = StageStatusType.Canceled; + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + progressSampleChannel?.Stop(); + } + private const float transition_duration = 200; + private const Easing transition_easing = Easing.OutQuint; private void updateProgress() { - if (progress.Value != null) - progressBar.ResizeWidthTo(progress.Value.Value, transition_duration, Easing.OutQuint); + progressSampleFadeDelegate?.Cancel(); + progressSampleStopDelegate?.Cancel(); - progressBarContainer.FadeTo(status.Value == StageStatusType.InProgress && progress.Value != null ? 1 : 0, transition_duration, Easing.OutQuint); + try + { + if (progress.Value == null) + return; + + float progressValue = progress.Value.Value; + + progressBar.ResizeWidthTo(progressValue, transition_duration, transition_easing); + + if (progressSampleChannel == null || Precision.AlmostEquals(progressValue, 0f)) + return; + + // Don't restart the looping sample if already playing + if (!progressSampleChannel.Playing) + progressSampleChannel.Play(); + + this.TransformBindableTo(progressSampleChannel.Frequency, 0.5f + (progressValue * 1.5f), transition_duration, transition_easing); + this.TransformBindableTo(progressSampleChannel.Volume, 0.25f + (progressValue * .75f), transition_duration, transition_easing); + + progressSampleFadeDelegate = Scheduler.AddDelayed(() => + { + // Perform a fade-out before stopping the sample to prevent clicking. + this.TransformBindableTo(progressSampleChannel.Volume, 0, fadeout_duration); + progressSampleStopDelegate = Scheduler.AddDelayed(() => { progressSampleChannel.Stop(); }, fadeout_duration); + }, transition_duration - fadeout_duration); + } + finally + { + progressBarContainer.FadeTo(status.Value == StageStatusType.InProgress && progress.Value != null ? 1 : 0, transition_duration, transition_easing); + } } private void updateStatus() From c149a6efd6c4ac028f9a68151edd677e0f79b71d Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 24 Apr 2025 21:22:01 +0900 Subject: [PATCH 53/61] Update 404ing cover image URLs --- osu.Game.Tests/Resources/TestResources.cs | 7 +++++- .../TestSceneDailyChallengeCarousel.cs | 5 ++-- .../TestSceneDailyChallengeEventFeed.cs | 8 +++---- .../TestSceneDailyChallengeScoreBreakdown.cs | 5 ++-- .../TestSceneDailyChallengeTotalsDisplay.cs | 4 ++-- .../TestSceneMultiplayerParticipantsList.cs | 23 ++++++++++--------- .../Online/TestSceneDashboardOverlay.cs | 3 ++- .../Visual/Online/TestSceneFriendDisplay.cs | 5 ++-- .../Online/TestSceneUserClickableAvatar.cs | 7 +++--- .../Online/TestSceneUserProfileHeader.cs | 5 ++-- .../Online/TestSceneUserProfileOverlay.cs | 11 +++++---- .../TestScenePlaylistsResultsScreen.cs | 6 ++--- .../SongSelectV2/TestSceneLeaderboardScore.cs | 6 ++--- 13 files changed, 54 insertions(+), 41 deletions(-) diff --git a/osu.Game.Tests/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs index e0572e604c..54204d412a 100644 --- a/osu.Game.Tests/Resources/TestResources.cs +++ b/osu.Game.Tests/Resources/TestResources.cs @@ -29,6 +29,11 @@ namespace osu.Game.Tests.Resources { public const double QUICK_BEATMAP_LENGTH = 10000; + public const string COVER_IMAGE_1 = "https://assets.ppy.sh/user-cover-presets/1/df28696b58541a9e67f6755918951d542d93bdf1da41720fcca2fd2c1ea8cf51.jpeg"; + public const string COVER_IMAGE_2 = "https://assets.ppy.sh/user-cover-presets/7/4a0ccb7b7fdd5c4238b11f0e7c686760fe2c99c6472b19400e82d1a8ff503e31.jpeg"; + public const string COVER_IMAGE_3 = "https://assets.ppy.sh/user-cover-presets/12/6e8d3402c8080c2d9549a98321e1bff111dd9c94603ccdb237597479cab6e8a7.jpeg"; + public const string COVER_IMAGE_4 = "https://assets.ppy.sh/user-cover-presets/17/80f82e4c2b27d8d6eed3ce89708ec27343e5ac63389cba6b5fb4550776562d08.jpeg"; + private static readonly TemporaryNativeStorage temp_storage = new TemporaryNativeStorage("TestResources"); public static DllResourceStore GetStore() => new DllResourceStore(typeof(TestResources).Assembly); @@ -178,7 +183,7 @@ namespace osu.Game.Tests.Resources { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = COVER_IMAGE_3, }, BeatmapInfo = beatmap, BeatmapHash = beatmap.Hash, diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs index b9470f3be4..becce7b22a 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeCarousel.cs @@ -16,6 +16,7 @@ using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.DailyChallenge; using osu.Game.Screens.OnlinePlay.DailyChallenge.Events; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.DailyChallenge { @@ -129,7 +130,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), null); feed.AddNewScore(ev); @@ -141,7 +142,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), RNG.Next(1, 1000)); feed.AddNewScore(ev); diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs index 4b784f661d..eda596effb 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeEventFeed.cs @@ -63,7 +63,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), null); feed.AddNewScore(ev); @@ -75,7 +75,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), RNG.Next(11, 1000)); var testScore = TestResources.CreateTestScoreInfo(); @@ -90,7 +90,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), RNG.Next(1, 10)); feed.AddNewScore(ev); @@ -108,7 +108,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), null); feed.AddNewScore(ev); diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeScoreBreakdown.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeScoreBreakdown.cs index b04696aded..b4e1ffffdb 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeScoreBreakdown.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeScoreBreakdown.cs @@ -13,6 +13,7 @@ using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.DailyChallenge; using osu.Game.Screens.OnlinePlay.DailyChallenge.Events; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.DailyChallenge { @@ -65,7 +66,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), null); breakdown.AddNewScore(ev); @@ -85,7 +86,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), null); breakdown.AddNewScore(ev); diff --git a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs index ae212f5212..4619fad938 100644 --- a/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs +++ b/osu.Game.Tests/Visual/DailyChallenge/TestSceneDailyChallengeTotalsDisplay.cs @@ -59,7 +59,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), null); totals.AddNewScore(ev); @@ -73,7 +73,7 @@ namespace osu.Game.Tests.Visual.DailyChallenge { Id = 2, Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, RNG.Next(1_000_000), RNG.Next(11, 1000)); var testScore = TestResources.CreateTestScoreInfo(); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index ed3fd4a6f8..158a1f46a0 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -21,6 +21,7 @@ using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; +using osu.Game.Tests.Resources; using osu.Game.Users; using osuTK; @@ -46,7 +47,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { Id = 3, Username = "Second", - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, })); AddAssert("two unique panels", () => this.ChildrenOfType().Select(p => p.User).Distinct().Count() == 2); @@ -79,7 +80,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { Id = 3, Username = "Second", - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }); }); @@ -159,7 +160,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { Id = 3, Username = "Second", - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, })); AddUntilStep("first user crown visible", () => this.ChildrenOfType().ElementAt(0).ChildrenOfType().First().Alpha == 1); @@ -178,7 +179,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { Id = 3, Username = "Second", - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, })); AddStep("make second user host", () => MultiplayerClient.TransferHost(3)); @@ -197,7 +198,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { Id = 3, Username = "Second", - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, })); AddUntilStep("kick buttons visible", () => this.ChildrenOfType().Count(d => d.IsPresent) == 1); @@ -218,7 +219,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { Id = 3, Username = "Second", - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, })); AddStep("kick second user", () => this.ChildrenOfType().Single(d => d.IsPresent).TriggerClick()); @@ -246,7 +247,7 @@ namespace osu.Game.Tests.Visual.Multiplayer new UserStatistics { GlobalRank = RNG.Next(1, 100000), } } }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }); MultiplayerClient.ChangeUserState(i, (MultiplayerUserState)RNG.Next(0, (int)MultiplayerUserState.Results + 1)); @@ -293,7 +294,7 @@ namespace osu.Game.Tests.Visual.Multiplayer new UserStatistics { GlobalRank = RNG.Next(1, 100000), } } }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }); MultiplayerClient.ChangeUserMods(0, new Mod[] @@ -330,7 +331,7 @@ namespace osu.Game.Tests.Visual.Multiplayer new UserStatistics { GlobalRank = RNG.Next(1, 100000), } } }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }); MultiplayerClient.ChangeUserStyle(0, 259, 2); @@ -366,7 +367,7 @@ namespace osu.Game.Tests.Visual.Multiplayer new UserStatistics { GlobalRank = RNG.Next(1, 100000), } } }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }); MultiplayerClient.ChangeUserMods(0, new Mod[] { @@ -415,7 +416,7 @@ namespace osu.Game.Tests.Visual.Multiplayer new UserStatistics { GlobalRank = RNG.Next(1, 100000), } } }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }); MultiplayerClient.ChangeUserBeatmapAvailability(0, BeatmapAvailability.LocallyAvailable()); diff --git a/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs index fb54e936bc..13b7e6e18c 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDashboardOverlay.cs @@ -6,6 +6,7 @@ using osu.Framework.Allocation; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Online { @@ -40,7 +41,7 @@ namespace osu.Game.Tests.Visual.Online Username = @"peppy", Id = 2, Colour = "99EB47", - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, IsSupporter = supportLevel > 0, SupportLevel = supportLevel } diff --git a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs index 52905fe5da..805ac44829 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneFriendDisplay.cs @@ -18,6 +18,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Metadata; using osu.Game.Overlays; using osu.Game.Overlays.Dashboard.Friends; +using osu.Game.Tests.Resources; using osu.Game.Tests.Visual.Metadata; using osu.Game.Users; @@ -237,7 +238,7 @@ namespace osu.Game.Tests.Visual.Online WasRecentlyOnline = true, Statistics = new UserStatistics { GlobalRank = 1111 }, CountryCode = CountryCode.JP, - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" + CoverUrl = TestResources.COVER_IMAGE_4 }, new APIUser { @@ -246,7 +247,7 @@ namespace osu.Game.Tests.Visual.Online WasRecentlyOnline = false, Statistics = new UserStatistics { GlobalRank = 2222 }, CountryCode = CountryCode.AU, - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, IsSupporter = true, SupportLevel = 3, }, diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserClickableAvatar.cs b/osu.Game.Tests/Visual/Online/TestSceneUserClickableAvatar.cs index 29272f7336..3333eae567 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserClickableAvatar.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserClickableAvatar.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Effects; using osu.Framework.Testing; using osu.Game.Graphics.Cursor; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Tests.Resources; using osu.Game.Users; using osu.Game.Users.Drawables; using osuTK; @@ -30,9 +31,9 @@ namespace osu.Game.Tests.Visual.Online Spacing = new Vector2(10f), Children = new[] { - generateUser(@"peppy", 2, CountryCode.AU, @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", false, "99EB47"), - generateUser(@"flyte", 3103765, CountryCode.JP, @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg", true), - generateUser(@"joshika39", 17032217, CountryCode.RS, @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", false), + generateUser(@"peppy", 2, CountryCode.AU, TestResources.COVER_IMAGE_3, false, "99EB47"), + generateUser(@"flyte", 3103765, CountryCode.JP, TestResources.COVER_IMAGE_4, true), + generateUser(@"joshika39", 17032217, CountryCode.RS, TestResources.COVER_IMAGE_3, false), new UpdateableAvatar(), new UpdateableAvatar() }, diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs index 193b356d71..d3be8d3b98 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs @@ -18,6 +18,7 @@ using osu.Game.Overlays; using osu.Game.Overlays.Profile; using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Rulesets.Osu; +using osu.Game.Tests.Resources; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online @@ -136,7 +137,7 @@ namespace osu.Game.Tests.Visual.Online { Id = 727, Username = "SomeoneIndecisive", - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c1.jpg", + CoverUrl = TestResources.COVER_IMAGE_1, Groups = new[] { new APIUserGroup { Colour = "#EB47D0", ShortName = "DEV", Name = "Developers" }, @@ -162,7 +163,7 @@ namespace osu.Game.Tests.Visual.Online { Id = 728, Username = "Certain Guy", - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + CoverUrl = TestResources.COVER_IMAGE_2, Statistics = new UserStatistics { IsRanked = false, diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index 2972f69cba..1c2fdc7860 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -13,6 +13,7 @@ using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Rulesets.Taiko; +using osu.Game.Tests.Resources; using osu.Game.Users; namespace osu.Game.Tests.Visual.Online @@ -152,7 +153,7 @@ namespace osu.Game.Tests.Visual.Online Username = $"Colorful #{hue}", Id = 1, CountryCode = CountryCode.JP, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + CoverUrl = TestResources.COVER_IMAGE_2, ProfileHue = hue, PlayMode = "osu", }); @@ -196,7 +197,7 @@ namespace osu.Game.Tests.Visual.Online Username = $"Colorful #{hue}", Id = 1, CountryCode = CountryCode.JP, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + CoverUrl = TestResources.COVER_IMAGE_2, ProfileHue = hue, PlayMode = "osu", })); @@ -212,7 +213,7 @@ namespace osu.Game.Tests.Visual.Online Username = $"Colorful #{hue2}", Id = 2, CountryCode = CountryCode.JP, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + CoverUrl = TestResources.COVER_IMAGE_2, ProfileHue = hue2, PlayMode = "osu", })); @@ -225,7 +226,7 @@ namespace osu.Game.Tests.Visual.Online Username = $"Colorful #{hue2}", Id = 2, CountryCode = CountryCode.JP, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + CoverUrl = TestResources.COVER_IMAGE_2, ProfileHue = hue2, PlayMode = "osu", })); @@ -236,7 +237,7 @@ namespace osu.Game.Tests.Visual.Online Username = @"Somebody", Id = 1, CountryCode = CountryCode.JP, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c1.jpg", + CoverUrl = TestResources.COVER_IMAGE_1, JoinDate = DateTimeOffset.Now.AddDays(-1), LastVisit = DateTimeOffset.Now, Groups = new[] diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index 6b73f1a5f4..61269a7bf4 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -416,7 +416,7 @@ namespace osu.Game.Tests.Visual.Playlists { Id = 2 + i, Username = $"peppy{i}", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, }); @@ -432,7 +432,7 @@ namespace osu.Game.Tests.Visual.Playlists { Id = 2 + i, Username = $"peppy{i}", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, }); @@ -497,7 +497,7 @@ namespace osu.Game.Tests.Visual.Playlists { Id = 2 + i, Username = $"peppy{i}", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + CoverUrl = TestResources.COVER_IMAGE_3, }, }); diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneLeaderboardScore.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneLeaderboardScore.cs index b59a31c173..9d827fdc72 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneLeaderboardScore.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneLeaderboardScore.cs @@ -141,7 +141,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 Id = 6602580, Username = @"waaiiru", CountryCode = CountryCode.ES, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c1.jpg", + CoverUrl = TestResources.COVER_IMAGE_1, }, Date = DateTimeOffset.Now.AddYears(-2), }; @@ -198,7 +198,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 Id = 6602580, Username = @"waaiiru", CountryCode = CountryCode.ES, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c1.jpg", + CoverUrl = TestResources.COVER_IMAGE_1, }, Date = DateTimeOffset.Now.AddYears(-2), }, @@ -216,7 +216,7 @@ namespace osu.Game.Tests.Visual.SongSelectV2 Id = 1541390, Username = @"Toukai", CountryCode = CountryCode.CA, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c2.jpg", + CoverUrl = TestResources.COVER_IMAGE_2, }, Date = DateTimeOffset.Now.AddMonths(-6), }, From 4bf5e9a4ddc6e1ddfb2707e9abbb0f4886283fc1 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Thu, 24 Apr 2025 21:35:54 +0900 Subject: [PATCH 54/61] Fix user covers not loading if one corner is off-screen --- osu.Game/Users/UserCoverBackground.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/osu.Game/Users/UserCoverBackground.cs b/osu.Game/Users/UserCoverBackground.cs index de6a306b2a..4d248d450b 100644 --- a/osu.Game/Users/UserCoverBackground.cs +++ b/osu.Game/Users/UserCoverBackground.cs @@ -33,7 +33,10 @@ namespace osu.Game.Users protected virtual double UnloadDelay => 5000; protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func createContentFunc, double timeBeforeLoad) - => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, UnloadDelay); + => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad, UnloadDelay) + { + RelativeSizeAxes = Axes.Both, + }; [LongRunningLoad] private partial class Cover : CompositeDrawable From bc03a2a9303d7e4338f1d1683e21ea0900e0100d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 25 Apr 2025 08:52:59 +0200 Subject: [PATCH 55/61] Attempt to improve appearance of new combo toggle / combo colour control when contracted Follow-up to https://discord.com/channels/188630481301012481/188630652340404224/1364909125812617258. Not sure if better, but an attempt was made? --- .../Graphics/Containers/ExpandingContainer.cs | 4 +- .../TernaryButtons/DrawableTernaryButton.cs | 2 +- .../TernaryButtons/NewComboTernaryButton.cs | 42 ++++++++++++------- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/osu.Game/Graphics/Containers/ExpandingContainer.cs b/osu.Game/Graphics/Containers/ExpandingContainer.cs index 2abdb508ae..477de616ac 100644 --- a/osu.Game/Graphics/Containers/ExpandingContainer.cs +++ b/osu.Game/Graphics/Containers/ExpandingContainer.cs @@ -14,6 +14,8 @@ namespace osu.Game.Graphics.Containers /// public partial class ExpandingContainer : Container, IExpandingContainer { + public const double TRANSITION_DURATION = 500; + private readonly float contractedWidth; private readonly float expandedWidth; @@ -61,7 +63,7 @@ namespace osu.Game.Graphics.Containers Expanded.BindValueChanged(v => { - this.ResizeWidthTo(v.NewValue ? expandedWidth : contractedWidth, 500, Easing.OutQuint); + this.ResizeWidthTo(v.NewValue ? expandedWidth : contractedWidth, TRANSITION_DURATION, Easing.OutQuint); }, true); } diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs index 326fdbc731..7b36b5f957 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs @@ -47,7 +47,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons private Color4 selectedBackgroundColour; private Color4 selectedIconColour; - protected Drawable Icon { get; private set; } = null!; + public Drawable Icon { get; private set; } = null!; public DrawableTernaryButton() { diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs index c6ecee5f45..259fda70c5 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs @@ -40,11 +40,14 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons private readonly BindableList selectedHitObjects = new BindableList(); private readonly BindableList comboColours = new BindableList(); + private readonly Bindable expanded = new Bindable(true); + private Container mainButtonContainer = null!; private ColourPickerButton pickerButton = null!; + private DrawableTernaryButton mainButton = null!; [BackgroundDependencyLoader] - private void load(EditorBeatmap editorBeatmap) + private void load(EditorBeatmap editorBeatmap, IExpandingContainer? expandableParent) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -54,7 +57,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Child = new DrawableTernaryButton + Child = mainButton = new DrawableTernaryButton { Current = Current, Description = "New combo", @@ -65,8 +68,6 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, - Alpha = 0, - Width = 25, ComboColours = { BindTarget = comboColours } } }; @@ -74,6 +75,9 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons selectedHitObjects.BindTo(editorBeatmap.SelectedHitObjects); if (editorBeatmap.BeatmapSkin != null) comboColours.BindTo(editorBeatmap.BeatmapSkin.ComboColours); + + if (expandableParent != null) + expanded.BindTo(expandableParent.Expanded); } protected override void LoadComplete() @@ -82,6 +86,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons selectedHitObjects.BindCollectionChanged((_, _) => updateState()); comboColours.BindCollectionChanged((_, _) => updateState()); + expanded.BindValueChanged(_ => updateState()); Current.BindValueChanged(_ => updateState(), true); } @@ -89,14 +94,21 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { if (Current.Value == TernaryState.True && selectedHitObjects.Count == 1 && selectedHitObjects.Single() is IHasComboInformation hasCombo && comboColours.Count > 1) { - mainButtonContainer.Padding = new MarginPadding { Right = 30 }; + float targetPickerButtonWidth = expanded.Value ? 25 : 10; + + pickerButton.ResizeWidthTo(targetPickerButtonWidth, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); pickerButton.SelectedHitObject.Value = hasCombo; - pickerButton.Alpha = 1; + pickerButton.Icon.Alpha = expanded.Value ? 1 : 0; + + mainButtonContainer.TransformTo(nameof(mainButtonContainer.Padding), new MarginPadding { Right = targetPickerButtonWidth + 5 }, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); + mainButton.Icon.MoveToX(expanded.Value ? 10 : 2.5f, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); } else { - mainButtonContainer.Padding = new MarginPadding(); - pickerButton.Alpha = 0; + pickerButton.ResizeWidthTo(0, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); + + mainButtonContainer.TransformTo(nameof(mainButtonContainer.Padding), new MarginPadding(), ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); + mainButton.Icon.MoveToX(10, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); } } @@ -111,12 +123,12 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; - private SpriteIcon icon = null!; + public SpriteIcon Icon { get; private set; } = null!; [BackgroundDependencyLoader] private void load() { - Add(icon = new SpriteIcon + Add(Icon = new SpriteIcon { Icon = FontAwesome.Solid.Palette, Size = new Vector2(16), @@ -149,17 +161,17 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { Enabled.Value = SelectedHitObject.Value != null; - if (SelectedHitObject.Value == null || SelectedHitObject.Value.ComboOffset == 0 || ComboColours.Count <= 1) + if (SelectedHitObject.Value == null || SelectedHitObject.Value.ComboOffset == 0 || ComboColours.Count <= 1 || !SelectedHitObject.Value.NewCombo) { BackgroundColour = colourProvider.Background3; - icon.Colour = BackgroundColour.Darken(0.5f); - icon.Blending = BlendingParameters.Additive; + Icon.Colour = BackgroundColour.Darken(0.5f); + Icon.Blending = BlendingParameters.Additive; } else { BackgroundColour = ComboColours[comboIndexFor(SelectedHitObject.Value, ComboColours)]; - icon.Colour = OsuColour.ForegroundTextColourFor(BackgroundColour); - icon.Blending = BlendingParameters.Inherit; + Icon.Colour = OsuColour.ForegroundTextColourFor(BackgroundColour); + Icon.Blending = BlendingParameters.Inherit; } } From 032459ea4e12054bf8bcc911e28784ec49c9bf7b Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 25 Apr 2025 16:25:27 +0900 Subject: [PATCH 56/61] Add test mode which allow more realistic testing of samples --- .../TestSceneSubmissionStageProgress.cs | 27 +++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs index 1598584144..ee22cbda71 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs @@ -8,6 +8,8 @@ using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Overlays; using osu.Game.Screens.Edit.Submission; @@ -28,6 +30,8 @@ namespace osu.Game.Tests.Visual.Editing [Test] public void TestAppearance() { + float incrementingProgress = 0; + SubmissionStageProgress progress = null!; AddStep("create content", () => Child = new Container @@ -45,8 +49,27 @@ namespace osu.Game.Tests.Visual.Editing }); AddStep("not started", () => progress.SetNotStarted()); AddStep("indeterminate progress", () => progress.SetInProgress()); - AddStep("30% progress", () => progress.SetInProgress(0.3f)); - AddStep("70% progress", () => progress.SetInProgress(0.7f)); + AddStep("increase progress to 100", () => + { + incrementingProgress = 0; + + ScheduledDelegate? task = null; + + task = Scheduler.AddDelayed(() => + { + if (incrementingProgress >= 1) + { + // ReSharper disable once AccessToModifiedClosure + task?.Cancel(); + return; + } + + if (RNG.NextDouble() < 0.01) + progress.SetInProgress(incrementingProgress += RNG.NextSingle(0.08f)); + }, 0, true); + }); + + AddUntilStep("wait for completed", () => incrementingProgress >= 1); AddStep("completed", () => progress.SetCompleted()); AddStep("failed", () => progress.SetFailed("the foobarator has defrobnicated")); AddStep("failed with long message", () => progress.SetFailed("this is a very very very very VERY VEEEEEEEEEEEEEEEEEEEEEEEEERY long error message like you would never believe")); From 40f6283fb5736fa0dfe25e9c156dd901649941b9 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 25 Apr 2025 16:35:34 +0900 Subject: [PATCH 57/61] Update resources --- osu.Game/osu.Game.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 7231a9be56..a300d971b5 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,7 +36,7 @@ - + From c6a11a56a3eb6e14fa37a8c682985b8668326e3a Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Fri, 25 Apr 2025 17:22:25 +0900 Subject: [PATCH 58/61] Remove unnecessary try-finally usage --- .../Edit/Submission/SubmissionStageProgress.cs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs index 905f654d04..389ba2470a 100644 --- a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs +++ b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs @@ -191,13 +191,10 @@ namespace osu.Game.Screens.Edit.Submission progressSampleFadeDelegate?.Cancel(); progressSampleStopDelegate?.Cancel(); - try + progressBarContainer.FadeTo(status.Value == StageStatusType.InProgress && progress.Value != null ? 1 : 0, transition_duration, transition_easing); + + if (progress.Value is float progressValue) { - if (progress.Value == null) - return; - - float progressValue = progress.Value.Value; - progressBar.ResizeWidthTo(progressValue, transition_duration, transition_easing); if (progressSampleChannel == null || Precision.AlmostEquals(progressValue, 0f)) @@ -217,10 +214,6 @@ namespace osu.Game.Screens.Edit.Submission progressSampleStopDelegate = Scheduler.AddDelayed(() => { progressSampleChannel.Stop(); }, fadeout_duration); }, transition_duration - fadeout_duration); } - finally - { - progressBarContainer.FadeTo(status.Value == StageStatusType.InProgress && progress.Value != null ? 1 : 0, transition_duration, transition_easing); - } } private void updateStatus() From 0287ca285c5c5edd40519f44234db53cbdd80983 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sat, 26 Apr 2025 04:51:03 +0900 Subject: [PATCH 59/61] Fix filename not matching test scene --- ...UDOverlayLayouts.cs => TestSceneHUDOverlayRulesetLayouts.cs} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename osu.Game.Tests/Visual/Gameplay/{TestSceneHUDOverlayLayouts.cs => TestSceneHUDOverlayRulesetLayouts.cs} (97%) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayLayouts.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs similarity index 97% rename from osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayLayouts.cs rename to osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs index ae6e297f96..249128565c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayLayouts.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs @@ -30,7 +30,7 @@ using osu.Game.Tests.Visual.Spectator; namespace osu.Game.Tests.Visual.Gameplay { - [System.ComponentModel.Description(@"Exercises the appearance of the HUD overlay on various skin and ruleset combinations.")] + [Description(@"Exercises the appearance of the HUD overlay on various skin and ruleset combinations.")] public partial class TestSceneHUDOverlayRulesetLayouts : OsuTestScene, IStorageResourceProvider { private readonly Dictionary skins = new Dictionary(); From 54e0e1420fe45463e25e1bf451637a9a84978148 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sat, 26 Apr 2025 05:14:36 +0900 Subject: [PATCH 60/61] Fix `TriangleSkin` not always returning a container for `GlobalSkinnableContainerLookup` We have other safeties which mean that this is not an issue during gameplay, but in the new `TestSceneHUDOverlayRulesetLayouts` it became apparent that allowing this to fallback (via `null` return) could lead to weirdness. --- osu.Game/Skinning/TrianglesSkin.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/osu.Game/Skinning/TrianglesSkin.cs b/osu.Game/Skinning/TrianglesSkin.cs index a4a967bed9..18ca7629d7 100644 --- a/osu.Game/Skinning/TrianglesSkin.cs +++ b/osu.Game/Skinning/TrianglesSkin.cs @@ -68,10 +68,6 @@ namespace osu.Game.Skinning switch (lookup) { case GlobalSkinnableContainerLookup containerLookup: - // Only handle global level defaults for now. - if (containerLookup.Ruleset != null) - return null; - switch (containerLookup.Lookup) { case GlobalSkinnableContainers.SongSelect: @@ -83,6 +79,11 @@ namespace osu.Game.Skinning return songSelectComponents; case GlobalSkinnableContainers.MainHUDComponents: + if (containerLookup.Ruleset != null) + { + return new DefaultSkinComponentsContainer(_ => { }); + } + var skinnableTargetWrapper = new DefaultSkinComponentsContainer(container => { var score = container.OfType().FirstOrDefault(); From 03014638107782c7cb4e1cbcfccda2c08c55cf5b Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Sat, 26 Apr 2025 05:18:24 +0900 Subject: [PATCH 61/61] Adjust variables for legibility I found the previous way things were written a bit awkward. Easier to just enforce non-null here. --- .../Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs index 249128565c..1f883aa784 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs @@ -63,8 +63,7 @@ namespace osu.Game.Tests.Visual.Gameplay var beatmap = ruleset.CreateBeatmapConverter(new Beatmap()).Convert(); var drawableRuleset = ruleset.CreateDrawableRulesetWith(beatmap); - var skin = skins[skinName]; - ISkin provider = ruleset.CreateSkinTransformer(skin, beatmap) ?? skin; + ISkin provider = ruleset.CreateSkinTransformer(skins[skinName], beatmap)!; var gameplayState = TestGameplayState.Create(ruleset); ((Bindable)gameplayState.PlayingState).Value = LocalUserPlayingState.Playing;