Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix GetRateAdjustedDisplayDifficulty() (partially incorrectly) locally reimplementing difficulty range calculations #25762

Merged
merged 6 commits into from
Dec 15, 2023
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// 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.Collections.Generic;
using NUnit.Framework;
using osu.Game.Beatmaps;

namespace osu.Game.Rulesets.Catch.Tests
{
[TestFixture]
public class CatchRateAdjustedDisplayDifficultyTest
{
private static IEnumerable<float> difficultyValuesToTest()
{
for (float i = 0; i <= 10; i += 0.5f)
yield return i;
}

[TestCaseSource(nameof(difficultyValuesToTest))]
public void TestApproachRateIsUnchangedWithRateEqualToOne(float originalApproachRate)
{
var ruleset = new CatchRuleset();
var difficulty = new BeatmapDifficulty { ApproachRate = originalApproachRate };

var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 1);

Assert.That(adjustedDifficulty.ApproachRate, Is.EqualTo(originalApproachRate));
}

[Test]
public void TestRateBelowOne()
{
var ruleset = new CatchRuleset();
var difficulty = new BeatmapDifficulty();

var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 0.75);

Assert.That(adjustedDifficulty.ApproachRate, Is.EqualTo(1.67).Within(0.01));
}

[Test]
public void TestRateAboveOne()
{
var ruleset = new CatchRuleset();
var difficulty = new BeatmapDifficulty();

var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 1.5);

Assert.That(adjustedDifficulty.ApproachRate, Is.EqualTo(7.67).Within(0.01));
}
}
}
10 changes: 4 additions & 6 deletions osu.Game.Rulesets.Catch/CatchRuleset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using osu.Game.Rulesets.Catch.Difficulty;
using osu.Game.Rulesets.Catch.Edit;
using osu.Game.Rulesets.Catch.Mods;
using osu.Game.Rulesets.Catch.Objects;
using osu.Game.Rulesets.Catch.Replays;
using osu.Game.Rulesets.Catch.Scoring;
using osu.Game.Rulesets.Catch.Skinning.Argon;
Expand Down Expand Up @@ -236,17 +237,14 @@ public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatm
};
}

/// <seealso cref="CatchHitObject.ApplyDefaultsToSelf"/>
public override BeatmapDifficulty GetRateAdjustedDisplayDifficulty(IBeatmapDifficultyInfo difficulty, double rate)
{
BeatmapDifficulty adjustedDifficulty = new BeatmapDifficulty(difficulty);

double preempt = adjustedDifficulty.ApproachRate < 6
? 1200.0 + 600.0 * (5 - adjustedDifficulty.ApproachRate) / 5
: 1200.0 - 750.0 * (adjustedDifficulty.ApproachRate - 5) / 5;

double preempt = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.ApproachRate, CatchHitObject.PREEMPT_MAX, CatchHitObject.PREEMPT_MID, CatchHitObject.PREEMPT_MIN);
preempt /= rate;

adjustedDifficulty.ApproachRate = (float)(preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5);
adjustedDifficulty.ApproachRate = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, CatchHitObject.PREEMPT_MAX, CatchHitObject.PREEMPT_MID, CatchHitObject.PREEMPT_MIN);

return adjustedDifficulty;
}
Expand Down
17 changes: 16 additions & 1 deletion osu.Game.Rulesets.Catch/Objects/CatchHitObject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, I
{
base.ApplyDefaultsToSelf(controlPointInfo, difficulty);

TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450);
TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, PREEMPT_MAX, PREEMPT_MID, PREEMPT_MIN);

Scale = LegacyRulesetExtensions.CalculateScaleFromCircleSize(difficulty.CircleSize);
}
Expand Down Expand Up @@ -189,6 +189,21 @@ public void UpdateComboInformation(IHasComboInformation? lastObj)
// The half of the height of the osu! playfield.
public const float DEFAULT_LEGACY_CONVERT_Y = 192;

/// <summary>
/// Minimum preempt time at AR=10.
/// </summary>
public const double PREEMPT_MIN = 450;

/// <summary>
/// Median preempt time at AR=5.
/// </summary>
public const double PREEMPT_MID = 1200;

/// <summary>
/// Maximum preempt time at AR=0.
/// </summary>
public const double PREEMPT_MAX = 1800;

/// <summary>
/// The Y position of the hit object is not used in the normal osu!catch gameplay.
/// It is preserved to maximize the backward compatibility with the legacy editor, in which the mappers use the Y position to organize the patterns.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// 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.Collections.Generic;
using NUnit.Framework;
using osu.Game.Beatmaps;

