diff --git a/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs b/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs index eb243bfab7..b7b53587ab 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModCover.cs @@ -29,6 +29,8 @@ namespace osu.Game.Rulesets.Mania.Mods public override bool Ranked => 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 54a0b8f36d..f340608fd1 100644 --- a/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs +++ b/osu.Game.Rulesets.Mania/Mods/ManiaModFadeIn.cs @@ -14,6 +14,7 @@ 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; + 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 2964ca9396..6b3bc5f10f 100644 --- a/osu.Game.Tests/Mods/ModUtilsTest.cs +++ b/osu.Game.Tests/Mods/ModUtilsTest.cs @@ -2,14 +2,20 @@ // 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.Extensions.TypeExtensions; 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; @@ -182,98 +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() - }, - }; - [TestCaseSource(nameof(invalid_mod_test_scenarios))] public void TestInvalidModScenarios(Mod[] inputMods, Type[] expectedInvalid) { @@ -287,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, 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); - - 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() { @@ -343,38 +231,127 @@ 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.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)); + // 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.IsValidModForMatchType(new OsuModHardRock(), MatchType.HeadToHead)); - Assert.IsTrue(ModUtils.IsValidModForMatchType(new OsuModDoubleTime(), MatchType.HeadToHead)); - // 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)); + // 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() { - 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)); + // The rest are tested by TestMultiplayerModScenarios. + 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)); + } - Assert.IsTrue(ModUtils.IsValidFreeModForMatchType(new OsuModHardRock(), MatchType.HeadToHead)); - // 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)); + [Test] + public void TestFreestyleRulesetCompatibility() + { + HashSet commonAcronyms = new HashSet(); + + 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 new Ruleset[] { new OsuRuleset(), new TaikoRuleset(), new CatchRuleset(), new ManiaRuleset() }) + { + foreach (var mod in ruleset.CreateAllMods()) + { + 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!"); + } + } + }); } public abstract class CustomMod1 : Mod, IModCompatibilitySpecification @@ -385,7 +362,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,18 +383,22 @@ namespace osu.Game.Tests.Mods public override bool ValidForMultiplayerAsFreeMod => false; } - public class EditableMod : Mod + public class InvalidFreestyleRequiredMod : 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 ValidForFreestyleAsRequiredMod => false; } - public interface IModCompatibilitySpecification + public interface IModCompatibilitySpecification; + + 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.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/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 8ba62fd0e2..b7b6a2d7b3 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; } diff --git a/osu.Game/Rulesets/Mods/IMod.cs b/osu.Game/Rulesets/Mods/IMod.cs index 3a33d14835..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; } @@ -53,6 +53,12 @@ namespace osu.Game.Rulesets.Mods /// bool ValidForMultiplayer { get; } + /// + /// 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; } + /// /// 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/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs index f23f16fd44..727db913e2 100644 --- a/osu.Game/Rulesets/Mods/Mod.cs +++ b/osu.Game/Rulesets/Mods/Mod.cs @@ -87,56 +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 "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. - /// - /// + public virtual bool ValidForFreestyleAsRequiredMod => false; + [JsonIgnore] public virtual bool ValidForMultiplayerAsFreeMod => true; - /// [JsonIgnore] public virtual bool AlwaysValidForSubmission => false; @@ -146,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; diff --git a/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs b/osu.Game/Rulesets/Mods/ModAccuracyChallenge.cs index db16e771d3..83d5fb027e 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 ValidForFreestyleAsRequiredMod => true; + public override IEnumerable<(LocalisableString setting, LocalisableString value)> SettingDescription { get diff --git a/osu.Game/Rulesets/Mods/ModClassic.cs b/osu.Game/Rulesets/Mods/ModClassic.cs index b0f6ba9374..e20ac5dfc7 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 ValidForFreestyleAsRequiredMod => true; } } diff --git a/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs b/osu.Game/Rulesets/Mods/ModDifficultyAdjust.cs index cdde1b73b6..79fc918487 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 ValidForFreestyleAsRequiredMod => true; + public override Type[] IncompatibleMods => new[] { typeof(ModEasy), typeof(ModHardRock) }; protected const int FIRST_SETTING_ORDER = 1; diff --git a/osu.Game/Rulesets/Mods/ModEasy.cs b/osu.Game/Rulesets/Mods/ModEasy.cs index da43a6b294..b0ac0d5cce 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 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 64c193d25f..da45b7cc92 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 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 1e99891b99..ce40e6e075 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 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 2915cb9bea..f7a1336fd2 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 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 7aefefc58d..2eb243d565 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 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 1aaef8eac4..121524e594 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 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 5bedf443da..e7957ac4c5 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 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 a824731830..d5fc1363bb 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 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 d07ff6ce87..f82033938a 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 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 fd85709b52..30c41c15f5 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 ValidForFreestyleAsRequiredMod => true; public sealed override bool ValidForMultiplayerAsFreeMod => false; public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModAdaptiveSpeed) }; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs index 8463a4720c..d2c964c967 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerUserModSelectOverlay.cs @@ -81,14 +81,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 = 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 = 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/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 9bedecc221..bb6d75fa3b 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, }; } @@ -115,45 +115,74 @@ namespace osu.Game.Screens.OnlinePlay Freestyle.Value = initialItem.Freestyle; } - Mods.BindValueChanged(onModsChanged); + Mods.BindValueChanged(onGlobalModsChanged); Ruleset.BindValueChanged(onRulesetChanged); - Freestyle.BindValueChanged(onFreestyleChanged, true); + Freestyle.BindValueChanged(onFreestyleChanged); freeModSelectOverlayRegistration = OverlayManager?.RegisterBlockingOverlay(freeModSelect); + + updateFooterButtons(); + updateValidMods(); } private void onFreestyleChanged(ValueChangedEvent enabled) { + updateFooterButtons(); + updateValidMods(); + if (enabled.NewValue) { - freeModsFooterButton.Enabled.Value = false; - freeModsFooterButton.Enabled.Value = false; - ModsFooterButton.Enabled.Value = false; - - ModSelect.Hide(); - freeModSelect.Hide(); - - Mods.Value = []; + // 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; - ModsFooterButton.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 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. - freeModSelect.IsValidMod = isValidFreeMod; + updateValidMods(); } 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 = []; + } + + private void updateFooterButtons() + { + if (Freestyle.Value) + { + freeModsFooterButton.Enabled.Value = false; + freeModSelect.Hide(); + } + else + 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() + { + 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; + freeModSelect.IsValidMod = isValidAllowedMod; } protected sealed override bool OnStart() @@ -195,7 +224,7 @@ namespace osu.Game.Screens.OnlinePlay protected override ModSelectOverlay CreateModSelectOverlay() => new UserModSelectOverlay(OverlayColourScheme.Plum) { - IsValidMod = isValidMod + IsValidMod = isValidRequiredMod }; protected override IEnumerable<(FooterButton button, OverlayContainer? overlay)> CreateSongSelectFooterButtons() @@ -221,22 +250,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 isValidMod(Mod mod) => ModUtils.IsValidModForMatchType(mod, room.Type); + 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.IsValidFreeModForMatchType(mod, room.Type) - // 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) { diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 9834598ac0..cfd651ba4d 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -572,31 +572,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(); - - if (item.Freestyle) - return rulesetInstance.AllMods.OfType().Where(m => ModUtils.IsValidFreeModForMatchType(m, room.Type)).ToArray(); - - 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.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(); } @@ -613,7 +600,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.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 @@ -623,7 +610,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) diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs index ac24bf2130..e944b188f1 100644 --- a/osu.Game/Utils/ModUtils.cs +++ b/osu.Game/Utils/ModUtils.cs @@ -128,12 +128,13 @@ 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. /// 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(); @@ -145,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, out invalidMods); + return checkValid(mods, m => IsValidModForMatch(m, true, MatchType.HeadToHead, 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, @@ -157,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, false, MatchType.HeadToHead, freestyle), out invalidMods); private static bool checkValid(IEnumerable mods, Predicate valid, [NotNullWhen(false)] out List? invalidMods) { @@ -295,43 +297,59 @@ namespace osu.Game.Utils } /// - /// Determines whether a mod can be applied to playlist items in the given match type. + /// Determines whether a given mod is valid on a playlist item. /// /// The mod to test. - /// The match type. - public static bool IsValidModForMatchType(Mod mod, MatchType type) + /// + /// 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, bool required, MatchType matchType, bool freestyle) { if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation) return false; - switch (type) + if (freestyle && required && !mod.ValidForFreestyleAsRequiredMod) + return false; + + switch (matchType) { case MatchType.Playlists: return true; default: - return mod.ValidForMultiplayer; + return required ? mod.ValidForMultiplayer : mod.ValidForMultiplayerAsFreeMod; } } /// - /// Determines whether a mod can be applied as a free mod to playlist items in the given match type. + /// 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 mod to test. - /// The match type. - public static bool IsValidFreeModForMatchType(Mod mod, MatchType type) + /// 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[] EnumerateUserSelectableFreeMods(MatchType matchType, IEnumerable requiredMods, IEnumerable allowedMods, bool freestyle, Ruleset userRuleset) { - if (mod.Type == ModType.System || !mod.UserPlayable || !mod.HasImplementation) - return false; - - switch (type) + if (freestyle) { - case MatchType.Playlists: - return true; + Mod[] rulesetRequiredMods = requiredMods.Select(m => m.ToMod(userRuleset)).ToArray(); - default: - return mod.ValidForMultiplayerAsFreeMod; + // 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, false, matchType, 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(); } } }