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

Implement a bunch of rhythm difficulty calculation fixes #28871

Merged
merged 34 commits into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
e25642b
Implement a bunch of rhythm difficulty calculation fixes
stanriders Jul 15, 2024
67cb4a2
InspectCode
stanriders Jul 15, 2024
bae9625
Make repetition nerf harsher, buff initial rhythm ratio, small refact…
stanriders Jul 19, 2024
c1532bc
Reduce base ratio a bit
stanriders Jul 19, 2024
3acd00b
Tweak some values
stanriders Aug 10, 2024
f1adc6f
Don't cap max island size, make repetition nerf more lenient on high …
stanriders Aug 22, 2024
ce8286d
Scale difficulty with doubletapness, make kicksliders not reduce the …
stanriders Aug 23, 2024
ed45c94
Adjust max history by clockrate to make rhythm calculation more consi…
stanriders Aug 27, 2024
7fda8bc
Reduce repetition nerf for non-consecutive islands
stanriders Aug 27, 2024
3398497
Remove kickslider changes
stanriders Sep 11, 2024
d544498
Merge branch 'master' into rhythm-fixes
stanriders Sep 12, 2024
5e8174f
Reduce ratio for island size 1
stanriders Sep 14, 2024
db626f1
Refactor
stanriders Sep 14, 2024
738d4bc
Reduce ratio if we're starting island counting after a slider
stanriders Sep 14, 2024
863a74f
Balancing
stanriders Sep 14, 2024
145731b
More balancing
stanriders Sep 14, 2024
bee18b0
Reduce history max overall instead of using clock rate
stanriders Sep 15, 2024
c9ce7d2
Adjust multipliers
stanriders Sep 15, 2024
0bad5e4
Move slider-related ratio multiplier out of the delta switch block, a…
stanriders Sep 18, 2024
732a114
Slight refactoring
stanriders Sep 19, 2024
202364b
Use `List` instead of `Dictionary` for island counting, minor adjustm…
stanriders Sep 19, 2024
e986a7d
Fix repetition nerf not updating the counts
stanriders Sep 19, 2024
e04b88a
Replace speed buff with aim buff
stanriders Sep 23, 2024
6ed151c
Merge branch 'master' into rhythm-fixes
stanriders Sep 23, 2024
08bded8
Remove GetHashCode
stanriders Sep 23, 2024
75dc822
Adjust some multipliers
stanriders Sep 24, 2024
872628b
Some extra tweaking
stanriders Sep 24, 2024
fe8b953
Bring back some old nerfs as balancing factor
stanriders Sep 25, 2024
f4055d9
increase aim skill multiplier
tsunyoku Sep 25, 2024
d4a00d7
Update osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs
stanriders Oct 4, 2024
6507e3e
Merge branch 'master' into rhythm-fixes
bdach Oct 7, 2024
f47b8d5
Merge branch 'slight-rebalance' into rhythm-fixes
bdach Oct 7, 2024
9508b0e
Fix tests
bdach Oct 7, 2024
5a49017
Merge branch 'master' into rhythm-fixes
bdach Oct 7, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions osu.Game.Rulesets.Osu.Tests/OsuDifficultyCalculatorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,21 @@ public class OsuDifficultyCalculatorTest : DifficultyCalculatorTest
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests";