namespace osu.Game.Rulesets.Osu.Tests
{
[TestFixture]
public class OsuRateAdjustedDisplayDifficultyTest
{
private static IEnumerable<float> difficultyValuesToTest()
{
for (float i = 0; i <= 10; i += 0.5f)
yield return i;
}

[TestCaseSource(nameof(difficultyValuesToTest))]
public void TestApproachRateIsUnchangedWithRateEqualToOne(float originalApproachRate)
{
var ruleset = new OsuRuleset();
var difficulty = new BeatmapDifficulty { ApproachRate = originalApproachRate };

var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 1);

Assert.That(adjustedDifficulty.ApproachRate, Is.EqualTo(originalApproachRate));
}

[TestCaseSource(nameof(difficultyValuesToTest))]
public void TestOverallDifficultyIsUnchangedWithRateEqualToOne(float originalOverallDifficulty)
{
var ruleset = new OsuRuleset();
var difficulty = new BeatmapDifficulty { OverallDifficulty = originalOverallDifficulty };

var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 1);

Assert.That(adjustedDifficulty.OverallDifficulty, Is.EqualTo(originalOverallDifficulty));
}

[Test]
public void TestRateBelowOne()
{
var ruleset = new OsuRuleset();
var difficulty = new BeatmapDifficulty();

var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 0.75);

Assert.That(adjustedDifficulty.ApproachRate, Is.EqualTo(1.67).Within(0.01));
Assert.That(adjustedDifficulty.OverallDifficulty, Is.EqualTo(2.22).Within(0.01));
}

[Test]
public void TestRateAboveOne()
{
var ruleset = new OsuRuleset();
var difficulty = new BeatmapDifficulty();

var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 1.5);

Assert.That(adjustedDifficulty.ApproachRate, Is.EqualTo(7.67).Within(0.01));
Assert.That(adjustedDifficulty.OverallDifficulty, Is.EqualTo(7.77).Within(0.01));
}
}
}
12 changes: 11 additions & 1 deletion osu.Game.Rulesets.Osu/Objects/OsuHitObject.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ public abstract class OsuHitObject : HitObject, IHasComboInformation, IHasPositi
/// </summary>
public const double PREEMPT_MIN = 450;

/// <summary>
/// Median preempt time at AR=5.
/// </summary>
public const double PREEMPT_MID = 1200;

/// <summary>
/// Maximum preempt time at AR=0.
/// </summary>
public const double PREEMPT_MAX = 1800;

public double TimePreempt = 600;
public double TimeFadeIn = 400;

Expand Down Expand Up @@ -148,7 +158,7 @@ protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, I
{
base.ApplyDefaultsToSelf(controlPointInfo, difficulty);

TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, PREEMPT_MIN);
TimePreempt = (float)IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, PREEMPT_MAX, PREEMPT_MID, PREEMPT_MIN);

// Preempt time can go below 450ms. Normally, this is achieved via the DT mod which uniformly speeds up all animations game wide regardless of AR.
// This uniform speedup is hard to match 1:1, however we can at least make AR>10 (via mods) feel good by extending the upper linear function above.
Expand Down
19 changes: 8 additions & 11 deletions osu.Game.Rulesets.Osu/OsuRuleset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -332,23 +332,20 @@ public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatm

public override RulesetSetupSection CreateEditorSetupSection() => new OsuSetupSection();

/// <seealso cref="OsuHitObject.ApplyDefaultsToSelf"/>
/// <seealso cref="OsuHitWindows"/>
public override BeatmapDifficulty GetRateAdjustedDisplayDifficulty(IBeatmapDifficultyInfo difficulty, double rate)
{
BeatmapDifficulty adjustedDifficulty = new BeatmapDifficulty(difficulty);

double preempt = adjustedDifficulty.ApproachRate < 5
? 1200.0 + 600.0 * (5 - adjustedDifficulty.ApproachRate) / 5
: 1200.0 - 750.0 * (adjustedDifficulty.ApproachRate - 5) / 5;

double preempt = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.ApproachRate, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN);
preempt /= rate;
adjustedDifficulty.ApproachRate = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN);

adjustedDifficulty.ApproachRate = (float)(preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5);

double hitwindow = 80.0 - 6 * adjustedDifficulty.OverallDifficulty;

hitwindow /= rate;

adjustedDifficulty.OverallDifficulty = (float)(80.0 - hitwindow) / 6;
var greatHitWindowRange = OsuHitWindows.OSU_RANGES.Single(range => range.Result == HitResult.Great);
double greatHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max);
greatHitWindow /= rate;
adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(greatHitWindow, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max);

return adjustedDifficulty;
}
Expand Down
4 changes: 2 additions & 2 deletions osu.Game.Rulesets.Osu/Scoring/OsuHitWindows.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public class OsuHitWindows : HitWindows
/// </summary>
public const double MISS_WINDOW = 400;

private static readonly DifficultyRange[] osu_ranges =
internal static readonly DifficultyRange[] OSU_RANGES =
{
new DifficultyRange(HitResult.Great, 80, 50, 20),
new DifficultyRange(HitResult.Ok, 140, 100, 60),
Expand All @@ -34,6 +34,6 @@ public override bool IsHitResultAllowed(HitResult result)
return false;
}

protected override DifficultyRange[] GetRanges() => osu_ranges;
protected override DifficultyRange[] GetRanges() => OSU_RANGES;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// 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.Collections.Generic;
using NUnit.Framework;
using osu.Game.Beatmaps;

