diff --git a/osu.Game.Tests/Mods/ModUtilsTest.cs b/osu.Game.Tests/Mods/ModUtilsTest.cs
index 4c126f0a3b..6c9dddf51f 100644
--- a/osu.Game.Tests/Mods/ModUtilsTest.cs
+++ b/osu.Game.Tests/Mods/ModUtilsTest.cs
@@ -137,33 +137,137 @@ namespace osu.Game.Tests.Mods
// incompatible pair.
new object[]
{
- new Mod[] { new OsuModDoubleTime(), new OsuModHalfTime() },
- new[] { typeof(OsuModDoubleTime), typeof(OsuModHalfTime) }
+ new Mod[] { new OsuModHidden(), new OsuModApproachDifferent() },
+ new[] { typeof(OsuModHidden), typeof(OsuModApproachDifferent) }
},
// incompatible pair with derived class.
new object[]
{
- new Mod[] { new OsuModNightcore(), new OsuModHalfTime() },
- new[] { typeof(OsuModNightcore), typeof(OsuModHalfTime) }
+ new Mod[] { new OsuModDeflate(), new OsuModApproachDifferent() },
+ new[] { typeof(OsuModDeflate), typeof(OsuModApproachDifferent) }
},
// system mod.
new object[]
{
- new Mod[] { new OsuModDoubleTime(), new OsuModTouchDevice() },
+ new Mod[] { new OsuModHidden(), new OsuModTouchDevice() },
new[] { typeof(OsuModTouchDevice) }
},
// multi mod.
new object[]
{
- new Mod[] { new MultiMod(new OsuModHalfTime()), new OsuModDaycore() },
+ new Mod[] { new MultiMod(new OsuModSuddenDeath(), new OsuModPerfect()) },
new[] { typeof(MultiMod) }
},
+ // invalid multiplayer mod is valid for local.
+ new object[]
+ {
+ new Mod[] { new OsuModHidden(), new InvalidMultiplayerMod() },
+ null
+ },
+ // invalid free mod is valid for local.
+ new object[]
+ {
+ new Mod[] { new OsuModHidden(), new InvalidMultiplayerFreeMod() },
+ null
+ },
// valid pair.
new object[]
{
- new Mod[] { new OsuModDoubleTime(), new OsuModHardRock() },
+ new Mod[] { new OsuModHidden(), new OsuModHardRock() },
null
- }
+ },
+ };
+
+ 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() },
+ null
+ },
+ // valid pair.
+ new object[]
+ {
+ new Mod[] { new OsuModHidden(), new OsuModHardRock() },
+ null
+ },
+ };
+
+ 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() },
+ null,
+ },
+ // incompatible pair with derived class is valid for free mods.
+ new object[]
+ {
+ new Mod[] { new OsuModDeflate(), new OsuModSpinIn() },
+ null,
+ },
+ // valid pair.
+ new object[]
+ {
+ new Mod[] { new OsuModHidden(), new OsuModHardRock() },
+ null
+ },
};
[TestCaseSource(nameof(invalid_mod_test_scenarios))]
@@ -179,6 +283,32 @@ 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 == null));
+
+ 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 == null));
+
+ if (isValid)
+ Assert.IsNull(invalid);
+ else
+ Assert.That(invalid.Select(t => t.GetType()), Is.EquivalentTo(expectedInvalid));
+ }
+
public abstract class CustomMod1 : Mod, IModCompatibilitySpecification
{
}
@@ -187,6 +317,27 @@ namespace osu.Game.Tests.Mods
{
}
+ public class InvalidMultiplayerMod : Mod
+ {
+ public override string Name => string.Empty;
+ public override string Description => string.Empty;
+ public override string Acronym => string.Empty;
+ public override double ScoreMultiplier => 1;
+ public override bool HasImplementation => true;
+ public override bool ValidForMultiplayer => false;
+ public override bool ValidForMultiplayerAsFreeMod => false;
+ }
+
+ private class InvalidMultiplayerFreeMod : Mod
+ {
+ public override string Name => string.Empty;
+ public override string Description => string.Empty;
+ public override string Acronym => string.Empty;
+ public override double ScoreMultiplier => 1;
+ public override bool HasImplementation => true;
+ public override bool ValidForMultiplayerAsFreeMod => false;
+ }
+
public interface IModCompatibilitySpecification
{
}
diff --git a/osu.Game/Rulesets/Mods/IMod.cs b/osu.Game/Rulesets/Mods/IMod.cs
index d5d1de91de..30fa1ea8cb 100644
--- a/osu.Game/Rulesets/Mods/IMod.cs
+++ b/osu.Game/Rulesets/Mods/IMod.cs
@@ -39,6 +39,18 @@ namespace osu.Game.Rulesets.Mods
///
bool UserPlayable { get; }
+ ///
+ /// Whether this mod is valid for multiplayer matches.
+ /// Should be false for mods that make gameplay duration dependent on user input (e.g. ).
+ ///
+ bool ValidForMultiplayer { 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 ).
+ ///
+ bool ValidForMultiplayerAsFreeMod { get; }
+
///
/// Create a fresh instance based on this mod.
///
diff --git a/osu.Game/Rulesets/Mods/Mod.cs b/osu.Game/Rulesets/Mods/Mod.cs
index b2d4be54ce..af1550f8a9 100644
--- a/osu.Game/Rulesets/Mods/Mod.cs
+++ b/osu.Game/Rulesets/Mods/Mod.cs
@@ -94,6 +94,12 @@ namespace osu.Game.Rulesets.Mods
[JsonIgnore]
public virtual bool UserPlayable => true;
+ [JsonIgnore]
+ public virtual bool ValidForMultiplayer => true;
+
+ [JsonIgnore]
+ public virtual bool ValidForMultiplayerAsFreeMod => true;
+
[Obsolete("Going forward, the concept of \"ranked\" doesn't exist. The only exceptions are automation mods, which should now override and set UserPlayable to false.")] // Can be removed 20211009
public virtual bool Ranked => false;
diff --git a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs
index 1115b95e6f..93251f7b2d 100644
--- a/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs
+++ b/osu.Game/Rulesets/Mods/ModAdaptiveSpeed.cs
@@ -31,6 +31,9 @@ namespace osu.Game.Rulesets.Mods
public override double ScoreMultiplier => 1;
+ public override bool ValidForMultiplayer => false;
+ public override bool ValidForMultiplayerAsFreeMod => false;
+
public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModTimeRamp) };
[SettingSource("Initial rate", "The starting speed of the track")]
diff --git a/osu.Game/Rulesets/Mods/ModAutoplay.cs b/osu.Game/Rulesets/Mods/ModAutoplay.cs
index 87dc627b19..0ebe11b393 100644
--- a/osu.Game/Rulesets/Mods/ModAutoplay.cs
+++ b/osu.Game/Rulesets/Mods/ModAutoplay.cs
@@ -25,6 +25,8 @@ namespace osu.Game.Rulesets.Mods
public bool RestartOnFail => false;
public override bool UserPlayable => false;
+ public override bool ValidForMultiplayer => false;
+ public override bool ValidForMultiplayerAsFreeMod => false;
public override Type[] IncompatibleMods => new[] { typeof(ModCinema), typeof(ModRelax), typeof(ModFailCondition), typeof(ModNoFail) };
diff --git a/osu.Game/Rulesets/Mods/ModRateAdjust.cs b/osu.Game/Rulesets/Mods/ModRateAdjust.cs
index 88fb609c07..05953f903f 100644
--- a/osu.Game/Rulesets/Mods/ModRateAdjust.cs
+++ b/osu.Game/Rulesets/Mods/ModRateAdjust.cs
@@ -11,6 +11,8 @@ namespace osu.Game.Rulesets.Mods
{
public abstract class ModRateAdjust : Mod, IApplicableToRate
{
+ public override bool ValidForMultiplayerAsFreeMod => false;
+
public abstract BindableNumber SpeedChange { get; }
public virtual void ApplyToTrack(ITrack track)
diff --git a/osu.Game/Rulesets/Mods/ModTimeRamp.cs b/osu.Game/Rulesets/Mods/ModTimeRamp.cs
index b6b2decede..fe6d54332c 100644
--- a/osu.Game/Rulesets/Mods/ModTimeRamp.cs
+++ b/osu.Game/Rulesets/Mods/ModTimeRamp.cs
@@ -30,6 +30,8 @@ namespace osu.Game.Rulesets.Mods
[SettingSource("Adjust pitch", "Should pitch be adjusted with speed")]
public abstract BindableBool AdjustPitch { get; }
+ public override bool ValidForMultiplayerAsFreeMod => false;
+
public override Type[] IncompatibleMods => new[] { typeof(ModRateAdjust), typeof(ModAdaptiveSpeed) };
public override string SettingDescription => $"{InitialRate.Value:N2}x to {FinalRate.Value:N2}x";
diff --git a/osu.Game/Rulesets/Mods/UnknownMod.cs b/osu.Game/Rulesets/Mods/UnknownMod.cs
index b426386d7a..72de0ad653 100644
--- a/osu.Game/Rulesets/Mods/UnknownMod.cs
+++ b/osu.Game/Rulesets/Mods/UnknownMod.cs
@@ -16,6 +16,8 @@ namespace osu.Game.Rulesets.Mods
public override double ScoreMultiplier => 0;
public override bool UserPlayable => false;
+ public override bool ValidForMultiplayer => false;
+ public override bool ValidForMultiplayerAsFreeMod => false;
public override ModType Type => ModType.System;
diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs
index 848424bc76..929c3ee321 100644
--- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs
+++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs
@@ -95,6 +95,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer
protected override BeatmapDetailArea CreateBeatmapDetailArea() => new PlayBeatmapDetailArea();
- protected override bool IsValidFreeMod(Mod mod) => base.IsValidFreeMod(mod) && !(mod is ModTimeRamp) && !(mod is ModRateAdjust);
+ protected override bool IsValidMod(Mod mod) => base.IsValidMod(mod) && mod.ValidForMultiplayer;
+
+ protected override bool IsValidFreeMod(Mod mod) => base.IsValidFreeMod(mod) && mod.ValidForMultiplayerAsFreeMod;
}
}
diff --git a/osu.Game/Utils/ModUtils.cs b/osu.Game/Utils/ModUtils.cs
index 8df44216b6..ea092a8ca3 100644
--- a/osu.Game/Utils/ModUtils.cs
+++ b/osu.Game/Utils/ModUtils.cs
@@ -106,22 +106,69 @@ namespace osu.Game.Utils
}
///
- /// Check the provided combination of mods are valid for a local gameplay session.
+ /// Checks that all s in a combination are valid for a local gameplay session.
///
/// The mods to check.
- /// Invalid mods, if any were found. Can be null if all mods were valid.
+ /// 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 CheckValidForGameplay(IEnumerable mods, [NotNullWhen(false)] out List? invalidMods)
{
mods = mods.ToArray();
- // exclude multi mods from compatibility checks.
- // the loop below automatically marks all multi mods as not valid for gameplay anyway.
- CheckCompatibleSet(mods.Where(m => !(m is MultiMod)), out invalidMods);
+ // checking compatibility of multi mods would try to flatten them and return incompatible mods.
+ // in gameplay context, we never want MultiMod selected in the first place, therefore check against it first.
+ if (!checkValid(mods, m => !(m is MultiMod), out invalidMods))
+ return false;
+
+ if (!CheckCompatibleSet(mods, out invalidMods))
+ return false;
+
+ return checkValid(mods, m => m.Type != ModType.System && m.HasImplementation, out invalidMods);
+ }
+
+ ///
+ /// Checks that all s in a combination are valid as "required mods" in a multiplayer match session.
+ ///
+ /// 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 CheckValidRequiredModsForMultiplayer(IEnumerable mods, [NotNullWhen(false)] out List? invalidMods)
+ {
+ mods = mods.ToArray();
+
+ // checking compatibility of multi mods would try to flatten them and return incompatible mods.
+ // in gameplay context, we never want MultiMod selected in the first place, therefore check against it first.
+ if (!checkValid(mods, m => !(m is MultiMod), out invalidMods))
+ return false;
+
+ if (!CheckCompatibleSet(mods, out invalidMods))
+ return false;
+
+ return checkValid(mods, m => m.Type != ModType.System && m.HasImplementation && m.ValidForMultiplayer, out invalidMods);
+ }
+
+ ///
+ /// Checks that all s in a combination are valid as "free mods" in a multiplayer match session.
+ ///
+ ///
+ /// Note that this does not check compatibility between mods,
+ /// given that the passed mods are expected to be the ones to be allowed for the multiplayer match,
+ /// not to be confused with the list of mods the user currently has selected for the multiplayer match.
+ ///
+ /// 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 CheckValidFreeModsForMultiplayer(IEnumerable mods, [NotNullWhen(false)] out List? invalidMods)
+ => checkValid(mods, m => m.Type != ModType.System && m.HasImplementation && m.ValidForMultiplayerAsFreeMod && !(m is MultiMod), out invalidMods);
+
+ private static bool checkValid(IEnumerable mods, Predicate valid, [NotNullWhen(false)] out List? invalidMods)
+ {
+ mods = mods.ToArray();
+ invalidMods = null;
foreach (var mod in mods)
{
- if (mod.Type == ModType.System || !mod.HasImplementation || mod is MultiMod)
+ if (!valid(mod))
{
invalidMods ??= new List();
invalidMods.Add(mod);