[TestCase(6.7154251995274938d, 239, "diffcalc-test")]
[TestCase(1.4430610657612626d, 54, "zero-length-sliders")]
[TestCase(6.7171144000821119d, 239, "diffcalc-test")]
[TestCase(1.4485749025771304d, 54, "zero-length-sliders")]
[TestCase(0.42630400627180914d, 4, "very-fast-slider")]
[TestCase(0.14143808967817237d, 2, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name);

[TestCase(8.9808183779700208d, 239, "diffcalc-test")]
[TestCase(1.7483507893412422d, 54, "zero-length-sliders")]
[TestCase(8.9825709931204205d, 239, "diffcalc-test")]
[TestCase(1.7550169162648608d, 54, "zero-length-sliders")]
[TestCase(0.55231632896800109d, 4, "very-fast-slider")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());

[TestCase(6.7154251995274938d, 239, "diffcalc-test")]
[TestCase(1.4430610657612626d, 54, "zero-length-sliders")]
[TestCase(6.7171144000821119d, 239, "diffcalc-test")]
[TestCase(1.4485749025771304d, 54, "zero-length-sliders")]
[TestCase(0.42630400627180914d, 4, "very-fast-slider")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());
Expand Down
178 changes: 146 additions & 32 deletions osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
Expand All @@ -10,8 +12,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{
public static class RhythmEvaluator
{
private const int history_time_max = 5000; // 5 seconds of calculatingRhythmBonus max.
private const double rhythm_multiplier = 0.75;
private const int history_time_max = 5 * 1000; // 5 seconds
private const int history_objects_max = 32;
private const double rhythm_overall_multiplier = 0.95;
private const double rhythm_ratio_multiplier = 12.0;

/// <summary>
/// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current <see cref="OsuDifficultyHitObject"/>.
Expand All @@ -21,15 +25,22 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current)
if (current.BaseObject is Spinner)
return 0;

int previousIslandSize = 0;

double rhythmComplexitySum = 0;
int islandSize = 1;

double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3;

var island = new Island(deltaDifferenceEpsilon);
var previousIsland = new Island(deltaDifferenceEpsilon);

// we can't use dictionary here because we need to compare island with a tolerance
// which is impossible to pass into the hash comparer
var islandCounts = new List<(Island Island, int Count)>();

double startRatio = 0; // store the ratio of the current start of an island to buff for tighter rhythms

bool firstDeltaSwitch = false;

int historicalNoteCount = Math.Min(current.Index, 32);
int historicalNoteCount = Math.Min(current.Index, history_objects_max);

int rhythmStart = 0;

Expand All @@ -39,74 +50,177 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current)
OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)current.Previous(rhythmStart);
OsuDifficultyHitObject lastObj = (OsuDifficultyHitObject)current.Previous(rhythmStart + 1);

// we go from the furthest object back to the current one
for (int i = rhythmStart; i > 0; i--)
{
OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)current.Previous(i - 1);

double currHistoricalDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; // scales note 0 to 1 from history to now
// scales note 0 to 1 from history to now
double timeDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max;
double noteDecay = (double)(historicalNoteCount - i) / historicalNoteCount;

currHistoricalDecay = Math.Min((double)(historicalNoteCount - i) / historicalNoteCount, currHistoricalDecay); // either we're limited by time or limited by object count.
double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count.

double currDelta = currObj.StrainTime;
double prevDelta = prevObj.StrainTime;
double lastDelta = lastObj.StrainTime;
double currRatio = 1.0 + 6.0 * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / (Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta))), 2)); // fancy function to calculate rhythmbonuses.

double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - currObj.HitWindowGreat * 0.3) / (currObj.HitWindowGreat * 0.3));
// calculate how much current delta difference deserves a rhythm bonus
// this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200)
double deltaDifferenceRatio = Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta);
double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / deltaDifferenceRatio), 2));

// reduce ratio bonus if delta difference is too big
double fraction = Math.Max(prevDelta / currDelta, currDelta / prevDelta);
double fractionMultiplier = Math.Clamp(2.0 - fraction / 8.0, 0.0, 1.0);

windowPenalty = Math.Min(1, windowPenalty);
double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);

double effectiveRatio = windowPenalty * currRatio;
double effectiveRatio = windowPenalty * currRatio * fractionMultiplier;

