1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-17 17:33:02 +08:00

Merge pull request #34181 from Hiviexd/verify/check-lowest-diff-drain

Add verify check for lowest diff drain/play time requirements
This commit is contained in:
Bartłomiej Dach
2025-07-22 08:38:59 +02:00
committed by GitHub
Unverified
12 changed files with 492 additions and 2 deletions
@@ -18,6 +18,9 @@ namespace osu.Game.Rulesets.Catch.Edit
new CheckBananaShowerGap(),
new CheckConcurrentObjects(),
// Spread
new CheckCatchLowestDiffDrainTime(),
// Settings
new CheckCatchAbnormalDifficultySettings(),
};
@@ -0,0 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks;
namespace osu.Game.Rulesets.Catch.Edit.Checks
{
public class CheckCatchLowestDiffDrainTime : CheckLowestDiffDrainTime
{
protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds()
{
// See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21catch#general
yield return (DifficultyRating.Hard, new TimeSpan(0, 2, 30).TotalMilliseconds, "Platter");
yield return (DifficultyRating.Insane, new TimeSpan(0, 3, 15).TotalMilliseconds, "Rain");
yield return (DifficultyRating.Expert, new TimeSpan(0, 4, 0).TotalMilliseconds, "Overdose");
}
}
}
@@ -0,0 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks;
namespace osu.Game.Rulesets.Mania.Edit.Checks
{
public class CheckManiaLowestDiffDrainTime : CheckLowestDiffDrainTime
{
protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds()
{
// See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21mania#rules
yield return (DifficultyRating.Hard, new TimeSpan(0, 2, 30).TotalMilliseconds, "Hard");
yield return (DifficultyRating.Insane, new TimeSpan(0, 2, 45).TotalMilliseconds, "Insane");
yield return (DifficultyRating.Expert, new TimeSpan(0, 3, 30).TotalMilliseconds, "Expert");
}
}
}
@@ -16,6 +16,9 @@ namespace osu.Game.Rulesets.Mania.Edit
// Compose
new CheckManiaConcurrentObjects(),
// Spread
new CheckManiaLowestDiffDrainTime(),
// Settings
new CheckKeyCount(),
new CheckManiaAbnormalDifficultySettings(),
@@ -0,0 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks;
namespace osu.Game.Rulesets.Osu.Edit.Checks
{
public class CheckOsuLowestDiffDrainTime : CheckLowestDiffDrainTime
{
protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds()
{
// See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21#general
yield return (DifficultyRating.Hard, new TimeSpan(0, 3, 30).TotalMilliseconds, "Hard");
yield return (DifficultyRating.Insane, new TimeSpan(0, 4, 15).TotalMilliseconds, "Insane");
yield return (DifficultyRating.Expert, new TimeSpan(0, 5, 0).TotalMilliseconds, "Expert");
}
}
}
@@ -23,6 +23,7 @@ namespace osu.Game.Rulesets.Osu.Edit
new CheckTimeDistanceEquality(),
new CheckLowDiffOverlaps(),
new CheckTooShortSliders(),
new CheckOsuLowestDiffDrainTime(),
// Settings
new CheckOsuAbnormalDifficultySettings(),
@@ -0,0 +1,21 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks;
namespace osu.Game.Rulesets.Taiko.Edit.Checks
{
public class CheckTaikoLowestDiffDrainTime : CheckLowestDiffDrainTime
{
protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds()
{
// See lowest difficulty requirements in https://osu.ppy.sh/wiki/en/Ranking_criteria/osu%21taiko#general
yield return (DifficultyRating.Hard, new TimeSpan(0, 3, 30).TotalMilliseconds, "Muzukashii");
yield return (DifficultyRating.Insane, new TimeSpan(0, 4, 15).TotalMilliseconds, "Oni");
yield return (DifficultyRating.Expert, new TimeSpan(0, 5, 0).TotalMilliseconds, "Inner Oni");
}
}
}
@@ -17,6 +17,9 @@ namespace osu.Game.Rulesets.Taiko.Edit
// Compose
new CheckConcurrentObjects(),
// Spread
new CheckTaikoLowestDiffDrainTime(),
// Settings
new CheckTaikoAbnormalDifficultySettings(),
};
@@ -0,0 +1,264 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.Game.Beatmaps;
using osu.Game.Beatmaps.Timing;
using osu.Game.Extensions;
using osu.Game.Rulesets.Edit;
using osu.Game.Rulesets.Edit.Checks;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Osu;
using osu.Game.Tests.Beatmaps;
namespace osu.Game.Tests.Editing.Checks
{
[TestFixture]
public class CheckLowestDiffDrainTimeTest
{
private TestCheckLowestDiffDrainTime check = null!;
[SetUp]
public void Setup()
{
check = new TestCheckLowestDiffDrainTime();
}
[Test]
public void TestSingleDifficultyMeetsRequirement()
{
var beatmap = createBeatmapWithDrainTime(4 * 60 * 1000, 3.5, "Hard"); // 4 minutes
assertOk(beatmap);
}
[Test]
public void TestSingleDifficultyTooShort()
{
var beatmap = createBeatmapWithDrainTime(2 * 60 * 1000, 3.5, "Hard"); // 2 minutes - too short for Hard
assertTooShort(beatmap);
}
[Test]
public void TestHardDifficultyAtThreshold()
{
var beatmap = createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 3.5, "Hard"); // Exactly 3:30
assertOk(beatmap);
}
[Test]
public void TestHardDifficultyJustUnderThreshold()
{
var beatmap = createBeatmapWithDrainTime((3 * 60 + 29) * 1000, 3.5, "Hard"); // 3:29 - just under threshold
assertTooShort(beatmap);
}
[Test]
public void TestInsaneDifficultyAtThreshold()
{
var beatmap = createBeatmapWithDrainTime((4 * 60 + 15) * 1000, 4.5, "Insane"); // Exactly 4:15
assertOk(beatmap);
}
[Test]
public void TestInsaneDifficultyTooShort()
{
var beatmap = createBeatmapWithDrainTime(4 * 60 * 1000, 4.5, "Insane"); // 4:00 - too short for Insane
assertTooShort(beatmap);
}
[Test]
public void TestExpertDifficultyAtThreshold()
{
var beatmap = createBeatmapWithDrainTime(5 * 60 * 1000, 5.5, "Expert"); // Exactly 5:00
assertOk(beatmap);
}
[Test]
public void TestExpertDifficultyTooShort()
{
var beatmap = createBeatmapWithDrainTime((4 * 60 + 30) * 1000, 5.5, "Expert"); // 4:30 - too short for Expert
assertTooShort(beatmap);
}
[Test]
public void TestEasyDifficultyMeetsRequirement()
{
var beatmap = createBeatmapWithDrainTime(2 * 60 * 1000, 1.5, "Easy"); // 2 minutes - should be ok for Easy
assertOk(beatmap);
}
[Test]
public void TestNormalDifficultyMeetsRequirement()
{
var beatmap = createBeatmapWithDrainTime(2 * 60 * 1000, 2.5, "Normal"); // 2 minutes - should be ok for Normal
assertOk(beatmap);
}
[Test]
public void TestMultipleDifficultiesMeetsRequirement()
{
var difficulties = new List<IBeatmap>
{
createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 3.5, "Hard"), // Hard - lowest difficulty, 3:30
createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 4.5, "Insane"),
createBeatmapWithDrainTime((3 * 60 + 30) * 1000, 5.5, "Expert")
};
// All should be ok because lowest difficulty is Hard and drain time meets Hard requirement
assertOkWithMultipleDifficulties(difficulties[0], difficulties);
assertOkWithMultipleDifficulties(difficulties[1], difficulties);
assertOkWithMultipleDifficulties(difficulties[2], difficulties);
}
[Test]
public void TestMultipleDifficultiesTooShort()
{
var difficulties = new List<IBeatmap>
{
createBeatmapWithDrainTime(4 * 60 * 1000, 4.5, "Insane"), // Insane - lowest difficulty, 4:00
createBeatmapWithDrainTime(4 * 60 * 1000, 5.5, "Expert") // Same drain time
};
// Should be too short because lowest difficulty is Insane and requires 4:15
assertTooShortWithMultipleDifficulties(difficulties[0], difficulties);
assertTooShortWithMultipleDifficulties(difficulties[1], difficulties);
}
[Test]
public void TestPlayTimeVsDrainTimeNotHighestDifficulty()
{
var expertBeatmap = createBeatmapWithPlayTime(5 * 60 * 1000, 5.5, "Expert"); // 5:00 play time
expertBeatmap.Breaks.Add(new BreakPeriod(60000, 100000)); // 40-second break
var difficulties = new List<IBeatmap>
{
expertBeatmap, // Expert - 5:00 play, 4:20 drain
createBeatmapWithPlayTime(5 * 60 * 1000, 6.5, "ExpertPlus") // ExpertPlus - highest difficulty
};
// The Expert difficulty (not highest) should use play time (5:00) and pass the Expert requirement
assertOkWithMultipleDifficulties(difficulties[0], difficulties);
}
[Test]
public void TestPlayTimeVsDrainTimeHighestDifficulty()
{
var expertBeatmap = createBeatmapWithPlayTime(5 * 60 * 1000, 5.5, "Expert"); // 5:00 play time
expertBeatmap.Breaks.Add(new BreakPeriod(60000, 100000)); // 40-second break
// As the highest difficulty with breaks > 30s, it should use drain time and fail
assertTooShort(expertBeatmap);
}
private IBeatmap createBeatmapWithDrainTime(double drainTimeMs, double starRating = 3.5, string difficultyName = "Default")
{
var beatmap = new Beatmap<HitObject>
{
BeatmapInfo = new BeatmapInfo
{
StarRating = starRating,
DifficultyName = difficultyName,
Ruleset = new OsuRuleset().RulesetInfo
},
HitObjects = new List<HitObject>
{
new HitObject { StartTime = 0 },
new HitObject { StartTime = drainTimeMs } // Last object at drain time
}
};
return beatmap;
}
private IBeatmap createBeatmapWithPlayTime(double playTimeMs, double starRating = 3.5, string difficultyName = "Default")
{
var beatmap = new Beatmap<HitObject>
{
BeatmapInfo = new BeatmapInfo
{
StarRating = starRating,
DifficultyName = difficultyName,
Ruleset = new OsuRuleset().RulesetInfo
},
HitObjects = new List<HitObject>
{
new HitObject { StartTime = 0 },
new HitObject { StartTime = playTimeMs } // Last object at play time
}
};
return beatmap;
}
private void assertOk(IBeatmap beatmap)
{
var difficultyRating = StarDifficulty.GetDifficultyRating(beatmap.BeatmapInfo.StarRating);
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
Assert.That(check.Run(context), Is.Empty);
}
private void assertTooShort(IBeatmap beatmap)
{
var difficultyRating = StarDifficulty.GetDifficultyRating(beatmap.BeatmapInfo.StarRating);
var context = new BeatmapVerifierContext(beatmap, new TestWorkingBeatmap(beatmap), difficultyRating);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.First().Template is CheckLowestDiffDrainTime.IssueTemplateTooShort);
}
private void assertOkWithMultipleDifficulties(IBeatmap currentBeatmap, IEnumerable<IBeatmap> allDifficulties)
{
var context = createContextWithMultipleDifficulties(currentBeatmap, allDifficulties);
Assert.That(check.Run(context), Is.Empty);
}
private void assertTooShortWithMultipleDifficulties(IBeatmap currentBeatmap, IEnumerable<IBeatmap> allDifficulties)
{
var context = createContextWithMultipleDifficulties(currentBeatmap, allDifficulties);
var issues = check.Run(context).ToList();
Assert.That(issues, Has.Count.EqualTo(1));
Assert.That(issues.First().Template is CheckLowestDiffDrainTime.IssueTemplateTooShort);
}
private BeatmapVerifierContext createContextWithMultipleDifficulties(IBeatmap currentBeatmap, IEnumerable<IBeatmap> allDifficulties)
{
var beatmapSet = new BeatmapSetInfo();
var beatmapInfos = allDifficulties.Select(d => d.BeatmapInfo).ToList();
// Set up the beatmapset with all difficulties
beatmapSet.Beatmaps.AddRange(beatmapInfos);
currentBeatmap.BeatmapInfo.BeatmapSet = beatmapSet;
// Create a resolver that returns the appropriate working beatmap for each difficulty
var difficultyDict = allDifficulties.ToDictionary(d => d.BeatmapInfo, d => new TestWorkingBeatmap(d));
// Use the current beatmap's star rating to determine its difficulty rating
var currentDifficultyRating = StarDifficulty.GetDifficultyRating(currentBeatmap.BeatmapInfo.StarRating);
return new BeatmapVerifierContext(
currentBeatmap,
new TestWorkingBeatmap(currentBeatmap),
currentDifficultyRating,
beatmapInfo => difficultyDict.TryGetValue(beatmapInfo, out var workingBeatmap) ? workingBeatmap.Beatmap : null
);
}
private class TestCheckLowestDiffDrainTime : CheckLowestDiffDrainTime
{
protected override IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds()
{
// Same thresholds as `CheckOsuLowestDiffDrainTime` for testing
yield return (DifficultyRating.Hard, new TimeSpan(0, 3, 30).TotalMilliseconds, "Hard");
yield return (DifficultyRating.Insane, new TimeSpan(0, 4, 15).TotalMilliseconds, "Insane");
yield return (DifficultyRating.Expert, new TimeSpan(0, 5, 0).TotalMilliseconds, "Expert");
}
}
}
}
@@ -1,6 +1,8 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Game.Beatmaps;
namespace osu.Game.Rulesets.Edit
@@ -26,11 +28,44 @@ namespace osu.Game.Rulesets.Edit
/// </summary>
public DifficultyRating InterpretedDifficulty;
public BeatmapVerifierContext(IBeatmap beatmap, IWorkingBeatmap workingBeatmap, DifficultyRating difficultyRating = DifficultyRating.ExpertPlus)
/// <summary>
/// All beatmap difficulties in the same beatmapset, including the current beatmap.
/// </summary>
public readonly IReadOnlyList<IBeatmap> BeatmapsetDifficulties;
// TODO: Refactor this to have a simple constructor that only stores data and move the beatmap resolution logic to a static factory method.
public BeatmapVerifierContext(IBeatmap beatmap, IWorkingBeatmap workingBeatmap, DifficultyRating difficultyRating = DifficultyRating.ExpertPlus, Func<BeatmapInfo, IBeatmap?>? beatmapResolver = null)
{
Beatmap = beatmap;
WorkingBeatmap = workingBeatmap;
InterpretedDifficulty = difficultyRating;
var beatmapSet = beatmap.BeatmapInfo.BeatmapSet;
if (beatmapSet?.Beatmaps == null)
{
BeatmapsetDifficulties = new[] { beatmap };
return;
}
var difficulties = new List<IBeatmap>();
foreach (var beatmapInfo in beatmapSet.Beatmaps)
{
// Use the current beatmap if it matches this BeatmapInfo
if (beatmapInfo.Equals(beatmap.BeatmapInfo))
{
difficulties.Add(beatmap);
continue;
}
// Try to resolve other difficulties using the provided resolver
var resolvedBeatmap = beatmapResolver?.Invoke(beatmapInfo);
if (resolvedBeatmap != null)
difficulties.Add(resolvedBeatmap);
}
BeatmapsetDifficulties = difficulties;
}
}
}
@@ -0,0 +1,88 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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 osu.Game.Beatmaps;
using osu.Game.Rulesets.Edit.Checks.Components;
namespace osu.Game.Rulesets.Edit.Checks
{
public abstract class CheckLowestDiffDrainTime : ICheck
{
/// <summary>
/// Defines the minimum drain time thresholds for different difficulty ratings.
/// </summary>
protected abstract IEnumerable<(DifficultyRating rating, double thresholdMs, string name)> GetThresholds();
private const double break_time_leniency = 30 * 1000;
public CheckMetadata Metadata => new CheckMetadata(CheckCategory.Spread, "Lowest difficulty too difficult for the given drain/play time(s)");
public IEnumerable<IssueTemplate> PossibleTemplates => new IssueTemplate[]
{
new IssueTemplateTooShort(this)
};
public IEnumerable<Issue> Run(BeatmapVerifierContext context)
{
IReadOnlyList<IBeatmap> difficulties = context.BeatmapsetDifficulties
.Where(d => d.BeatmapInfo.Ruleset.Equals(context.Beatmap.BeatmapInfo.Ruleset))
.ToList();
if (difficulties.Count == 0)
yield break;
var lowestDifficulty = difficulties.OrderBy(b => b.BeatmapInfo.StarRating).First();
// Get difficulty rating for the lowest difficulty
DifficultyRating lowestDifficultyRating = lowestDifficulty == context.Beatmap
? context.InterpretedDifficulty
: StarDifficulty.GetDifficultyRating(lowestDifficulty.BeatmapInfo.StarRating);
double drainTime = context.Beatmap.CalculateDrainLength();
double playTime = context.Beatmap.CalculatePlayableLength();
bool isHighestDifficulty = difficulties.OrderByDescending(b => b.BeatmapInfo.StarRating).First() == context.Beatmap;
// Use play time unless it's the highest difficulty and has significant breaks
bool canUsePlayTime = !isHighestDifficulty || context.Beatmap.TotalBreakTime < break_time_leniency;
double effectiveTime = canUsePlayTime ? playTime : drainTime;
double thresholdReduction = canUsePlayTime ? 0 : break_time_leniency;
// Check against thresholds based on the lowest difficulty's rating in the beatmapset
// Find the most appropriate threshold (highest rating that applies)
var applicableThreshold = GetThresholds()
.Where(t => lowestDifficultyRating >= t.rating)
.OrderByDescending(t => t.rating)
.FirstOrDefault();
if (applicableThreshold != default && effectiveTime < applicableThreshold.thresholdMs - thresholdReduction)
{
yield return new IssueTemplateTooShort(this).Create(
applicableThreshold.name,
canUsePlayTime ? "play" : "drain",
applicableThreshold.thresholdMs - thresholdReduction,
effectiveTime
);
}
}
public class IssueTemplateTooShort : IssueTemplate
{
public IssueTemplateTooShort(ICheck check)
: base(check, IssueType.Problem, "With the lowest difficulty being \"{0}\", the {1} time of this difficulty must be at least {2}, currently {3}.")
{
}
public Issue Create(string lowestDiffLevel, string timeType, double requiredTime, double currentTime)
=> new Issue(this,
lowestDiffLevel,
timeType,
TimeSpan.FromMilliseconds(requiredTime).ToString(@"m\:ss"),
TimeSpan.FromMilliseconds(currentTime).ToString(@"m\:ss"));
}
}
}
+10 -1
View File
@@ -33,6 +33,9 @@ namespace osu.Game.Screens.Edit.Verify
[Resolved]
private VerifyScreen verify { get; set; }
[Resolved]
private BeatmapManager beatmapManager { get; set; }
private IBeatmapVerifier rulesetVerifier;
private BeatmapVerifier generalVerifier;
private BeatmapVerifierContext context;
@@ -43,7 +46,13 @@ namespace osu.Game.Screens.Edit.Verify
generalVerifier = new BeatmapVerifier();
rulesetVerifier = beatmap.BeatmapInfo.Ruleset.CreateInstance().CreateBeatmapVerifier();
context = new BeatmapVerifierContext(beatmap, workingBeatmap.Value, verify.InterpretedDifficulty.Value);
context = new BeatmapVerifierContext(
beatmap,
workingBeatmap.Value,
verify.InterpretedDifficulty.Value,
beatmapInfo => beatmapManager.GetWorkingBeatmap(beatmapInfo).GetPlayableBeatmap(beatmapInfo.Ruleset)
);
verify.InterpretedDifficulty.BindValueChanged(difficulty => context.InterpretedDifficulty = difficulty.NewValue);
RelativeSizeAxes = Axes.Both;