diff --git a/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs index ea66386c9a..2a7f2dc7ea 100644 --- a/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs +++ b/osu.Game.Rulesets.Mania.Tests/TestSceneLegacyReplayPlayback.cs @@ -297,34 +297,202 @@ namespace osu.Game.Rulesets.Mania.Tests new object[] { 3.1f, -123d, HitResult.Miss }, }; + private static readonly object[][] score_v1_non_convert_hard_rock_test_cases = + { + // OD = 5 test cases. + // This leads to "effective" OD of 7. + // PERFECT hit window is [-11ms, 11ms] + // GREAT hit window is [-35ms, 35ms] + // GOOD hit window is [-58ms, 58ms] + // OK hit window is [-80ms, 80ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-97ms, ----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 5f, -10d, HitResult.Perfect }, + new object[] { 5f, -11d, HitResult.Perfect }, + new object[] { 5f, -12d, HitResult.Great }, + new object[] { 5f, -13d, HitResult.Great }, + new object[] { 5f, -34d, HitResult.Great }, + new object[] { 5f, -35d, HitResult.Great }, + new object[] { 5f, -36d, HitResult.Good }, + new object[] { 5f, -37d, HitResult.Good }, + new object[] { 5f, -57d, HitResult.Good }, + new object[] { 5f, -58d, HitResult.Good }, + new object[] { 5f, -59d, HitResult.Ok }, + new object[] { 5f, -60d, HitResult.Ok }, + new object[] { 5f, -79d, HitResult.Ok }, + new object[] { 5f, -80d, HitResult.Ok }, + new object[] { 5f, -81d, HitResult.Meh }, + new object[] { 5f, -82d, HitResult.Meh }, + new object[] { 5f, -96d, HitResult.Meh }, + new object[] { 5f, -97d, HitResult.Meh }, + new object[] { 5f, -98d, HitResult.Miss }, + new object[] { 5f, -99d, HitResult.Miss }, + new object[] { 5f, 79d, HitResult.Ok }, + new object[] { 5f, 80d, HitResult.Miss }, + new object[] { 5f, 81d, HitResult.Miss }, + new object[] { 5f, 82d, HitResult.Miss }, + new object[] { 5f, 96d, HitResult.Miss }, + new object[] { 5f, 97d, HitResult.Miss }, + new object[] { 5f, 98d, HitResult.Miss }, + new object[] { 5f, 99d, HitResult.Miss }, + + // OD = 9.3 test cases. + // This leads to "effective" OD of 13.02. + // Note that contrary to other rulesets this does NOT cap out to OD 10! + // PERFECT hit window is [-11ms, 11ms] + // GREAT hit window is [-25ms, 25ms] + // GOOD hit window is [-49ms, 49ms] + // OK hit window is [-70ms, 70ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-87ms, ----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 9.3f, 10d, HitResult.Perfect }, + new object[] { 9.3f, 11d, HitResult.Perfect }, + new object[] { 9.3f, 12d, HitResult.Great }, + new object[] { 9.3f, 13d, HitResult.Great }, + new object[] { 9.3f, 24d, HitResult.Great }, + new object[] { 9.3f, 25d, HitResult.Great }, + new object[] { 9.3f, 26d, HitResult.Good }, + new object[] { 9.3f, 27d, HitResult.Good }, + new object[] { 9.3f, 48d, HitResult.Good }, + new object[] { 9.3f, 49d, HitResult.Good }, + new object[] { 9.3f, 50d, HitResult.Ok }, + new object[] { 9.3f, 51d, HitResult.Ok }, + new object[] { 9.3f, 69d, HitResult.Ok }, + new object[] { 9.3f, 70d, HitResult.Miss }, + new object[] { 9.3f, 71d, HitResult.Miss }, + new object[] { 9.3f, 72d, HitResult.Miss }, + new object[] { 9.3f, 86d, HitResult.Miss }, + new object[] { 9.3f, 87d, HitResult.Miss }, + new object[] { 9.3f, 88d, HitResult.Miss }, + new object[] { 9.3f, 89d, HitResult.Miss }, + new object[] { 9.3f, -69d, HitResult.Ok }, + new object[] { 9.3f, -70d, HitResult.Ok }, + new object[] { 9.3f, -71d, HitResult.Meh }, + new object[] { 9.3f, -72d, HitResult.Meh }, + new object[] { 9.3f, -86d, HitResult.Meh }, + new object[] { 9.3f, -87d, HitResult.Meh }, + new object[] { 9.3f, -88d, HitResult.Miss }, + new object[] { 9.3f, -89d, HitResult.Miss }, + }; + + private static readonly object[][] score_v1_non_convert_easy_test_cases = + { + // Assume OD = 5 (other values are not tested, even OD 5 is enough to exercise the flooring logic). + // PERFECT hit window is [ -22ms, 22ms] + // GREAT hit window is [ -68ms, 68ms] + // GOOD hit window is [-114ms, 114ms] + // OK hit window is [-156ms, 156ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-190ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 5f, -21d, HitResult.Perfect }, + new object[] { 5f, -22d, HitResult.Perfect }, + new object[] { 5f, -23d, HitResult.Great }, + new object[] { 5f, -24d, HitResult.Great }, + new object[] { 5f, -67d, HitResult.Great }, + new object[] { 5f, -68d, HitResult.Great }, + new object[] { 5f, -69d, HitResult.Good }, + new object[] { 5f, -70d, HitResult.Good }, + new object[] { 5f, -113d, HitResult.Good }, + new object[] { 5f, -114d, HitResult.Good }, + new object[] { 5f, -115d, HitResult.Ok }, + new object[] { 5f, -116d, HitResult.Ok }, + new object[] { 5f, -155d, HitResult.Ok }, + new object[] { 5f, -156d, HitResult.Ok }, + new object[] { 5f, -157d, HitResult.Meh }, + new object[] { 5f, -158d, HitResult.Meh }, + new object[] { 5f, -189d, HitResult.Meh }, + new object[] { 5f, -190d, HitResult.Meh }, + new object[] { 5f, -191d, HitResult.Miss }, + new object[] { 5f, -192d, HitResult.Miss }, + new object[] { 5f, 155d, HitResult.Ok }, + new object[] { 5f, 156d, HitResult.Miss }, + new object[] { 5f, 157d, HitResult.Miss }, + new object[] { 5f, 158d, HitResult.Miss }, + new object[] { 5f, 189d, HitResult.Miss }, + new object[] { 5f, 190d, HitResult.Miss }, + new object[] { 5f, 191d, HitResult.Miss }, + new object[] { 5f, 192d, HitResult.Miss }, + }; + + private static readonly object[][] score_v1_non_convert_double_time_test_cases = + { + // Assume OD = 5 (other values are not tested, even OD 5 is enough to exercise the flooring logic). + // PERFECT hit window is [ -24ms, 24ms] + // GREAT hit window is [ -73ms, 73ms] + // GOOD hit window is [-123ms, 123ms] + // OK hit window is [-168ms, 168ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-204ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 5f, -23d, HitResult.Perfect }, + new object[] { 5f, -24d, HitResult.Perfect }, + new object[] { 5f, -25d, HitResult.Great }, + new object[] { 5f, -26d, HitResult.Great }, + new object[] { 5f, -72d, HitResult.Great }, + new object[] { 5f, -73d, HitResult.Great }, + new object[] { 5f, -74d, HitResult.Good }, + new object[] { 5f, -75d, HitResult.Good }, + new object[] { 5f, -122d, HitResult.Good }, + new object[] { 5f, -123d, HitResult.Good }, + new object[] { 5f, -124d, HitResult.Ok }, + new object[] { 5f, -125d, HitResult.Ok }, + new object[] { 5f, -167d, HitResult.Ok }, + new object[] { 5f, -168d, HitResult.Ok }, + new object[] { 5f, -169d, HitResult.Meh }, + new object[] { 5f, -170d, HitResult.Meh }, + new object[] { 5f, -203d, HitResult.Meh }, + new object[] { 5f, -204d, HitResult.Meh }, + new object[] { 5f, -205d, HitResult.Miss }, + new object[] { 5f, -206d, HitResult.Miss }, + new object[] { 5f, 167d, HitResult.Ok }, + new object[] { 5f, 168d, HitResult.Miss }, + new object[] { 5f, 169d, HitResult.Miss }, + new object[] { 5f, 170d, HitResult.Miss }, + new object[] { 5f, 203d, HitResult.Miss }, + new object[] { 5f, 204d, HitResult.Miss }, + new object[] { 5f, 205d, HitResult.Miss }, + new object[] { 5f, 206d, HitResult.Miss }, + }; + + private static readonly object[][] score_v1_non_convert_half_time_test_cases = + { + // Assume OD = 5 (other values are not tested, even OD 5 is enough to exercise the flooring logic). + // PERFECT hit window is [ -12ms, 12ms] + // GREAT hit window is [ -36ms, 36ms] + // GOOD hit window is [ -61ms, 61ms] + // OK hit window is [ -84ms, 84ms) <- not a typo, this side of the interval is OPEN! + // MEH hit window is [-102ms, -----) <- it is NOT POSSIBLE to get a MEH result on a late hit! + new object[] { 5f, -11d, HitResult.Perfect }, + new object[] { 5f, -12d, HitResult.Perfect }, + new object[] { 5f, -13d, HitResult.Great }, + new object[] { 5f, -14d, HitResult.Great }, + new object[] { 5f, -35d, HitResult.Great }, + new object[] { 5f, -36d, HitResult.Great }, + new object[] { 5f, -37d, HitResult.Good }, + new object[] { 5f, -38d, HitResult.Good }, + new object[] { 5f, -60d, HitResult.Good }, + new object[] { 5f, -61d, HitResult.Good }, + new object[] { 5f, -62d, HitResult.Ok }, + new object[] { 5f, -63d, HitResult.Ok }, + new object[] { 5f, -83d, HitResult.Ok }, + new object[] { 5f, -84d, HitResult.Ok }, + new object[] { 5f, -85d, HitResult.Meh }, + new object[] { 5f, -86d, HitResult.Meh }, + new object[] { 5f, -101d, HitResult.Meh }, + new object[] { 5f, -102d, HitResult.Meh }, + new object[] { 5f, -103d, HitResult.Miss }, + new object[] { 5f, -104d, HitResult.Miss }, + new object[] { 5f, 83d, HitResult.Ok }, + new object[] { 5f, 84d, HitResult.Miss }, + new object[] { 5f, 85d, HitResult.Miss }, + new object[] { 5f, 86d, HitResult.Miss }, + new object[] { 5f, 101d, HitResult.Miss }, + new object[] { 5f, 102d, HitResult.Miss }, + new object[] { 5f, 103d, HitResult.Miss }, + new object[] { 5f, 104d, HitResult.Miss }, + }; + + private const double note_time = 300; + [TestCaseSource(nameof(score_v2_test_cases))] public void TestHitWindowTreatmentWithScoreV2(float overallDifficulty, double hitOffset, HitResult expectedResult) { - const double note_time = 300; - - var cpi = new ControlPointInfo(); - cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); - var beatmap = new ManiaBeatmap(new StageDefinition(1)) - { - HitObjects = - { - new Note - { - StartTime = note_time, - Column = 0, - } - }, - Difficulty = new BeatmapDifficulty - { - OverallDifficulty = overallDifficulty, - CircleSize = 1, - }, - BeatmapInfo = - { - Ruleset = new ManiaRuleset().RulesetInfo, - }, - ControlPointInfo = cpi, - }; + var beatmap = createNonConvertBeatmap(overallDifficulty); var replay = new Replay { @@ -352,31 +520,7 @@ namespace osu.Game.Rulesets.Mania.Tests [TestCaseSource(nameof(score_v1_non_convert_test_cases))] public void TestHitWindowTreatmentWithScoreV1NonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) { - const double note_time = 300; - - var cpi = new ControlPointInfo(); - cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); - var beatmap = new ManiaBeatmap(new StageDefinition(1)) - { - HitObjects = - { - new Note - { - StartTime = note_time, - Column = 0, - } - }, - Difficulty = new BeatmapDifficulty - { - OverallDifficulty = overallDifficulty, - CircleSize = 1, - }, - BeatmapInfo = - { - Ruleset = new ManiaRuleset().RulesetInfo, - }, - ControlPointInfo = cpi, - }; + var beatmap = createNonConvertBeatmap(overallDifficulty); var replay = new Replay { @@ -403,29 +547,7 @@ namespace osu.Game.Rulesets.Mania.Tests [TestCaseSource(nameof(score_v1_convert_test_cases))] public void TestHitWindowTreatmentWithScoreV1Convert(float overallDifficulty, double hitOffset, HitResult expectedResult) { - const double note_time = 300; - - var cpi = new ControlPointInfo(); - cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); - var beatmap = new Beatmap - { - HitObjects = - { - new FakeCircle - { - StartTime = note_time, - } - }, - Difficulty = new BeatmapDifficulty - { - OverallDifficulty = overallDifficulty, - }, - BeatmapInfo = - { - Ruleset = new RulesetInfo { OnlineID = 0 } - }, - ControlPointInfo = cpi, - }; + var beatmap = createConvertBeatmap(overallDifficulty); var replay = new Replay { @@ -450,6 +572,172 @@ namespace osu.Game.Rulesets.Mania.Tests RunTest($@"SV1 convert single note @ OD{overallDifficulty}", beatmap, $@"SV1 convert {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); } + [TestCaseSource(nameof(score_v1_non_convert_hard_rock_test_cases))] + public void TestHitWindowTreatmentWithScoreV1AndHardRockNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createNonConvertBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + new ManiaReplayFrame(0), + new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1), + new ManiaReplayFrame(note_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new ManiaModHardRock()], + } + }; + + RunTest($@"SV1+HR single note @ OD{overallDifficulty}", beatmap, $@"SV1+HR {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(score_v1_non_convert_easy_test_cases))] + public void TestHitWindowTreatmentWithScoreV1AndEasyNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createNonConvertBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + new ManiaReplayFrame(0), + new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1), + new ManiaReplayFrame(note_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new ManiaModEasy()], + } + }; + + RunTest($@"SV1+EZ single note @ OD{overallDifficulty}", beatmap, $@"SV1+EZ {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(score_v1_non_convert_double_time_test_cases))] + public void TestHitWindowTreatmentWithScoreV1AndDoubleTimeNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createNonConvertBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + new ManiaReplayFrame(0), + new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1), + new ManiaReplayFrame(note_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new ManiaModDoubleTime()], + } + }; + + RunTest($@"SV1+DT single note @ OD{overallDifficulty}", beatmap, $@"SV1+DT {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(score_v1_non_convert_half_time_test_cases))] + public void TestHitWindowTreatmentWithScoreV1AndHalfTimeNonConvert(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createNonConvertBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + new ManiaReplayFrame(0), + new ManiaReplayFrame(note_time + hitOffset, ManiaAction.Key1), + new ManiaReplayFrame(note_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new ManiaModHalfTime()], + } + }; + + RunTest($@"SV1+HT single note @ OD{overallDifficulty}", beatmap, $@"SV1+HT {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + private static ManiaBeatmap createNonConvertBeatmap(float overallDifficulty) + { + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); + var beatmap = new ManiaBeatmap(new StageDefinition(1)) + { + HitObjects = + { + new Note + { + StartTime = note_time, + Column = 0, + } + }, + Difficulty = new BeatmapDifficulty + { + OverallDifficulty = overallDifficulty, + CircleSize = 1, + }, + BeatmapInfo = + { + Ruleset = new ManiaRuleset().RulesetInfo, + }, + ControlPointInfo = cpi, + }; + return beatmap; + } + + private static Beatmap createConvertBeatmap(float overallDifficulty) + { + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); + var beatmap = new Beatmap + { + HitObjects = + { + new FakeCircle + { + StartTime = note_time, + } + }, + Difficulty = new BeatmapDifficulty + { + OverallDifficulty = overallDifficulty, + }, + BeatmapInfo = + { + Ruleset = new RulesetInfo { OnlineID = 0 } + }, + ControlPointInfo = cpi, + }; + return beatmap; + } + private class FakeCircle : HitObject, IHasPosition { public float X 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.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs index c22255bbdf..379699b276 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneLegacyReplayPlayback.cs @@ -6,6 +6,7 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Replays; using osu.Game.Rulesets.Osu.Beatmaps; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Osu.UI; @@ -23,7 +24,7 @@ namespace osu.Game.Rulesets.Osu.Tests protected override string? ExportLocation => null; - private static readonly object[][] test_cases = + private static readonly object[][] no_mod_test_cases = { // With respect to notation, // square brackets `[]` represent *closed* or *inclusive* bounds, @@ -65,30 +66,73 @@ namespace osu.Game.Rulesets.Osu.Tests new object[] { 5.7f, 144d, HitResult.Miss }, }; - [TestCaseSource(nameof(test_cases))] + private static readonly object[][] hard_rock_test_cases = + { + // OD = 5 test cases. + // This leads to "effective" OD of 7. + // GREAT hit window is ( -38ms, 38ms) + // OK hit window is ( -84ms, 84ms) + // MEH hit window is (-130ms, 130ms) + new object[] { 5f, 36d, HitResult.Great }, + new object[] { 5f, 37d, HitResult.Great }, + new object[] { 5f, 38d, HitResult.Ok }, + new object[] { 5f, 39d, HitResult.Ok }, + new object[] { 5f, 82d, HitResult.Ok }, + new object[] { 5f, 83d, HitResult.Ok }, + new object[] { 5f, 84d, HitResult.Meh }, + new object[] { 5f, 85d, HitResult.Meh }, + new object[] { 5f, 128d, HitResult.Meh }, + new object[] { 5f, 129d, HitResult.Meh }, + new object[] { 5f, 130d, HitResult.Miss }, + new object[] { 5f, 131d, HitResult.Miss }, + + // OD = 8 test cases. + // This would lead to "effective" OD of 11.2, + // but the effects are capped to OD 10. + // GREAT hit window is ( -20ms, 20ms) + // OK hit window is ( -60ms, 60ms) + // MEH hit window is (-100ms, 100ms) + new object[] { 8f, 18d, HitResult.Great }, + new object[] { 8f, 19d, HitResult.Great }, + new object[] { 8f, 20d, HitResult.Ok }, + new object[] { 8f, 21d, HitResult.Ok }, + new object[] { 8f, 58d, HitResult.Ok }, + new object[] { 8f, 59d, HitResult.Ok }, + new object[] { 8f, 60d, HitResult.Meh }, + new object[] { 8f, 61d, HitResult.Meh }, + new object[] { 8f, 98d, HitResult.Meh }, + new object[] { 8f, 99d, HitResult.Meh }, + new object[] { 8f, 100d, HitResult.Miss }, + new object[] { 8f, 101d, HitResult.Miss }, + }; + + private static readonly object[][] easy_test_cases = + { + // OD = 5 test cases. + // This leads to "effective" OD of 2.5. + // GREAT hit window is ( -65ms, 65ms) + // OK hit window is (-120ms, 120ms) + // MEH hit window is (-175ms, 175ms) + new object[] { 5f, 63d, HitResult.Great }, + new object[] { 5f, 64d, HitResult.Great }, + new object[] { 5f, 65d, HitResult.Ok }, + new object[] { 5f, 66d, HitResult.Ok }, + new object[] { 5f, 118d, HitResult.Ok }, + new object[] { 5f, 119d, HitResult.Ok }, + new object[] { 5f, 120d, HitResult.Meh }, + new object[] { 5f, 121d, HitResult.Meh }, + new object[] { 5f, 173d, HitResult.Meh }, + new object[] { 5f, 174d, HitResult.Meh }, + new object[] { 5f, 175d, HitResult.Miss }, + new object[] { 5f, 176d, HitResult.Miss }, + }; + + private const double hit_circle_time = 100; + + [TestCaseSource(nameof(no_mod_test_cases))] public void TestHitWindowTreatment(float overallDifficulty, double hitOffset, HitResult expectedResult) { - const double hit_circle_time = 100; - - var cpi = new ControlPointInfo(); - cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); - var beatmap = new OsuBeatmap - { - HitObjects = - { - new HitCircle - { - StartTime = hit_circle_time, - Position = OsuPlayfield.BASE_SIZE / 2 - } - }, - Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty }, - BeatmapInfo = - { - Ruleset = new OsuRuleset().RulesetInfo, - }, - ControlPointInfo = cpi, - }; + var beatmap = createBeatmap(overallDifficulty); var replay = new Replay { @@ -114,5 +158,91 @@ namespace osu.Game.Rulesets.Osu.Tests RunTest($@"single circle @ OD{overallDifficulty}", beatmap, $@"{hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); } + + [TestCaseSource(nameof(hard_rock_test_cases))] + public void TestHitWindowTreatmentWithHardRock(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + // required for correct playback in stable + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, OsuPlayfield.BASE_SIZE / 2), + new OsuReplayFrame(hit_circle_time + hitOffset, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton), + new OsuReplayFrame(hit_circle_time + hitOffset + 20, OsuPlayfield.BASE_SIZE / 2), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new OsuModHardRock()] + } + }; + + RunTest($@"HR single circle @ OD{overallDifficulty}", beatmap, $@"HR {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(easy_test_cases))] + public void TestHitWindowTreatmentWithEasy(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + // required for correct playback in stable + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, new Vector2(256, -500)), + new OsuReplayFrame(0, OsuPlayfield.BASE_SIZE / 2), + new OsuReplayFrame(hit_circle_time + hitOffset, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton), + new OsuReplayFrame(hit_circle_time + hitOffset + 20, OsuPlayfield.BASE_SIZE / 2), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new OsuModEasy()] + } + }; + + RunTest($@"EZ single circle @ OD{overallDifficulty}", beatmap, $@"EZ {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + private static OsuBeatmap createBeatmap(float overallDifficulty) + { + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); + var beatmap = new OsuBeatmap + { + HitObjects = + { + new HitCircle + { + StartTime = hit_circle_time, + Position = OsuPlayfield.BASE_SIZE / 2 + } + }, + Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty }, + BeatmapInfo = + { + Ruleset = new OsuRuleset().RulesetInfo, + }, + ControlPointInfo = cpi, + }; + return beatmap; + } } } diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs index 459312f2b4..5e71f974d8 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneLegacyReplayPlayback.cs @@ -7,6 +7,7 @@ using osu.Game.Beatmaps.ControlPoints; using osu.Game.Replays; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Beatmaps; +using osu.Game.Rulesets.Taiko.Mods; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Replays; using osu.Game.Scoring; @@ -21,7 +22,7 @@ namespace osu.Game.Rulesets.Taiko.Tests protected override Ruleset CreateRuleset() => new TaikoRuleset(); - private static readonly object[][] test_cases = + private static readonly object[][] no_mod_test_cases = { // With respect to notation, // square brackets `[]` represent *closed* or *inclusive* bounds, @@ -52,30 +53,58 @@ namespace osu.Game.Rulesets.Taiko.Tests new object[] { 7.8f, -64d, HitResult.Miss }, }; - [TestCaseSource(nameof(test_cases))] + private static readonly object[][] hard_rock_test_cases = + { + // OD = 5 test cases. + // This leads to "effective" OD of 7. + // GREAT hit window is (-29ms, 29ms) + // OK hit window is (-68ms, 68ms) + new object[] { 5f, -27d, HitResult.Great }, + new object[] { 5f, -28d, HitResult.Great }, + new object[] { 5f, -29d, HitResult.Ok }, + new object[] { 5f, -30d, HitResult.Ok }, + new object[] { 5f, -66d, HitResult.Ok }, + new object[] { 5f, -67d, HitResult.Ok }, + new object[] { 5f, -68d, HitResult.Miss }, + new object[] { 5f, -69d, HitResult.Miss }, + + // OD = 7.8 test cases. + // This would lead to "effective" OD of 10.92, + // but the effects are capped to OD 10. + // GREAT hit window is (-20ms, 20ms) + // OK hit window is (-50ms, 50ms) + new object[] { 7.8f, -18d, HitResult.Great }, + new object[] { 7.8f, -19d, HitResult.Great }, + new object[] { 7.8f, -20d, HitResult.Ok }, + new object[] { 7.8f, -21d, HitResult.Ok }, + new object[] { 7.8f, -48d, HitResult.Ok }, + new object[] { 7.8f, -49d, HitResult.Ok }, + new object[] { 7.8f, -50d, HitResult.Miss }, + new object[] { 7.8f, -51d, HitResult.Miss }, + }; + + private static readonly object[][] easy_test_cases = + { + // OD = 5 test cases. + // This leads to "effective" OD of 2.5. + // GREAT hit window is ( -42ms, 42ms) + // OK hit window is (-100ms, 100ms) + new object[] { 5f, -40d, HitResult.Great }, + new object[] { 5f, -41d, HitResult.Great }, + new object[] { 5f, -42d, HitResult.Ok }, + new object[] { 5f, -43d, HitResult.Ok }, + new object[] { 5f, -98d, HitResult.Ok }, + new object[] { 5f, -99d, HitResult.Ok }, + new object[] { 5f, -100d, HitResult.Miss }, + new object[] { 5f, -101d, HitResult.Miss }, + }; + + private const double hit_time = 100; + + [TestCaseSource(nameof(no_mod_test_cases))] public void TestHitWindowTreatment(float overallDifficulty, double hitOffset, HitResult expectedResult) { - const double hit_time = 100; - - var cpi = new ControlPointInfo(); - cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); - var beatmap = new TaikoBeatmap - { - HitObjects = - { - new Hit - { - StartTime = hit_time, - Type = HitType.Centre, - } - }, - Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty }, - BeatmapInfo = - { - Ruleset = new TaikoRuleset().RulesetInfo, - }, - ControlPointInfo = cpi, - }; + var beatmap = createBeatmap(overallDifficulty); var replay = new Replay { @@ -98,5 +127,85 @@ namespace osu.Game.Rulesets.Taiko.Tests RunTest($@"single hit @ OD{overallDifficulty}", beatmap, $@"{hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); } + + [TestCaseSource(nameof(hard_rock_test_cases))] + public void TestHitWindowTreatmentWithHardRock(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time + hitOffset, TaikoAction.LeftCentre), + new TaikoReplayFrame(hit_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new TaikoModHardRock()] + } + }; + + RunTest($@"HR single hit @ OD{overallDifficulty}", beatmap, $@"HR {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + [TestCaseSource(nameof(easy_test_cases))] + public void TestHitWindowTreatmentWithEasy(float overallDifficulty, double hitOffset, HitResult expectedResult) + { + var beatmap = createBeatmap(overallDifficulty); + + var replay = new Replay + { + Frames = + { + new TaikoReplayFrame(0), + new TaikoReplayFrame(hit_time + hitOffset, TaikoAction.LeftCentre), + new TaikoReplayFrame(hit_time + hitOffset + 20), + } + }; + + var score = new Score + { + Replay = replay, + ScoreInfo = new ScoreInfo + { + Ruleset = CreateRuleset().RulesetInfo, + Mods = [new TaikoModHardRock()] + } + }; + + RunTest($@"EZ single hit @ OD{overallDifficulty}", beatmap, $@"EZ {hitOffset}ms @ OD{overallDifficulty} = {expectedResult}", score, [expectedResult]); + } + + private static TaikoBeatmap createBeatmap(float overallDifficulty) + { + var cpi = new ControlPointInfo(); + cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); + var beatmap = new TaikoBeatmap + { + HitObjects = + { + new Hit + { + StartTime = hit_time, + Type = HitType.Centre, + } + }, + Difficulty = new BeatmapDifficulty { OverallDifficulty = overallDifficulty }, + BeatmapInfo = + { + Ruleset = new TaikoRuleset().RulesetInfo, + }, + ControlPointInfo = cpi, + }; + return beatmap; + } } } 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/Editing/TestSceneSubmissionStageProgress.cs b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs index 47414bb24e..ee22cbda71 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneSubmissionStageProgress.cs @@ -1,10 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Overlays; using osu.Game.Screens.Edit.Submission; using osuTK; @@ -16,9 +22,16 @@ namespace osu.Game.Tests.Visual.Editing [Cached] private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Aquamarine); + [Resolved] + private AudioManager audio { get; set; } = null!; + + private Sample? completeSample; + [Test] public void TestAppearance() { + float incrementingProgress = 0; + SubmissionStageProgress progress = null!; AddStep("create content", () => Child = new Container @@ -36,12 +49,119 @@ namespace osu.Game.Tests.Visual.Editing }); AddStep("not started", () => progress.SetNotStarted()); AddStep("indeterminate progress", () => progress.SetInProgress()); - AddStep("30% progress", () => progress.SetInProgress(0.3f)); - AddStep("70% progress", () => progress.SetInProgress(0.7f)); + AddStep("increase progress to 100", () => + { + incrementingProgress = 0; + + ScheduledDelegate? task = null; + + task = Scheduler.AddDelayed(() => + { + if (incrementingProgress >= 1) + { + // ReSharper disable once AccessToModifiedClosure + task?.Cancel(); + return; + } + + if (RNG.NextDouble() < 0.01) + progress.SetInProgress(incrementingProgress += RNG.NextSingle(0.08f)); + }, 0, true); + }); + + AddUntilStep("wait for completed", () => incrementingProgress >= 1); AddStep("completed", () => progress.SetCompleted()); AddStep("failed", () => progress.SetFailed("the foobarator has defrobnicated")); AddStep("failed with long message", () => progress.SetFailed("this is a very very very very VERY VEEEEEEEEEEEEEEEEEEEEEEEEERY long error message like you would never believe")); AddStep("canceled", () => progress.SetCanceled()); } + + [Test] + public void TestAudioSequence() + { + SubmissionStageProgress[] stages = new SubmissionStageProgress[4]; + Container? cardContainer = null; + + AddStep("prepare", () => + { + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(1), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.8f), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 5), + Children = new Drawable[] + { + stages[0] = new SubmissionStageProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + StageDescription = "Export...", + StageIndex = 0 + }, + stages[1] = new SubmissionStageProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + StageDescription = "CreateSet...", + StageIndex = 1 + }, + stages[2] = new SubmissionStageProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + StageDescription = "Upload...", + StageIndex = 2 + }, + stages[3] = new SubmissionStageProgress + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + StageDescription = "Update...", + StageIndex = 3 + }, + cardContainer = new Container + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } + } + } + }; + + completeSample = audio.Samples.Get(@"UI/bss-complete"); + }); + + for (int i = 0; i < stages.Length; i++) + { + int step = i; + AddStep($"{step}: not started", () => stages[step].SetNotStarted()); + AddStep($"{step}: indeterminate progress", () => stages[step].SetInProgress()); + AddStep($"{step}: 25% progress", () => stages[step].SetInProgress(0.25f)); + AddStep($"{step}: 70% progress", () => stages[step].SetInProgress(0.7f)); + AddStep($"{step}: completed", () => stages[step].SetCompleted()); + } + + AddWaitStep("pause for timing", 2); + + AddStep("Sequence Complete", () => + { + var beatmapSet = CreateAPIBeatmapSet(Ruleset.Value); + beatmapSet.Beatmaps = Enumerable.Repeat(beatmapSet.Beatmaps.First(), 100).ToArray(); + LoadComponentAsync(new BeatmapCardExtra(beatmapSet, false), loaded => + { + cardContainer?.Add(loaded); + completeSample?.Play(); + }); + }); + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs new file mode 100644 index 0000000000..1f883aa784 --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneHUDOverlayRulesetLayouts.cs @@ -0,0 +1,156 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Track; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Rendering; +using osu.Framework.Graphics.Textures; +using osu.Framework.IO.Stores; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.IO; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Spectator; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.UI.Scrolling; +using osu.Game.Scoring; +using osu.Game.Screens.Play; +using osu.Game.Screens.Select.Leaderboards; +using osu.Game.Skinning; +using osu.Game.Tests.Gameplay; +using osu.Game.Tests.Visual.Spectator; + +namespace osu.Game.Tests.Visual.Gameplay +{ + [Description(@"Exercises the appearance of the HUD overlay on various skin and ruleset combinations.")] + public partial class TestSceneHUDOverlayRulesetLayouts : OsuTestScene, IStorageResourceProvider + { + private readonly Dictionary skins = new Dictionary(); + + [Resolved] + private GameHost host { get; set; } = null!; + + [Resolved] + private RulesetStore rulesets { get; set; } = null!; + + [BackgroundDependencyLoader] + private void load() + { + skins["argon"] = new ArgonSkin(this); + skins["triangles"] = new TrianglesSkin(this); + skins["legacy"] = new DefaultLegacySkin(this); + } + + [Test] + public void TestLayout( + [Values("argon", "triangles", "legacy")] + string skinName, + [Values("osu", "taiko", "fruits", "mania")] + string rulesetName) + { + AddStep("create content", () => + { + var rulesetInfo = rulesets.GetRuleset(rulesetName); + var ruleset = rulesetInfo!.CreateInstance(); + var beatmap = ruleset.CreateBeatmapConverter(new Beatmap()).Convert(); + var drawableRuleset = ruleset.CreateDrawableRulesetWith(beatmap); + + ISkin provider = ruleset.CreateSkinTransformer(skins[skinName], beatmap)!; + + var gameplayState = TestGameplayState.Create(ruleset); + ((Bindable)gameplayState.PlayingState).Value = LocalUserPlayingState.Playing; + var spectatorClient = new TestSpectatorClient(); + + for (int i = 0; i < 15; ++i) + { + ((ISpectatorClient)spectatorClient).UserStartedWatching([ + new SpectatorUser + { + OnlineID = i, + Username = $"User {i}" + } + ]); + } + + GameplayClockContainer gameplayClock; + + List<(Type, object)> dependencies = + [ + (typeof(GameplayState), gameplayState), + (typeof(ScoreProcessor), gameplayState.ScoreProcessor), + (typeof(HealthProcessor), gameplayState.HealthProcessor), + (typeof(IGameplayClock), gameplayClock = new GameplayClockContainer(new TrackVirtual(60000), false, false)), + (typeof(SpectatorClient), spectatorClient), + (typeof(IGameplayLeaderboardProvider), new TestGameplayLeaderboardProvider()), + ]; + + if (drawableRuleset is IDrawableScrollingRuleset scrolling) + dependencies.Add((typeof(IScrollingInfo), scrolling.ScrollingInfo)); + + Child = new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = dependencies.ToArray(), + Children = new Drawable[] + { + spectatorClient, + new SkinProvidingContainer(provider) + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + drawableRuleset, + new HUDOverlay(drawableRuleset, []) + { + RelativeSizeAxes = Axes.Both, + } + } + } + } + }; + + gameplayClock.Start(); + }); + } + + private class TestGameplayLeaderboardProvider : IGameplayLeaderboardProvider + { + IBindableList IGameplayLeaderboardProvider.Scores => Scores; + public BindableList Scores { get; } = new BindableList(); + public bool IsPartial { get; } = false; + + public TestGameplayLeaderboardProvider() + { + for (int i = 0; i < 20; ++i) + { + Scores.Add(new GameplayLeaderboardScore(new ScoreInfo + { + User = new APIUser { Username = $"User {i}" }, + TotalScore = (20 - i) * 50_000, + Accuracy = i * 0.05, + Combo = i * 50 + }, i == 19)); + } + } + } + + #region IResourceStorageProvider + + public IRenderer Renderer => host.Renderer; + public AudioManager AudioManager => Audio; + public IResourceStore Files => null!; + public new IResourceStore Resources => base.Resources; + public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => host.CreateTextureLoaderStore(underlyingStore); + RealmAccess IStorageResourceProvider.RealmAccess => null!; + + #endregion + } +} 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.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs new file mode 100644 index 0000000000..31aa1b6f94 --- /dev/null +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapCarouselUpdateHandling.cs @@ -0,0 +1,185 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Extensions; +using osu.Game.Screens.SelectV2; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Visual.SongSelectV2 +{ + [TestFixture] + public partial class TestSceneBeatmapCarouselUpdateHandling : BeatmapCarouselTestScene + { + private BeatmapSetInfo baseTestBeatmap = null!; + + [SetUpSteps] + public void SetUpSteps() + { + RemoveAllBeatmaps(); + CreateCarousel(); + AddBeatmaps(1, 3); + AddStep("generate and add test beatmap", () => + { + baseTestBeatmap = TestResources.CreateTestBeatmapSetInfo(3); + + var metadata = new BeatmapMetadata + { + Artist = "update test", + Title = "beatmap", + }; + + foreach (var b in baseTestBeatmap.Beatmaps) + b.Metadata = metadata; + BeatmapSets.Add(baseTestBeatmap); + }); + + WaitForSorting(); + } + + [Test] + public void TestBeatmapSetUpdatedNoop() + { + List originalDrawables = new List(); + + AddStep("store drawable references", () => + { + originalDrawables.Clear(); + originalDrawables.AddRange(Carousel.ChildrenOfType().ToList()); + }); + + AddStep("update beatmap with same reference", () => BeatmapSets.ReplaceRange(1, 1, [baseTestBeatmap])); + + WaitForSorting(); + AddAssert("drawables unchanged", () => Carousel.ChildrenOfType(), () => Is.EqualTo(originalDrawables)); + } + + [Test] + public void TestBeatmapSetMetadataUpdated() + { + var metadata = new BeatmapMetadata + { + Artist = "updated test", + Title = "new beatmap title", + }; + + List originalDrawables = new List(); + + AddStep("store drawable references", () => + { + originalDrawables.Clear(); + originalDrawables.AddRange(Carousel.ChildrenOfType().ToList()); + }); + + updateBeatmap(b => b.Metadata = metadata); + + WaitForSorting(); + AddAssert("drawables changed", () => Carousel.ChildrenOfType(), () => Is.Not.EqualTo(originalDrawables)); + } + + [Test] + public void TestSelectionHeld() + { + SelectPrevGroup(); + + WaitForSelection(1, 0); + AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + + updateBeatmap(); + WaitForSorting(); + + AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + } + + [Test] // Checks that we keep selection based on online ID where possible. + public void TestSelectionHeldDifficultyNameChanged() + { + SelectPrevGroup(); + + WaitForSelection(1, 0); + AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + + updateBeatmap(b => b.DifficultyName = "new name"); + WaitForSorting(); + + AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + } + + [Test] // Checks that we fallback to keeping selection based on difficulty name. + public void TestSelectionHeldDifficultyOnlineIDChanged() + { + SelectPrevGroup(); + + WaitForSelection(1, 0); + AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + + updateBeatmap(b => b.OnlineID = b.OnlineID + 1); + WaitForSorting(); + + AddAssert("selection is updateable beatmap", () => Carousel.CurrentSelection, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + AddAssert("visible panel is updateable beatmap", () => GetSelectedPanel()?.Item?.Model, () => Is.EqualTo(baseTestBeatmap.Beatmaps[0])); + } + + private void updateBeatmap(Action? updateBeatmap = null, Action? updateSet = null) + { + AddStep("update beatmap with different reference", () => + { + var updatedSet = new BeatmapSetInfo + { + ID = baseTestBeatmap.ID, + OnlineID = baseTestBeatmap.OnlineID, + DateAdded = baseTestBeatmap.DateAdded, + DateSubmitted = baseTestBeatmap.DateSubmitted, + DateRanked = baseTestBeatmap.DateRanked, + Status = baseTestBeatmap.Status, + StatusInt = baseTestBeatmap.StatusInt, + DeletePending = baseTestBeatmap.DeletePending, + Hash = baseTestBeatmap.Hash, + Protected = baseTestBeatmap.Protected, + }; + + updateSet?.Invoke(updatedSet); + + var updatedBeatmaps = baseTestBeatmap.Beatmaps.Select(b => + { + var updatedBeatmap = new BeatmapInfo + { + ID = b.ID, + Metadata = b.Metadata, + Ruleset = b.Ruleset, + DifficultyName = b.DifficultyName, + BeatmapSet = updatedSet, + Status = b.Status, + OnlineID = b.OnlineID, + Length = b.Length, + BPM = b.BPM, + Hash = b.Hash, + StarRating = b.StarRating, + MD5Hash = b.MD5Hash, + OnlineMD5Hash = b.OnlineMD5Hash, + }; + + updateBeatmap?.Invoke(updatedBeatmap); + + return updatedBeatmap; + }).ToList(); + + updatedSet.Beatmaps.AddRange(updatedBeatmaps); + + int originalIndex = BeatmapSets.IndexOf(baseTestBeatmap); + + BeatmapSets.ReplaceRange(originalIndex, 1, [updatedSet]); + }); + } + } +} diff --git a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs index 6a14ddc147..8b89de5fce 100644 --- a/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs +++ b/osu.Game.Tests/Visual/SongSelectV2/TestSceneBeatmapTitleWedge.cs @@ -79,8 +79,6 @@ namespace osu.Game.Tests.Visual.SongSelectV2 selectBeatmap(null); AddAssert("check default title", () => titleWedge.DisplayedTitle == Beatmap.Default.BeatmapInfo.Metadata.Title); AddAssert("check default artist", () => titleWedge.DisplayedArtist == Beatmap.Default.BeatmapInfo.Metadata.Artist); - AddAssert("check empty version", () => string.IsNullOrEmpty(difficultyDisplay.DisplayedVersion.ToString())); - AddAssert("check empty author", () => string.IsNullOrEmpty(difficultyDisplay.DisplayedAuthor.ToString())); AddAssert("check no statistics", () => difficultyDisplay.ChildrenOfType().All(d => !d.Statistics.Any())); } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneMissingBeatmapNotification.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneMissingBeatmapNotification.cs index f5506edf3b..b7d58a633d 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneMissingBeatmapNotification.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneMissingBeatmapNotification.cs @@ -27,7 +27,7 @@ namespace osu.Game.Tests.Visual.UserInterface AutoSizeAxes = Axes.Y, Anchor = Anchor.Centre, Origin = Anchor.Centre, - Child = new MissingBeatmapNotification(CreateAPIBeatmapSet(Ruleset.Value).Beatmaps.First(), new ImportScoreTest.TestArchiveReader(), "deadbeef") + Child = new MissingBeatmapNotification(CreateAPIBeatmapSet(Ruleset.Value).Beatmaps.First(), "deadbeef", new ImportScoreTest.TestArchiveReader()) }; } } diff --git a/osu.Game/Database/MissingBeatmapNotification.cs b/osu.Game/Database/MissingBeatmapNotification.cs index 584b2675f3..fff2448f3f 100644 --- a/osu.Game/Database/MissingBeatmapNotification.cs +++ b/osu.Game/Database/MissingBeatmapNotification.cs @@ -28,7 +28,7 @@ namespace osu.Game.Database [Resolved] private RealmAccess realm { get; set; } = null!; - private readonly ArchiveReader scoreArchive; + private readonly ArchiveReader? scoreArchive; private readonly APIBeatmapSet beatmapSetInfo; private readonly string beatmapHash; @@ -38,7 +38,13 @@ namespace osu.Game.Database private IDisposable? realmSubscription; - public MissingBeatmapNotification(APIBeatmap beatmap, ArchiveReader scoreArchive, string beatmapHash) + /// + /// Creates a new notification about a missing beatmap that needs to be downloaded to proceed with an action. + /// + /// The online-retrieved beatmap to download. + /// The hash of the beatmap that is required to proceed. + /// Optional archive with a score. If not , a re-import of this archive will be attempted after the missing beatmap is downloaded. + public MissingBeatmapNotification(APIBeatmap beatmap, string beatmapHash, ArchiveReader? scoreArchive) { beatmapSetInfo = beatmap.BeatmapSet!; @@ -86,9 +92,13 @@ namespace osu.Game.Database if (sender.Any(s => s.Beatmaps.Any(b => b.MD5Hash == beatmapHash))) { - string name = scoreArchive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase)); - var importTask = new ImportTask(scoreArchive.GetStream(name), name); - scoreManager.Import(new[] { importTask }); + if (scoreArchive != null) + { + string name = scoreArchive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase)); + var importTask = new ImportTask(scoreArchive.GetStream(name), name); + scoreManager.Import(new[] { importTask }); + } + realmSubscription?.Dispose(); Close(false); } diff --git a/osu.Game/Graphics/Carousel/Carousel.cs b/osu.Game/Graphics/Carousel/Carousel.cs index 3a02eb7119..8d8289422b 100644 --- a/osu.Game/Graphics/Carousel/Carousel.cs +++ b/osu.Game/Graphics/Carousel/Carousel.cs @@ -166,6 +166,11 @@ namespace osu.Game.Graphics.Carousel /// protected virtual Task FilterAsync() => filterTask = performFilter(); + /// + /// Check whether two models are the same for display purposes. + /// + protected virtual bool CheckModelEquality(object x, object y) => ReferenceEquals(x, y); + /// /// Create a drawable for the given carousel item so it can be displayed. /// @@ -490,11 +495,11 @@ namespace osu.Game.Graphics.Carousel updateItemYPosition(item, ref lastVisible, ref yPos); - if (ReferenceEquals(item.Model, currentKeyboardSelection.Model)) - currentKeyboardSelection = new Selection(item.Model, item, item.CarouselYPosition, i); + if (CheckModelEquality(item.Model, currentKeyboardSelection.Model!)) + currentKeyboardSelection = new Selection(currentKeyboardSelection.Model, item, item.CarouselYPosition, i); - if (ReferenceEquals(item.Model, currentSelection.Model)) - currentSelection = new Selection(item.Model, item, item.CarouselYPosition, i); + if (CheckModelEquality(item.Model, currentSelection.Model!)) + currentSelection = new Selection(currentSelection.Model, item, item.CarouselYPosition, i); } // If a keyboard selection is currently made, we want to keep the view stable around the selection. @@ -578,7 +583,7 @@ namespace osu.Game.Graphics.Carousel panel.X = GetPanelXOffset(panel); - c.Selected.Value = c.Item == currentSelection?.CarouselItem; + c.Selected.Value = currentSelection?.CarouselItem != null && CheckModelEquality(c.Item, currentSelection.CarouselItem); c.KeyboardSelected.Value = c.Item == currentKeyboardSelection?.CarouselItem; c.Expanded.Value = c.Item.IsExpanded; } @@ -644,7 +649,10 @@ namespace osu.Game.Graphics.Carousel // The case where we're intending to display this panel, but it's already displayed. // Note that we **must compare the model here** as the CarouselItems may be fresh instances due to a filter operation. - var existing = toDisplay.FirstOrDefault(i => i.Model == carouselPanel.Item!.Model); + // + // Reference equality is used here instead of CheckModelEquality intentionally. In order to switch to `CheckModelEquality`, + // we need a way to signal to the drawable panels that there is an update. + var existing = toDisplay.FirstOrDefault(i => ReferenceEquals(i.Model, carouselPanel.Item!.Model)); if (existing != null) { diff --git a/osu.Game/Graphics/Containers/ExpandingContainer.cs b/osu.Game/Graphics/Containers/ExpandingContainer.cs index 2abdb508ae..477de616ac 100644 --- a/osu.Game/Graphics/Containers/ExpandingContainer.cs +++ b/osu.Game/Graphics/Containers/ExpandingContainer.cs @@ -14,6 +14,8 @@ namespace osu.Game.Graphics.Containers /// public partial class ExpandingContainer : Container, IExpandingContainer { + public const double TRANSITION_DURATION = 500; + private readonly float contractedWidth; private readonly float expandedWidth; @@ -61,7 +63,7 @@ namespace osu.Game.Graphics.Containers Expanded.BindValueChanged(v => { - this.ResizeWidthTo(v.NewValue ? expandedWidth : contractedWidth, 500, Easing.OutQuint); + this.ResizeWidthTo(v.NewValue ? expandedWidth : contractedWidth, TRANSITION_DURATION, Easing.OutQuint); }, true); } diff --git a/osu.Game/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/OsuGame.cs b/osu.Game/OsuGame.cs index cbb2d44a9a..962718b564 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -46,6 +46,7 @@ using osu.Game.Input.Bindings; using osu.Game.IO; using osu.Game.Localisation; using osu.Game.Online; +using osu.Game.Online.API.Requests; using osu.Game.Online.Chat; using osu.Game.Online.Leaderboards; using osu.Game.Online.Rooms; @@ -59,6 +60,7 @@ using osu.Game.Overlays.SkinEditor; using osu.Game.Overlays.Toolbar; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; using osu.Game.Screens; using osu.Game.Screens.Edit; using osu.Game.Screens.Footer; @@ -742,23 +744,33 @@ namespace osu.Game { Logger.Log($"Beginning {nameof(PresentScore)} with score {score}"); - var databasedScore = ScoreManager.GetScore(score); + Score databasedScore; + + try + { + databasedScore = ScoreManager.GetScore(score); + } + catch (LegacyScoreDecoder.BeatmapNotFoundException notFound) + { + Logger.Log("The replay cannot be played because the beatmap is missing.", LoggingTarget.Information); + + var req = new GetBeatmapRequest(new BeatmapInfo { MD5Hash = notFound.Hash }); + req.Success += res => Notifications.Post(new MissingBeatmapNotification(res, notFound.Hash, null)); + API.Queue(req); + + return; + } if (databasedScore == null) return; if (databasedScore.Replay == null) { - Logger.Log("The loaded score has no replay data.", LoggingTarget.Information); + Logger.Log("The loaded score has no replay data.", LoggingTarget.Information, LogLevel.Important); return; } - var databasedBeatmap = BeatmapManager.QueryBeatmap(b => b.ID == databasedScore.ScoreInfo.BeatmapInfo.ID); - - if (databasedBeatmap == null) - { - Logger.Log("Tried to load a score for a beatmap we don't have!", LoggingTarget.Information); - return; - } + var databasedBeatmap = databasedScore.ScoreInfo.BeatmapInfo; + Debug.Assert(databasedBeatmap != null); // This should be able to be performed from song select always, but that is disabled for now // due to the weird decoupled ruleset logic (which can cause a crash in certain filter scenarios). diff --git a/osu.Game/Overlays/Rankings/RankingsScope.cs b/osu.Game/Overlays/Rankings/RankingsScope.cs index 0740c17e8c..658732a1b1 100644 --- a/osu.Game/Overlays/Rankings/RankingsScope.cs +++ b/osu.Game/Overlays/Rankings/RankingsScope.cs @@ -8,10 +8,10 @@ namespace osu.Game.Overlays.Rankings { public enum RankingsScope { - [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypePerformance))] + [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.StatPerformance))] Performance, - [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeScore))] + [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.StatRankedScore))] Score, [LocalisableDescription(typeof(RankingsStrings), nameof(RankingsStrings.TypeCountry))] diff --git a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs index e104bb7e39..3ce546785a 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/TabletSettings.cs @@ -37,13 +37,13 @@ namespace osu.Game.Overlays.Settings.Sections.Input private readonly Bindable areaSize = new Bindable(); private readonly IBindable tablet = new Bindable(); - private readonly BindableNumber offsetX = new BindableNumber { MinValue = 0 }; - private readonly BindableNumber offsetY = new BindableNumber { MinValue = 0 }; + private readonly BindableNumber offsetX = new BindableNumber { MinValue = 0, Precision = 1 }; + private readonly BindableNumber offsetY = new BindableNumber { MinValue = 0, Precision = 1 }; - private readonly BindableNumber sizeX = new BindableNumber { MinValue = 10 }; - private readonly BindableNumber sizeY = new BindableNumber { MinValue = 10 }; + private readonly BindableNumber sizeX = new BindableNumber { MinValue = 10, Precision = 1 }; + private readonly BindableNumber sizeY = new BindableNumber { MinValue = 10, Precision = 1 }; - private readonly BindableNumber rotation = new BindableNumber { MinValue = 0, MaxValue = 360 }; + private readonly BindableNumber rotation = new BindableNumber { MinValue = 0, MaxValue = 360, Precision = 1 }; private readonly BindableNumber pressureThreshold = new BindableNumber { MinValue = 0.0f, MaxValue = 1.0f, Precision = 0.005f }; 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/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs index 4b3f4a5e63..55b172526f 100644 --- a/osu.Game/Scoring/ScoreImporter.cs +++ b/osu.Game/Scoring/ScoreImporter.cs @@ -59,7 +59,7 @@ namespace osu.Game.Scoring { // In the case of a missing beatmap, let's attempt to resolve it and show a prompt to the user to download the required beatmap. var req = new GetBeatmapRequest(new BeatmapInfo { MD5Hash = notFound.Hash }); - req.Success += res => PostNotification?.Invoke(new MissingBeatmapNotification(res, archive, notFound.Hash)); + req.Success += res => PostNotification?.Invoke(new MissingBeatmapNotification(res, notFound.Hash, archive)); api.Queue(req); } diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs index 326fdbc731..7b36b5f957 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/DrawableTernaryButton.cs @@ -47,7 +47,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons private Color4 selectedBackgroundColour; private Color4 selectedIconColour; - protected Drawable Icon { get; private set; } = null!; + public Drawable Icon { get; private set; } = null!; public DrawableTernaryButton() { diff --git a/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs index c6ecee5f45..259fda70c5 100644 --- a/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs +++ b/osu.Game/Screens/Edit/Components/TernaryButtons/NewComboTernaryButton.cs @@ -40,11 +40,14 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons private readonly BindableList selectedHitObjects = new BindableList(); private readonly BindableList comboColours = new BindableList(); + private readonly Bindable expanded = new Bindable(true); + private Container mainButtonContainer = null!; private ColourPickerButton pickerButton = null!; + private DrawableTernaryButton mainButton = null!; [BackgroundDependencyLoader] - private void load(EditorBeatmap editorBeatmap) + private void load(EditorBeatmap editorBeatmap, IExpandingContainer? expandableParent) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -54,7 +57,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Child = new DrawableTernaryButton + Child = mainButton = new DrawableTernaryButton { Current = Current, Description = "New combo", @@ -65,8 +68,6 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, - Alpha = 0, - Width = 25, ComboColours = { BindTarget = comboColours } } }; @@ -74,6 +75,9 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons selectedHitObjects.BindTo(editorBeatmap.SelectedHitObjects); if (editorBeatmap.BeatmapSkin != null) comboColours.BindTo(editorBeatmap.BeatmapSkin.ComboColours); + + if (expandableParent != null) + expanded.BindTo(expandableParent.Expanded); } protected override void LoadComplete() @@ -82,6 +86,7 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons selectedHitObjects.BindCollectionChanged((_, _) => updateState()); comboColours.BindCollectionChanged((_, _) => updateState()); + expanded.BindValueChanged(_ => updateState()); Current.BindValueChanged(_ => updateState(), true); } @@ -89,14 +94,21 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { if (Current.Value == TernaryState.True && selectedHitObjects.Count == 1 && selectedHitObjects.Single() is IHasComboInformation hasCombo && comboColours.Count > 1) { - mainButtonContainer.Padding = new MarginPadding { Right = 30 }; + float targetPickerButtonWidth = expanded.Value ? 25 : 10; + + pickerButton.ResizeWidthTo(targetPickerButtonWidth, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); pickerButton.SelectedHitObject.Value = hasCombo; - pickerButton.Alpha = 1; + pickerButton.Icon.Alpha = expanded.Value ? 1 : 0; + + mainButtonContainer.TransformTo(nameof(mainButtonContainer.Padding), new MarginPadding { Right = targetPickerButtonWidth + 5 }, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); + mainButton.Icon.MoveToX(expanded.Value ? 10 : 2.5f, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); } else { - mainButtonContainer.Padding = new MarginPadding(); - pickerButton.Alpha = 0; + pickerButton.ResizeWidthTo(0, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); + + mainButtonContainer.TransformTo(nameof(mainButtonContainer.Padding), new MarginPadding(), ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); + mainButton.Icon.MoveToX(10, ExpandingContainer.TRANSITION_DURATION, Easing.OutQuint); } } @@ -111,12 +123,12 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; - private SpriteIcon icon = null!; + public SpriteIcon Icon { get; private set; } = null!; [BackgroundDependencyLoader] private void load() { - Add(icon = new SpriteIcon + Add(Icon = new SpriteIcon { Icon = FontAwesome.Solid.Palette, Size = new Vector2(16), @@ -149,17 +161,17 @@ namespace osu.Game.Screens.Edit.Components.TernaryButtons { Enabled.Value = SelectedHitObject.Value != null; - if (SelectedHitObject.Value == null || SelectedHitObject.Value.ComboOffset == 0 || ComboColours.Count <= 1) + if (SelectedHitObject.Value == null || SelectedHitObject.Value.ComboOffset == 0 || ComboColours.Count <= 1 || !SelectedHitObject.Value.NewCombo) { BackgroundColour = colourProvider.Background3; - icon.Colour = BackgroundColour.Darken(0.5f); - icon.Blending = BlendingParameters.Additive; + Icon.Colour = BackgroundColour.Darken(0.5f); + Icon.Blending = BlendingParameters.Additive; } else { BackgroundColour = ComboColours[comboIndexFor(SelectedHitObject.Value, ComboColours)]; - icon.Colour = OsuColour.ForegroundTextColourFor(BackgroundColour); - icon.Blending = BlendingParameters.Inherit; + Icon.Colour = OsuColour.ForegroundTextColourFor(BackgroundColour); + Icon.Blending = BlendingParameters.Inherit; } } diff --git a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs index e90936e38a..a258016da5 100644 --- a/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs +++ b/osu.Game/Screens/Edit/Compose/Components/EditorSelectionHandler.cs @@ -281,6 +281,8 @@ namespace osu.Game.Screens.Edit.Compose.Components SelectionAdditionBanksEnabled.Value = true; SelectionBankStates[HIT_BANK_AUTO].Value = TernaryState.True; SelectionAdditionBankStates[HIT_BANK_AUTO].Value = TernaryState.True; + foreach (var (_, sampleState) in SelectionSampleStates) + sampleState.Value = TernaryState.False; } /// @@ -316,7 +318,7 @@ namespace osu.Game.Screens.Edit.Compose.Components private void onSelectedItemsChanged(object? sender, NotifyCollectionChangedEventArgs e) { // Reset the ternary states when the selection is cleared. - if (e.OldStartingIndex >= 0 && e.NewStartingIndex < 0) + if (SelectedItems.Count == 0) Scheduler.AddOnce(resetTernaryStates); else Scheduler.AddOnce(UpdateTernaryStates); diff --git a/osu.Game/Screens/Edit/Setup/MetadataSection.cs b/osu.Game/Screens/Edit/Setup/MetadataSection.cs index 735204e2f4..4e8aed8c58 100644 --- a/osu.Game/Screens/Edit/Setup/MetadataSection.cs +++ b/osu.Game/Screens/Edit/Setup/MetadataSection.cs @@ -44,7 +44,7 @@ namespace osu.Game.Screens.Edit.Setup creatorTextBox = createTextBox(EditorSetupStrings.Creator), difficultyTextBox = createTextBox(EditorSetupStrings.DifficultyName), sourceTextBox = createTextBox(BeatmapsetsStrings.ShowInfoSource), - tagsTextBox = createTextBox(BeatmapsetsStrings.ShowInfoTags) + tagsTextBox = createTextBox(BeatmapsetsStrings.ShowInfoMapperTags) }; if (setupScreen != null) diff --git a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs index 2ea710d3ab..94ed813461 100644 --- a/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs +++ b/osu.Game/Screens/Edit/Submission/BeatmapSubmissionScreen.cs @@ -8,6 +8,8 @@ using System.IO; using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Development; using osu.Framework.Extensions; using osu.Framework.Graphics; @@ -81,8 +83,10 @@ namespace osu.Game.Screens.Edit.Submission private Live? importedSet; + private Sample completedSample = null!; + [BackgroundDependencyLoader] - private void load() + private void load(AudioManager audio) { AddRangeInternal(new Drawable[] { @@ -118,24 +122,28 @@ namespace osu.Game.Screens.Edit.Submission createSetStep = new SubmissionStageProgress { StageDescription = BeatmapSubmissionStrings.Preparing, + StageIndex = 0, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, }, exportStep = new SubmissionStageProgress { StageDescription = BeatmapSubmissionStrings.Exporting, + StageIndex = 1, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, }, uploadStep = new SubmissionStageProgress { StageDescription = BeatmapSubmissionStrings.Uploading, + StageIndex = 2, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, }, updateStep = new SubmissionStageProgress { StageDescription = BeatmapSubmissionStrings.Finishing, + StageIndex = 3, Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, }, @@ -181,6 +189,8 @@ namespace osu.Game.Screens.Edit.Submission } } }); + + completedSample = audio.Samples.Get(@"UI/bss-complete"); } private void createBeatmapSet() @@ -382,6 +392,8 @@ namespace osu.Game.Screens.Edit.Submission successContainer.Add(loaded); flashLayer.FadeOutFromOne(2000, Easing.OutQuint); }); + + completedSample.Play(); }; api.Queue(getBeatmapSetRequest); diff --git a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs index 101313c627..389ba2470a 100644 --- a/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs +++ b/osu.Game/Screens/Edit/Submission/SubmissionStageProgress.cs @@ -1,13 +1,18 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Localisation; +using osu.Framework.Threading; +using osu.Framework.Utils; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -21,6 +26,8 @@ namespace osu.Game.Screens.Edit.Submission { public LocalisableString StageDescription { get; init; } + public int StageIndex { get; init; } + private Bindable status { get; } = new Bindable(); private Bindable progress { get; } = new Bindable(); @@ -33,8 +40,22 @@ namespace osu.Game.Screens.Edit.Submission [Resolved] private OsuColour colours { get; set; } = null!; + private Sample? progressSample; + + private const int stage_done_sample_count = 4; + private Sample? stageDoneSample; + + private Sample? errorSample; + private Sample? cancelSample; + + private SampleChannel? progressSampleChannel; + + private const int fadeout_duration = 100; + private ScheduledDelegate? progressSampleFadeDelegate; + private ScheduledDelegate? progressSampleStopDelegate; + [BackgroundDependencyLoader] - private void load(OverlayColourProvider colourProvider) + private void load(OverlayColourProvider colourProvider, AudioManager audio) { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; @@ -111,6 +132,13 @@ namespace osu.Game.Screens.Edit.Submission } } }; + + errorSample = audio.Samples.Get(@"UI/generic-error"); + cancelSample = audio.Samples.Get(@"UI/notification-cancel"); + progressSample = audio.Samples.Get(@"UI/bss-progress"); + + int stageSample = Math.Min(stage_done_sample_count - 1, StageIndex); + stageDoneSample = audio.Samples.Get(@$"UI/bss-stage-{stageSample}"); } protected override void LoadComplete() @@ -119,6 +147,8 @@ namespace osu.Game.Screens.Edit.Submission status.BindValueChanged(_ => Scheduler.AddOnce(updateStatus), true); progress.BindValueChanged(_ => Scheduler.AddOnce(updateProgress), true); + + progressSampleChannel = progressSample?.GetChannel(); } public void SetNotStarted() => status.Value = StageStatusType.NotStarted; @@ -127,6 +157,13 @@ namespace osu.Game.Screens.Edit.Submission { this.progress.Value = progress; status.Value = StageStatusType.InProgress; + + if (progressSampleChannel == null) + return; + + progressSampleChannel.Frequency.Value = 0.5f; + progressSampleChannel.Volume.Value = 0.25f; + progressSampleChannel.Looping = true; } public void SetCompleted() => status.Value = StageStatusType.Completed; @@ -139,14 +176,44 @@ namespace osu.Game.Screens.Edit.Submission public void SetCanceled() => status.Value = StageStatusType.Canceled; + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + progressSampleChannel?.Stop(); + } + private const float transition_duration = 200; + private const Easing transition_easing = Easing.OutQuint; private void updateProgress() { - if (progress.Value != null) - progressBar.ResizeWidthTo(progress.Value.Value, transition_duration, Easing.OutQuint); + progressSampleFadeDelegate?.Cancel(); + progressSampleStopDelegate?.Cancel(); - progressBarContainer.FadeTo(status.Value == StageStatusType.InProgress && progress.Value != null ? 1 : 0, transition_duration, Easing.OutQuint); + progressBarContainer.FadeTo(status.Value == StageStatusType.InProgress && progress.Value != null ? 1 : 0, transition_duration, transition_easing); + + if (progress.Value is float progressValue) + { + progressBar.ResizeWidthTo(progressValue, transition_duration, transition_easing); + + if (progressSampleChannel == null || Precision.AlmostEquals(progressValue, 0f)) + return; + + // Don't restart the looping sample if already playing + if (!progressSampleChannel.Playing) + progressSampleChannel.Play(); + + this.TransformBindableTo(progressSampleChannel.Frequency, 0.5f + (progressValue * 1.5f), transition_duration, transition_easing); + this.TransformBindableTo(progressSampleChannel.Volume, 0.25f + (progressValue * .75f), transition_duration, transition_easing); + + progressSampleFadeDelegate = Scheduler.AddDelayed(() => + { + // Perform a fade-out before stopping the sample to prevent clicking. + this.TransformBindableTo(progressSampleChannel.Volume, 0, fadeout_duration); + progressSampleStopDelegate = Scheduler.AddDelayed(() => { progressSampleChannel.Stop(); }, fadeout_duration); + }, transition_duration - fadeout_duration); + } } private void updateStatus() @@ -176,6 +243,12 @@ namespace osu.Game.Screens.Edit.Submission }; iconContainer.Colour = colours.Green1; iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint); + + // manually set progress value, as to trigger sample playback for the final section + progress.Value = 1; + + stageDoneSample?.Play(); + break; case StageStatusType.Failed: @@ -186,6 +259,7 @@ namespace osu.Game.Screens.Edit.Submission }; iconContainer.Colour = colours.Red1; iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint); + errorSample?.Play(); break; case StageStatusType.Canceled: @@ -196,6 +270,7 @@ namespace osu.Game.Screens.Edit.Submission }; iconContainer.Colour = colours.Gray8; iconContainer.FlashColour(Colour4.White, 1000, Easing.OutQuint); + cancelSample?.Play(); break; } } 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/PlaylistItemResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs index 0e539936d8..e994299606 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistItemResultsScreen.cs @@ -19,6 +19,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Scoring; +using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Ranking; namespace osu.Game.Screens.OnlinePlay.Playlists @@ -34,6 +35,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists private MultiplayerScores? higherScores; private MultiplayerScores? lowerScores; + private WorkingBeatmap itemBeatmap = null!; [Resolved] protected IAPIProvider API { get; private set; } = null!; @@ -60,6 +62,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [BackgroundDependencyLoader] private void load() { + var localBeatmap = beatmapManager.QueryBeatmap($@"{nameof(BeatmapInfo.OnlineID)} == $0 AND {nameof(BeatmapInfo.MD5Hash)} == {nameof(BeatmapInfo.OnlineMD5Hash)}", + PlaylistItem.Beatmap.OnlineID); + itemBeatmap = beatmapManager.GetWorkingBeatmap(localBeatmap); + AddInternal(new Container { RelativeSizeAxes = Axes.Both, @@ -307,6 +313,8 @@ namespace osu.Game.Screens.OnlinePlay.Playlists } } + protected override BackgroundScreen CreateBackground() => new BackgroundScreenBeatmap(itemBeatmap); + private partial class PanelListLoadingSpinner : LoadingSpinner { private readonly ScorePanelList list; diff --git a/osu.Game/Screens/OnlinePlay/Playlists/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/Screens/SelectV2/BeatmapCarousel.cs b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs index 9cb7d152de..4af5e759a7 100644 --- a/osu.Game/Screens/SelectV2/BeatmapCarousel.cs +++ b/osu.Game/Screens/SelectV2/BeatmapCarousel.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Collections.Specialized; +using System.Diagnostics; using System.Linq; using System.Threading; using osu.Framework.Allocation; @@ -74,18 +75,17 @@ namespace osu.Game.Screens.SelectV2 { // TODO: moving management of BeatmapInfo tracking to BeatmapStore might be something we want to consider. // right now we are managing this locally which is a bit of added overhead. - IEnumerable? newBeatmapSets = changed.NewItems?.Cast(); - IEnumerable? beatmapSetInfos = changed.OldItems?.Cast(); + IEnumerable? newItems = changed.NewItems?.Cast(); + IEnumerable? oldItems = changed.OldItems?.Cast(); switch (changed.Action) { case NotifyCollectionChangedAction.Add: - Items.AddRange(newBeatmapSets!.SelectMany(s => s.Beatmaps)); + Items.AddRange(newItems!.SelectMany(s => s.Beatmaps)); break; case NotifyCollectionChangedAction.Remove: - - foreach (var set in beatmapSetInfos!) + foreach (var set in oldItems!) { foreach (var beatmap in set.Beatmaps) Items.RemoveAll(i => i is BeatmapInfo bi && beatmap.Equals(bi)); @@ -94,8 +94,50 @@ namespace osu.Game.Screens.SelectV2 break; case NotifyCollectionChangedAction.Move: + // We can ignore move operations as we are applying our own sort in all cases. + break; + case NotifyCollectionChangedAction.Replace: - throw new NotImplementedException(); + var oldSetBeatmaps = oldItems!.Single().Beatmaps; + var newSetBeatmaps = newItems!.Single().Beatmaps.ToList(); + + // Handling replace operations is a touch manual, as we need to locally diff the beatmaps of each version of the beatmap set. + // Matching is done based on online IDs, then difficulty names as these are the most stable thing between updates (which are usually triggered + // by users editing the beatmap or by difficulty/metadata recomputation). + // + // In the case of difficulty reprocessing, this will trigger multiple times per beatmap as it's always triggering a set update. + // We may want to look to improve this in the future either here or at the source (only trigger an update after all difficulties + // have been processed) if it becomes an issue for animation or performance reasons. + foreach (var beatmap in oldSetBeatmaps) + { + int previousIndex = Items.IndexOf(beatmap); + Debug.Assert(previousIndex >= 0); + + BeatmapInfo? matchingNewBeatmap = + newSetBeatmaps.SingleOrDefault(b => b.OnlineID > 0 && b.OnlineID == beatmap.OnlineID) ?? + newSetBeatmaps.SingleOrDefault(b => b.DifficultyName == beatmap.DifficultyName && b.Ruleset.Equals(beatmap.Ruleset)); + + if (matchingNewBeatmap != null) + { + // TODO: should this exist in song select instead of here? + // we need to ensure the global beatmap is also updated alongside changes. + if (CurrentSelection != null && CheckModelEquality(beatmap, CurrentSelection)) + CurrentSelection = matchingNewBeatmap; + + Items.ReplaceRange(previousIndex, 1, [matchingNewBeatmap]); + newSetBeatmaps.Remove(matchingNewBeatmap); + } + else + { + Items.RemoveAt(previousIndex); + } + } + + // Add any items which weren't found in the previous pass (difficulty names didn't match). + foreach (var beatmap in newSetBeatmaps) + Items.Add(beatmap); + + break; case NotifyCollectionChangedAction.Reset: Items.Clear(); @@ -132,7 +174,7 @@ namespace osu.Game.Screens.SelectV2 return; case BeatmapInfo beatmapInfo: - if (ReferenceEquals(CurrentSelection, beatmapInfo)) + if (CurrentSelection != null && CheckModelEquality(CurrentSelection, beatmapInfo)) { RequestPresentBeatmap?.Invoke(beatmapInfo); return; @@ -155,7 +197,7 @@ namespace osu.Game.Screens.SelectV2 case BeatmapInfo beatmapInfo: // Find any containing group. There should never be too many groups so iterating is efficient enough. - GroupDefinition? containingGroup = grouping.GroupItems.SingleOrDefault(kvp => kvp.Value.Any(i => ReferenceEquals(i.Model, beatmapInfo))).Key; + GroupDefinition? containingGroup = grouping.GroupItems.SingleOrDefault(kvp => kvp.Value.Any(i => CheckModelEquality(i.Model, beatmapInfo))).Key; if (containingGroup != null) setExpandedGroup(containingGroup); @@ -311,6 +353,24 @@ namespace osu.Game.Screens.SelectV2 AddInternal(setPanelPool); } + protected override bool CheckModelEquality(object x, object y) + { + // In the confines of the carousel logic, we assume that CurrentSelection (and all items) are using non-stale + // BeatmapInfo reference, and that we can match based on beatmap / beatmapset (GU)IDs. + // + // If there's a case where updates don't come in as expected, diagnosis should start from BeatmapStore, ensuring + // it is doing a Replace operation on the list. If it is, then check the local handling in beatmapSetsChanged + // before changing matching requirements here. + + if (x is BeatmapSetInfo beatmapSetX && y is BeatmapSetInfo beatmapSetY) + return beatmapSetX.Equals(beatmapSetY); + + if (x is BeatmapInfo beatmapX && y is BeatmapInfo beatmapY) + return beatmapX.Equals(beatmapY); + + return base.CheckModelEquality(x, y); + } + protected override Drawable GetDrawableForDisplay(CarouselItem item) { switch (item.Model) diff --git a/osu.Game/Skinning/TrianglesSkin.cs b/osu.Game/Skinning/TrianglesSkin.cs index a4a967bed9..18ca7629d7 100644 --- a/osu.Game/Skinning/TrianglesSkin.cs +++ b/osu.Game/Skinning/TrianglesSkin.cs @@ -68,10 +68,6 @@ namespace osu.Game.Skinning switch (lookup) { case GlobalSkinnableContainerLookup containerLookup: - // Only handle global level defaults for now. - if (containerLookup.Ruleset != null) - return null; - switch (containerLookup.Lookup) { case GlobalSkinnableContainers.SongSelect: @@ -83,6 +79,11 @@ namespace osu.Game.Skinning return songSelectComponents; case GlobalSkinnableContainers.MainHUDComponents: + if (containerLookup.Ruleset != null) + { + return new DefaultSkinComponentsContainer(_ => { }); + } + var skinnableTargetWrapper = new DefaultSkinComponentsContainer(container => { var score = container.OfType().FirstOrDefault(); 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(); } } } diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index b8aae9c015..a300d971b5 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,7 +36,7 @@ - +