if (firstDeltaSwitch)
{
if (!(prevDelta > 1.25 * currDelta || prevDelta * 1.25 < currDelta))
if (Math.Abs(prevDelta - currDelta) < deltaDifferenceEpsilon)
{
if (islandSize < 7)
islandSize++; // island is still progressing, count size.
// island is still progressing
island.AddDelta((int)currDelta);
}
else
{
if (currObj.BaseObject is Slider) // bpm change is into slider, this is easy acc window
// bpm change is into slider, this is easy acc window
if (currObj.BaseObject is Slider)
effectiveRatio *= 0.125;

if (prevObj.BaseObject is Slider) // bpm change was from a slider, this is easier typically than circle -> circle
effectiveRatio *= 0.25;
// bpm change was from a slider, this is easier typically than circle -> circle
// unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders
if (prevObj.BaseObject is Slider)
effectiveRatio *= 0.3;

if (previousIslandSize == islandSize) // repeated island size (ex: triplet -> triplet)
effectiveRatio *= 0.25;
// repeated island polarity (2 -> 4, 3 -> 5)
if (island.IsSimilarPolarity(previousIsland))
effectiveRatio *= 0.5;

if (previousIslandSize % 2 == islandSize % 2) // repeated island polartiy (2 -> 4, 3 -> 5)
effectiveRatio *= 0.50;

if (lastDelta > prevDelta + 10 && prevDelta > currDelta + 10) // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this.
// previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this.
if (lastDelta > prevDelta + deltaDifferenceEpsilon && prevDelta > currDelta + deltaDifferenceEpsilon)
effectiveRatio *= 0.125;

rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay * Math.Sqrt(4 + islandSize) / 2 * Math.Sqrt(4 + previousIslandSize) / 2;
// repeated island size (ex: triplet -> triplet)
// TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation
if (previousIsland.DeltaCount == island.DeltaCount)
effectiveRatio *= 0.5;

var islandCount = islandCounts.FirstOrDefault(x => x.Island.Equals(island));

if (islandCount != default)
{
int countIndex = islandCounts.IndexOf(islandCount);

// only add island to island counts if they're going one after another
if (previousIsland.Equals(island))
islandCount.Count++;

// repeated island (ex: triplet -> triplet)
double power = logistic(island.Delta, 2.75, 0.24, 14);
effectiveRatio *= Math.Min(3.0 / islandCount.Count, Math.Pow(1.0 / islandCount.Count, power));

islandCounts[countIndex] = (islandCount.Island, islandCount.Count);
}
else
{
islandCounts.Add((island, 1));
}

// scale down the difficulty if the object is doubletappable
double doubletapness = prevObj.GetDoubletapness(currObj);
effectiveRatio *= 1 - doubletapness * 0.75;

rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay;

startRatio = effectiveRatio;

previousIslandSize = islandSize; // log the last island size.
previousIsland = island;

if (prevDelta * 1.25 < currDelta) // we're slowing down, stop counting
firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size.
if (prevDelta + deltaDifferenceEpsilon < currDelta) // we're slowing down, stop counting
firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size.

islandSize = 1;
island = new Island((int)currDelta, deltaDifferenceEpsilon);
}
}
else if (prevDelta > 1.25 * currDelta) // we want to be speeding up.
else if (prevDelta > currDelta + deltaDifferenceEpsilon) // we're speeding up
{
// Begin counting island until we change speed again.
firstDeltaSwitch = true;

// bpm change is into slider, this is easy acc window
if (currObj.BaseObject is Slider)
effectiveRatio *= 0.6;

// bpm change was from a slider, this is easier typically than circle -> circle
// unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty than bursts without sliders
if (prevObj.BaseObject is Slider)
effectiveRatio *= 0.6;

startRatio = effectiveRatio;
islandSize = 1;

island = new Island((int)currDelta, deltaDifferenceEpsilon);
}

lastObj = prevObj;
prevObj = currObj;
}

return Math.Sqrt(4 + rhythmComplexitySum * rhythm_multiplier) / 2; //produces multiplier that can be applied to strain. range [1, infinity) (not really though)
return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though)
}

private static double logistic(double x, double maxValue, double multiplier, double offset) => (maxValue / (1 + Math.Pow(Math.E, offset - (multiplier * x))));

private class Island : IEquatable<Island>
{
private readonly double deltaDifferenceEpsilon;

public Island(double epsilon)
{
deltaDifferenceEpsilon = epsilon;
}

public Island(int delta, double epsilon)
{
deltaDifferenceEpsilon = epsilon;
Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME);
DeltaCount++;
}

