From ef0cf5143d1d14eb280240f62bc3d88e455182a5 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Fri, 4 Apr 2025 13:20:48 +0900 Subject: [PATCH 01/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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 f23eb995276a5bcf9b0370ef50640299bd3801cb Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 23 Apr 2025 15:04:44 +0900 Subject: [PATCH 22/24] 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 43386a193b6f5da2eb3a4efa7e2e537b60b42238 Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 23 Apr 2025 15:32:17 +0900 Subject: [PATCH 23/24] 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 e36c6db008d5caadb002d218976f2f1de7be75cd Mon Sep 17 00:00:00 2001 From: Dan Balasescu Date: Wed, 23 Apr 2025 18:17:27 +0900 Subject: [PATCH 24/24] 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; }