1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-27 05:09:58 +08:00

Merge pull request #32674 from smoogipoo/freestyle-mods

Allow mods/freemods in combination with freestyle
This commit is contained in:
Bartłomiej Dach
2025-04-24 14:51:04 +02:00
committed by GitHub
Unverified
25 changed files with 302 additions and 281 deletions
@@ -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<float> Coverage { get; } = new BindableFloat(0.5f)
{
@@ -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[]
{
+130 -149
View File
@@ -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<Type>()
},
// valid pair.
new object[]
{
new Mod[] { new OsuModHidden(), new OsuModHardRock() },
Array.Empty<Type>()
},
};
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<Type>(),
},
// incompatible pair with derived class is valid for free mods.
new object[]
{
new Mod[] { new OsuModDeflate(), new OsuModSpinIn() },
Array.Empty<Type>(),
},
// valid pair.
new object[]
{
new Mod[] { new OsuModHidden(), new OsuModHardRock() },
Array.Empty<Type>()
},
};
[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<Mod>? 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<string> commonAcronyms = new HashSet<string>();
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()))}]";
}
}
}
@@ -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())]
},
]
};
@@ -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; }
/// <summary>
/// Mods that should be applied for every participant in the room.
/// </summary>
[Key(5)]
public IEnumerable<APIMod> RequiredMods { get; set; } = Enumerable.Empty<APIMod>();
/// <summary>
/// Mods that participants are allowed to apply at their own discretion.
/// </summary>
/// <remarks>
/// This will be empty when <see cref="Freestyle"/> is <c>true</c>, but participants may still select any mods from their choice of ruleset,
/// provided the mod <see cref="IMod.ValidForMultiplayerAsFreeMod">implementation</see> indicates free-mod validity
/// and is <see cref="ModUtils.CheckCompatibleSet(IEnumerable{Mod})">compatible</see> with the rest of the user's selection.
/// </remarks>
[Key(6)]
public IEnumerable<APIMod> AllowedMods { get; set; } = Enumerable.Empty<APIMod>();
@@ -57,7 +70,7 @@ namespace osu.Game.Online.Rooms
public double StarRating { get; set; }
/// <summary>
/// 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.
/// </summary>
[Key(11)]
public bool Freestyle { get; set; }
+13 -1
View File
@@ -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; }
/// <summary>
/// Mods that participants are allowed to apply at their own discretion.
/// </summary>
/// <remarks>
/// This will be empty when <see cref="Freestyle"/> is <c>true</c>, but participants may still select any mods from their choice of ruleset,
/// provided the mod is <see cref="ModUtils.CheckCompatibleSet(IEnumerable{Mod})">compatible</see> with the rest of the user's selection.
/// </remarks>
[JsonProperty("allowed_mods")]
public APIMod[] AllowedMods { get; set; } = Array.Empty<APIMod>();
/// <summary>
/// Mods that should be applied for every participant in the room.
/// </summary>
[JsonProperty("required_mods")]
public APIMod[] RequiredMods { get; set; } = Array.Empty<APIMod>();
@@ -68,7 +80,7 @@ namespace osu.Game.Online.Rooms
}
/// <summary>
/// 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.
/// </summary>
[JsonProperty("freestyle")]
public bool Freestyle { get; set; }
+7 -1
View File
@@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Mods
IconUsage? Icon { get; }
/// <summary>
/// Whether this mod is playable by an end user.
/// Whether this mod is playable by a real human user.
/// Should be <c>false</c> for cases where the user is not interacting with the game (so it can be excluded from multiplayer selection, for example).
/// </summary>
bool UserPlayable { get; }
@@ -53,6 +53,12 @@ namespace osu.Game.Rulesets.Mods
/// </summary>
bool ValidForMultiplayer { get; }
/// <summary>
/// Whether this mod is valid as a required mod when freestyle is enabled.
/// Should be <c>true</c> for mods that are guaranteed to be implemented across all rulesets.
/// </summary>
bool ValidForFreestyleAsRequiredMod { get; }
/// <summary>
/// Whether this mod is valid as a free mod in multiplayer matches.
/// Should be <c>false</c> for mods that affect the gameplay duration (e.g. <see cref="ModRateAdjust"/> and <see cref="ModTimeRamp"/>).
+2 -44
View File
@@ -87,56 +87,17 @@ namespace osu.Game.Rulesets.Mods
[JsonIgnore]
public virtual bool HasImplementation => this is IApplicableMod;
/// <summary>
/// Whether this mod can be played by a real human user.
/// Non-user-playable mods are not viable for single-player score submission.
/// </summary>
/// <example>
/// <list type="bullet">
/// <item><see cref="ModDoubleTime"/> is user-playable.</item>
/// <item><see cref="ModAutoplay"/> is not user-playable.</item>
/// </list>
/// </example>
[JsonIgnore]
public virtual bool UserPlayable => true;
/// <summary>
/// Whether this mod can be specified as a "required" mod in a multiplayer context.
/// </summary>
/// <example>
/// <list type="bullet">
/// <item><see cref="ModHardRock"/> is valid for multiplayer.</item>
/// <item>
/// <see cref="ModDoubleTime"/> is valid for multiplayer as long as it is a <b>required</b> mod,
/// as that ensures the same duration of gameplay for all users in the room.
/// </item>
/// <item>
/// <see cref="ModAdaptiveSpeed"/> is not valid for multiplayer, as it leads to varying
/// gameplay duration depending on how the users in the room play.
/// </item>
/// <item><see cref="ModAutoplay"/> is not valid for multiplayer.</item>
/// </list>
/// </example>
[JsonIgnore]
public virtual bool ValidForMultiplayer => true;
/// <summary>
/// Whether this mod can be specified as a "free" or "allowed" mod in a multiplayer context.
/// </summary>
/// <example>
/// <list type="bullet">
/// <item><see cref="ModHardRock"/> is valid for multiplayer as a free mod.</item>
/// <item>
/// <see cref="ModDoubleTime"/> is <b>not</b> 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.
/// </item>
/// <item><see cref="ModAutoplay"/> is not valid for multiplayer as a free mod.</item>
/// </list>
/// </example>
public virtual bool ValidForFreestyleAsRequiredMod => false;
[JsonIgnore]
public virtual bool ValidForMultiplayerAsFreeMod => true;
/// <inheritdoc/>
[JsonIgnore]
public virtual bool AlwaysValidForSubmission => false;
@@ -146,9 +107,6 @@ namespace osu.Game.Rulesets.Mods
[JsonIgnore]
public virtual bool RequiresConfiguration => false;
/// <summary>
/// Whether scores with this mod active can give performance points.
/// </summary>
[JsonIgnore]
public virtual bool Ranked => false;
@@ -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
+2
View File
@@ -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).
/// </summary>
public sealed override bool Ranked => false;
public sealed override bool ValidForFreestyleAsRequiredMod => true;
}
}
@@ -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;
+1
View File
@@ -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)
{
+1
View File
@@ -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; }
+1
View File
@@ -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;
+1
View File
@@ -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)
{
+1
View File
@@ -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<TObject> : ModMuted, IApplicableToDrawableRuleset<TObject>, IApplicableToTrack, IApplicableToScoreProcessor
+1
View File
@@ -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<bool> showHealthBar = new Bindable<bool>();
+1
View File
@@ -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();
+1
View File
@@ -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<double> SpeedChange { get; }
+1
View File
@@ -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();
+1
View File
@@ -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) };
@@ -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<Mod>().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();
@@ -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<bool> 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<IReadOnlyList<Mod>> mods)
private void onGlobalModsChanged(ValueChangedEvent<IReadOnlyList<Mod>> 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<RulesetInfo> ruleset)
{
FreeMods.Value = Array.Empty<Mod>();
// 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;
}
/// <summary>
/// Removes invalid mods from <see cref="OsuScreen.Mods"/> and <see cref="FreeMods"/>,
/// and updates mod selection overlays to display the new mods valid for selection.
/// </summary>
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
}
/// <summary>
/// Checks whether a given <see cref="Mod"/> is valid for global selection.
/// Checks whether a given <see cref="Mod"/> is valid to be selected as a required mod.
/// </summary>
/// <param name="mod">The <see cref="Mod"/> to check.</param>
/// <returns>Whether <paramref name="mod"/> is a valid mod for online play.</returns>
private bool isValidMod(Mod mod) => ModUtils.IsValidModForMatchType(mod, room.Type);
private bool isValidRequiredMod(Mod mod) => ModUtils.IsValidModForMatch(mod, true, room.Type, Freestyle.Value);
/// <summary>
/// Checks whether a given <see cref="Mod"/> is valid for per-player free-mod selection.
/// Checks whether a given <see cref="Mod"/> is valid to be selected as an allowed mod.
/// </summary>
/// <param name="mod">The <see cref="Mod"/> to check.</param>
/// <returns>Whether <paramref name="mod"/> is a selectable free-mod.</returns>
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)
{
@@ -572,31 +572,18 @@ namespace osu.Game.Screens.OnlinePlay.Playlists
updateGameplayState();
}
/// <summary>
/// Lists the <see cref="Mod"/>s that are valid to be selected for the user mod style.
/// </summary>
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<Mod>().Where(m => ModUtils.IsValidFreeModForMatchType(m, room.Type)).ToArray();
return item.AllowedMods.Select(m => m.ToMod(rulesetInstance)).ToArray();
}
/// <summary>
/// Validates the user mod style against the selected item and ruleset style.
/// </summary>
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)
+41 -23
View File
@@ -128,12 +128,13 @@ namespace osu.Game.Utils
}
/// <summary>
/// Checks that all <see cref="Mod"/>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 <see cref="MultiplayerPlaylistItem.RequiredMods">required mods</see> of a multiplayer playlist item.
/// </summary>
/// <param name="mods">The mods to check.</param>
/// <param name="freestyle">Whether freestyle is enabled for the playlist item.</param>
/// <param name="invalidMods">Invalid mods, if any were found. Will be null if all mods were valid.</param>
/// <returns>Whether the input mods were all valid. If false, <paramref name="invalidMods"/> will contain all invalid entries.</returns>
public static bool CheckValidRequiredModsForMultiplayer(IEnumerable<Mod> mods, [NotNullWhen(false)] out List<Mod>? invalidMods)
public static bool CheckValidRequiredModsForMultiplayer(IEnumerable<Mod> mods, bool freestyle, [NotNullWhen(false)] out List<Mod>? 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);
}
/// <summary>
/// Checks that all <see cref="Mod"/>s in a combination are valid as "free mods" in a multiplayer match session.
/// Checks whether the given mods are valid to appear as <see cref="MultiplayerPlaylistItem.AllowedMods">allowed mods</see> in a multiplayer playlist item.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="mods">The mods to check.</param>
/// <param name="freestyle">Whether freestyle is enabled for the playlist item.</param>
/// <param name="invalidMods">Invalid mods, if any were found. Will be null if all mods were valid.</param>
/// <returns>Whether the input mods were all valid. If false, <paramref name="invalidMods"/> will contain all invalid entries.</returns>
public static bool CheckValidFreeModsForMultiplayer(IEnumerable<Mod> mods, [NotNullWhen(false)] out List<Mod>? invalidMods)
=> checkValid(mods, m => m.Type != ModType.System && m.HasImplementation && m.ValidForMultiplayerAsFreeMod && !(m is MultiMod), out invalidMods);
public static bool CheckValidAllowedModsForMultiplayer(IEnumerable<Mod> mods, bool freestyle, [NotNullWhen(false)] out List<Mod>? invalidMods)
=> checkValid(mods, m => IsValidModForMatch(m, false, MatchType.HeadToHead, freestyle), out invalidMods);
private static bool checkValid(IEnumerable<Mod> mods, Predicate<Mod> valid, [NotNullWhen(false)] out List<Mod>? invalidMods)
{
@@ -295,43 +297,59 @@ namespace osu.Game.Utils
}
/// <summary>
/// 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.
/// </summary>
/// <param name="mod">The mod to test.</param>
/// <param name="type">The match type.</param>
public static bool IsValidModForMatchType(Mod mod, MatchType type)
/// <param name="required">
/// <c>true</c> if the mod is intended as a <see cref="MultiplayerPlaylistItem.RequiredMods">required mod</see> on the target playlist item.
/// <c>false</c> if it is intended as an <see cref="MultiplayerPlaylistItem.AllowedMods">allowed mod</see>.
/// </param>
/// <param name="matchType">The type of match being played.</param>
/// <param name="freestyle">Whether the target playlist item enables <see cref="MultiplayerPlaylistItem.Freestyle">freestyle</see> mode.</param>
/// <seealso href="https://github.com/ppy/osu-web/blob/40936b514c6485b874f6c6496d55d9e8b1b88fd4/app/Singletons/Mods.php#L95-L113">Related osu!web function.</seealso>
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;
}
}
/// <summary>
/// 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.
/// </summary>
/// <param name="mod">The mod to test.</param>
/// <param name="type">The match type.</param>
public static bool IsValidFreeModForMatchType(Mod mod, MatchType type)
/// <param name="matchType">The type of match being played.</param>
/// <param name="requiredMods">The required mods for the playlist item.</param>
/// <param name="allowedMods">The allowed mods for the playlist item.</param>
/// <param name="freestyle">Whether freestyle is enabled for the playlist item.</param>
/// <param name="userRuleset">The user's preferred ruleset, which may differ from the playlist item's selection on freestyle playlist items.</param>
public static Mod[] EnumerateUserSelectableFreeMods(MatchType matchType, IEnumerable<APIMod> requiredMods, IEnumerable<APIMod> 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<Mod>()
// 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();
}
}
}