namespace osu.Game.Rulesets.Taiko.Tests
{
[TestFixture]
public class TaikoRateAdjustedDisplayDifficultyTest
{
private static IEnumerable<float> difficultyValuesToTest()
{
for (float i = 0; i <= 10; i += 0.5f)
yield return i;
}

[TestCaseSource(nameof(difficultyValuesToTest))]
public void TestOverallDifficultyIsUnchangedWithRateEqualToOne(float originalOverallDifficulty)
{
var ruleset = new TaikoRuleset();
var difficulty = new BeatmapDifficulty { OverallDifficulty = originalOverallDifficulty };

var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 1);

Assert.That(adjustedDifficulty.OverallDifficulty, Is.EqualTo(originalOverallDifficulty));
}

[Test]
public void TestRateBelowOne()
{
var ruleset = new TaikoRuleset();
var difficulty = new BeatmapDifficulty();

var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 0.75);

Assert.That(adjustedDifficulty.OverallDifficulty, Is.EqualTo(1.11).Within(0.01));
}

[Test]
public void TestRateAboveOne()
{
var ruleset = new TaikoRuleset();
var difficulty = new BeatmapDifficulty();

var adjustedDifficulty = ruleset.GetRateAdjustedDisplayDifficulty(difficulty, 1.5);

Assert.That(adjustedDifficulty.OverallDifficulty, Is.EqualTo(8.89).Within(0.01));
}
}
}
4 changes: 2 additions & 2 deletions osu.Game.Rulesets.Taiko/Scoring/TaikoHitWindows.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace osu.Game.Rulesets.Taiko.Scoring
{
public class TaikoHitWindows : HitWindows
{
private static readonly DifficultyRange[] taiko_ranges =
internal static readonly DifficultyRange[] TAIKO_RANGES =
{
new DifficultyRange(HitResult.Great, 50, 35, 20),
new DifficultyRange(HitResult.Ok, 120, 80, 50),
Expand All @@ -27,6 +27,6 @@ public override bool IsHitResultAllowed(HitResult result)
return false;
}

protected override DifficultyRange[] GetRanges() => taiko_ranges;
protected override DifficultyRange[] GetRanges() => TAIKO_RANGES;
}
}
10 changes: 5 additions & 5 deletions osu.Game.Rulesets.Taiko/TaikoRuleset.cs
Original file line number Diff line number Diff line change
Expand Up @@ -265,15 +265,15 @@ public override StatisticItem[] CreateStatisticsForScore(ScoreInfo score, IBeatm
};
}

/// <seealso cref="TaikoHitWindows"/>
public override BeatmapDifficulty GetRateAdjustedDisplayDifficulty(IBeatmapDifficultyInfo difficulty, double rate)
{
BeatmapDifficulty adjustedDifficulty = new BeatmapDifficulty(difficulty);

double hitWindow = 35.0 - 15.0 * (adjustedDifficulty.OverallDifficulty - 5) / 5;

hitWindow /= rate;

adjustedDifficulty.OverallDifficulty = (float)(5 * (35 - hitWindow) / 15 + 5);
var greatHitWindowRange = TaikoHitWindows.TAIKO_RANGES.Single(range => range.Result == HitResult.Great);
double greatHitWindow = IBeatmapDifficultyInfo.DifficultyRange(adjustedDifficulty.OverallDifficulty, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max);
greatHitWindow /= rate;
adjustedDifficulty.OverallDifficulty = (float)IBeatmapDifficultyInfo.InverseDifficultyRange(greatHitWindow, greatHitWindowRange.Min, greatHitWindowRange.Average, greatHitWindowRange.Max);

return adjustedDifficulty;
}
Expand Down
18 changes: 18 additions & 0 deletions osu.Game/Beatmaps/IBeatmapDifficultyInfo.cs
Original file line number Diff line number Diff line change
@@ -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;

namespace osu.Game.Beatmaps
{
/// <summary>
Expand Down Expand Up @@ -92,5 +94,21 @@ static double DifficultyRange(double difficulty, double min, double mid, double
/// <returns>Value to which the difficulty value maps in the specified range.</returns>
static double DifficultyRange(double difficulty, (double od0, double od5, double od10) range)
=> DifficultyRange(difficulty, range.od0, range.od5, range.od10);

/// <summary>
/// Inverse function to <see cref="DifficultyRange(double,double,double,double)"/>.
/// Maps a value returned by the function above back to the difficulty that produced it.
/// </summary>
/// <param name="difficultyValue">The difficulty-dependent value to be unmapped.</param>
/// <param name="diff0">Minimum of the resulting range which will be achieved by a difficulty value of 0.</param>
/// <param name="diff5">Midpoint of the resulting range which will be achieved by a difficulty value of 5.</param>
/// <param name="diff10">Maximum of the resulting range which will be achieved by a difficulty value of 10.</param>
/// <returns>Value to which the difficulty value maps in the specified range.</returns>
static double InverseDifficultyRange(double difficultyValue, double diff0, double diff5, double diff10)
{
return Math.Sign(difficultyValue - diff5) == Math.Sign(diff10 - diff5)
? (difficultyValue - diff5) / (diff10 - diff5) * 5 + 5
: (difficultyValue - diff5) / (diff5 - diff0) * 5 + 5;
}
}
}