public int Delta { get; private set; } = int.MaxValue;
public int DeltaCount { get; private set; }

public void AddDelta(int delta)
{
if (Delta == int.MaxValue)
Delta = Math.Max(delta, OsuDifficultyHitObject.MIN_DELTA_TIME);

DeltaCount++;
}

public bool IsSimilarPolarity(Island other)
{
// TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple)
// naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation
return DeltaCount % 2 == other.DeltaCount % 2;
}

public bool Equals(Island? other)
{
if (other == null)
return false;

return Math.Abs(Delta - other.Delta) < deltaDifferenceEpsilon &&
DeltaCount == other.DeltaCount;
}

public override string ToString()
{
return $"{Delta}x{DeltaCount}";
}
}
}
}
14 changes: 1 addition & 13 deletions osu.Game.Rulesets.Osu/Difficulty/Evaluators/SpeedEvaluator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,21 +31,9 @@ public static double EvaluateDifficultyOf(DifficultyHitObject current)
// derive strainTime for calculation
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null;
var osuNextObj = (OsuDifficultyHitObject?)current.Next(0);

double strainTime = osuCurrObj.StrainTime;
double doubletapness = 1;

// Nerf doubletappable doubles.
if (osuNextObj != null)
{
double currDeltaTime = Math.Max(1, osuCurrObj.DeltaTime);
double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime);
double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime);
double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference);
double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / osuCurrObj.HitWindowGreat), 2);
doubletapness = Math.Pow(speedRatio, 1 - windowRatio);
}
double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0));

// Cap deltatime to the OD 300 hitwindow.
// 0.93 is derived from making sure 260bpm OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ public class OsuDifficultyHitObject : DifficultyHitObject
/// </summary>
public const int NORMALISED_RADIUS = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths.

private const int min_delta_time = 25;
public const int MIN_DELTA_TIME = 25;

private const float maximum_slider_radius = NORMALISED_RADIUS * 2.4f;
private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f;

Expand Down Expand Up @@ -93,7 +94,7 @@ public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObje
this.lastObject = (OsuHitObject)lastObject;

// Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
StrainTime = Math.Max(DeltaTime, min_delta_time);
StrainTime = Math.Max(DeltaTime, MIN_DELTA_TIME);

if (BaseObject is Slider sliderObject)
{
Expand Down Expand Up @@ -136,14 +137,32 @@ public double OpacityAt(double time, bool hidden)
return Math.Clamp((time - fadeInStartTime) / fadeInDuration, 0.0, 1.0);
}

/// <summary>
/// Returns how possible is it to doubletap this object together with the next one and get perfect judgement in range from 0 to 1
/// </summary>
public double GetDoubletapness(OsuDifficultyHitObject? osuNextObj)
{
if (osuNextObj != null)
{
double currDeltaTime = Math.Max(1, DeltaTime);
double nextDeltaTime = Math.Max(1, osuNextObj.DeltaTime);
double deltaDifference = Math.Abs(nextDeltaTime - currDeltaTime);
double speedRatio = currDeltaTime / Math.Max(currDeltaTime, deltaDifference);
double windowRatio = Math.Pow(Math.Min(1, currDeltaTime / HitWindowGreat), 2);
return 1.0 - Math.Pow(speedRatio, 1 - windowRatio);
}

return 0;
}

private void setDistances(double clockRate)
{
if (BaseObject is Slider currentSlider)
{
computeSliderCursorPosition(currentSlider);
// Bonus for repeat sliders until a better per nested object strain system can be achieved.
TravelDistance = currentSlider.LazyTravelDistance * (float)Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5);
TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, min_delta_time);
TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME);
}

// We don't need to calculate either angle or distance when one of the last->curr objects is a spinner
Expand All @@ -167,8 +186,8 @@ private void setDistances(double clockRate)

if (lastObject is Slider lastSlider)
{
double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time);
MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, min_delta_time);
double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME);
MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, MIN_DELTA_TIME);

//
// There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects.
Expand Down
Loading
Loading