Skip to content

Commit

Permalink
Punish speed PP for scores with high deviation (#30907)
Browse files Browse the repository at this point in the history
  • Loading branch information
Givikap120 authored Jan 9, 2025
1 parent db58ec8 commit b21c645
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 10 deletions.
31 changes: 25 additions & 6 deletions osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,21 +62,33 @@ public class OsuDifficultyAttributes : DifficultyAttributes
/// <summary>
/// The perceived approach rate inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
/// <remarks>
/// Rate-adjusting mods don't directly affect the approach rate difficulty value, but have a perceived effect as a result of adjusting audio timing.
/// </remarks>
[JsonProperty("approach_rate")]
public double ApproachRate { get; set; }

/// <summary>
/// The perceived overall difficulty inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
/// <remarks>
/// Rate-adjusting mods don't directly affect the overall difficulty value, but have a perceived effect as a result of adjusting audio timing.
/// </remarks>
[JsonProperty("overall_difficulty")]
public double OverallDifficulty { get; set; }

/// <summary>
/// The perceived hit window for a GREAT hit inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
[JsonProperty("great_hit_window")]
public double GreatHitWindow { get; set; }

/// <summary>
/// The perceived hit window for an OK hit inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
[JsonProperty("ok_hit_window")]
public double OkHitWindow { get; set; }

/// <summary>
/// The perceived hit window for a MEH hit inclusive of rate-adjusting mods (DT/HT/etc).
/// </summary>
[JsonProperty("meh_hit_window")]
public double MehHitWindow { get; set; }

/// <summary>
/// The beatmap's drain rate. This doesn't scale with rate-adjusting mods.
/// </summary>
Expand Down Expand Up @@ -107,6 +119,7 @@ public class OsuDifficultyAttributes : DifficultyAttributes
yield return (ATTRIB_ID_OVERALL_DIFFICULTY, OverallDifficulty);
yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate);
yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow);

if (ShouldSerializeFlashlightDifficulty())
yield return (ATTRIB_ID_FLASHLIGHT, FlashlightDifficulty);
Expand All @@ -117,6 +130,9 @@ public class OsuDifficultyAttributes : DifficultyAttributes
yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount);
yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount);
yield return (ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT, AimDifficultSliderCount);

yield return (ATTRIB_ID_OK_HIT_WINDOW, OkHitWindow);
yield return (ATTRIB_ID_MEH_HIT_WINDOW, MehHitWindow);
}

public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
Expand All @@ -128,12 +144,15 @@ public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> val
OverallDifficulty = values[ATTRIB_ID_OVERALL_DIFFICULTY];
ApproachRate = values[ATTRIB_ID_APPROACH_RATE];
StarRating = values[ATTRIB_ID_DIFFICULTY];
GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW];
FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT);
SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR];
AimDifficultStrainCount = values[ATTRIB_ID_AIM_DIFFICULT_STRAIN_COUNT];
SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT];
SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT];
AimDifficultSliderCount = values[ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT];
OkHitWindow = values[ATTRIB_ID_OK_HIT_WINDOW];
MehHitWindow = values[ATTRIB_ID_MEH_HIT_WINDOW];
DrainRate = onlineInfo.DrainRate;
HitCircleCount = onlineInfo.CircleCount;
SliderCount = onlineInfo.SliderCount;
Expand Down
5 changes: 5 additions & 0 deletions osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);

double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate;
double hitWindowOk = hitWindows.WindowFor(HitResult.Ok) / clockRate;
double hitWindowMeh = hitWindows.WindowFor(HitResult.Meh) / clockRate;

OsuDifficultyAttributes attributes = new OsuDifficultyAttributes
{
Expand All @@ -114,6 +116,9 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat
SpeedDifficultStrainCount = speedDifficultyStrainCount,
ApproachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5,
OverallDifficulty = (80 - hitWindowGreat) / 6,
GreatHitWindow = hitWindowGreat,
OkHitWindow = hitWindowOk,
MehHitWindow = hitWindowMeh,
DrainRate = drainRate,
MaxCombo = beatmap.GetMaxCombo(),
HitCircleCount = hitCirclesCount,
Expand Down
3 changes: 3 additions & 0 deletions osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ public class OsuPerformanceAttributes : PerformanceAttributes
[JsonProperty("effective_miss_count")]
public double EffectiveMissCount { get; set; }

[JsonProperty("speed_deviation")]
public double? SpeedDeviation { get; set; }

public override IEnumerable<PerformanceDisplayAttribute> GetAttributesForDisplay()
{
foreach (var attribute in base.GetAttributesForDisplay())
Expand Down
119 changes: 115 additions & 4 deletions osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Utils;

namespace osu.Game.Rulesets.Osu.Difficulty
{
Expand Down Expand Up @@ -40,6 +41,8 @@ public class OsuPerformanceCalculator : PerformanceCalculator
/// </summary>
private double effectiveMissCount;

private double? speedDeviation;

public OsuPerformanceCalculator()
: base(new OsuRuleset())
{
Expand Down Expand Up @@ -110,10 +113,13 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s
effectiveMissCount = Math.Min(effectiveMissCount + countOk * okMultiplier + countMeh * mehMultiplier, totalHits);
}

speedDeviation = calculateSpeedDeviation(osuAttributes);

double aimValue = computeAimValue(score, osuAttributes);
double speedValue = computeSpeedValue(score, osuAttributes);
double accuracyValue = computeAccuracyValue(score, osuAttributes);
double flashlightValue = computeFlashlightValue(score, osuAttributes);

double totalValue =
Math.Pow(
Math.Pow(aimValue, 1.1) +
Expand All @@ -129,6 +135,7 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s
Accuracy = accuracyValue,
Flashlight = flashlightValue,
EffectiveMissCount = effectiveMissCount,
SpeedDeviation = speedDeviation,
Total = totalValue
};
}
Expand Down Expand Up @@ -198,7 +205,7 @@ private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attribut

private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attributes)
{
if (score.Mods.Any(h => h is OsuModRelax))
if (score.Mods.Any(h => h is OsuModRelax) || speedDeviation == null)
return 0.0;

double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
Expand Down Expand Up @@ -230,6 +237,9 @@ private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attrib
speedValue *= 1.0 + 0.04 * (12.0 - attributes.ApproachRate);
}

double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes);
speedValue *= speedHighDeviationMultiplier;

// Calculate accuracy assuming the worst case scenario
double relevantTotalDiff = totalHits - attributes.SpeedNoteCount;
double relevantCountGreat = Math.Max(0, countGreat - relevantTotalDiff);
Expand All @@ -240,9 +250,6 @@ private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attrib
// Scale the speed value with accuracy and OD.
speedValue *= (0.95 + Math.Pow(Math.Max(0, attributes.OverallDifficulty), 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - attributes.OverallDifficulty) / 2);

// Scale the speed value with # of 50s to punish doubletapping.
speedValue *= Math.Pow(0.99, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0);

return speedValue;
}

Expand Down Expand Up @@ -310,12 +317,116 @@ private double computeFlashlightValue(ScoreInfo score, OsuDifficultyAttributes a
return flashlightValue;
}

/// <summary>
/// Estimates player's deviation on speed notes using <see cref="calculateDeviation"/>, assuming worst-case.
/// Treats all speed notes as hit circles.
/// </summary>
private double? calculateSpeedDeviation(OsuDifficultyAttributes attributes)
{
if (totalSuccessfulHits == 0)
return null;

// Calculate accuracy assuming the worst case scenario
double speedNoteCount = attributes.SpeedNoteCount;
speedNoteCount += (totalHits - attributes.SpeedNoteCount) * 0.1;

// Assume worst case: all mistakes were on speed notes
double relevantCountMiss = Math.Min(countMiss, speedNoteCount);
double relevantCountMeh = Math.Min(countMeh, speedNoteCount - relevantCountMiss);
double relevantCountOk = Math.Min(countOk, speedNoteCount - relevantCountMiss - relevantCountMeh);
double relevantCountGreat = Math.Max(0, speedNoteCount - relevantCountMiss - relevantCountMeh - relevantCountOk);

return calculateDeviation(attributes, relevantCountGreat, relevantCountOk, relevantCountMeh, relevantCountMiss);
}

/// <summary>
/// Estimates the player's tap deviation based on the OD, given number of greats, oks, mehs and misses,
/// assuming the player's mean hit error is 0. The estimation is consistent in that two SS scores on the same map with the same settings
/// will always return the same deviation. Misses are ignored because they are usually due to misaiming.
/// Greats and oks are assumed to follow a normal distribution, whereas mehs are assumed to follow a uniform distribution.
/// </summary>
private double? calculateDeviation(OsuDifficultyAttributes attributes, double relevantCountGreat, double relevantCountOk, double relevantCountMeh, double relevantCountMiss)
{
if (relevantCountGreat + relevantCountOk + relevantCountMeh <= 0)
return null;

double objectCount = relevantCountGreat + relevantCountOk + relevantCountMeh + relevantCountMiss;

double hitWindowGreat = attributes.GreatHitWindow;
double hitWindowOk = attributes.OkHitWindow;
double hitWindowMeh = attributes.MehHitWindow;

// The probability that a player hits a circle is unknown, but we can estimate it to be
// the number of greats on circles divided by the number of circles, and then add one
// to the number of circles as a bias correction.
double n = Math.Max(1, objectCount - relevantCountMiss - relevantCountMeh);
const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed).

// Proportion of greats hit on circles, ignoring misses and 50s.
double p = relevantCountGreat / n;

// We can be 99% confident that p is at least this value.
double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4);

// Compute the deviation assuming greats and oks are normally distributed, and mehs are uniformly distributed.
// Begin with greats and oks first. Ignoring mehs, we can be 99% confident that the deviation is not higher than:
double deviation = hitWindowGreat / (Math.Sqrt(2) * SpecialFunctions.ErfInv(pLowerBound));

double randomValue = Math.Sqrt(2 / Math.PI) * hitWindowOk * Math.Exp(-0.5 * Math.Pow(hitWindowOk / deviation, 2))
/ (deviation * SpecialFunctions.Erf(hitWindowOk / (Math.Sqrt(2) * deviation)));

deviation *= Math.Sqrt(1 - randomValue);

// Value deviation approach as greatCount approaches 0
double limitValue = hitWindowOk / Math.Sqrt(3);

// If precision is not enough to compute true deviation - use limit value
if (pLowerBound == 0 || randomValue >= 1 || deviation > limitValue)
deviation = limitValue;

// Then compute the variance for mehs.
double mehVariance = (hitWindowMeh * hitWindowMeh + hitWindowOk * hitWindowMeh + hitWindowOk * hitWindowOk) / 3;

// Find the total deviation.
deviation = Math.Sqrt(((relevantCountGreat + relevantCountOk) * Math.Pow(deviation, 2) + relevantCountMeh * mehVariance) / (relevantCountGreat + relevantCountOk + relevantCountMeh));

return deviation;
}

// Calculates multiplier for speed to account for improper tapping based on the deviation and speed difficulty
// https://www.desmos.com/calculator/dmogdhzofn
private double calculateSpeedHighDeviationNerf(OsuDifficultyAttributes attributes)
{
if (speedDeviation == null)
return 0;

double speedValue = OsuStrainSkill.DifficultyToPerformance(attributes.SpeedDifficulty);

// Decides a point where the PP value achieved compared to the speed deviation is assumed to be tapped improperly. Any PP above this point is considered "excess" speed difficulty.
// This is used to cause PP above the cutoff to scale logarithmically towards the original speed value thus nerfing the value.
double excessSpeedDifficultyCutoff = 100 + 220 * Math.Pow(22 / speedDeviation.Value, 6.5);

if (speedValue <= excessSpeedDifficultyCutoff)
return 1.0;

const double scale = 50;
double adjustedSpeedValue = scale * (Math.Log((speedValue - excessSpeedDifficultyCutoff) / scale + 1) + excessSpeedDifficultyCutoff / scale);

// 200 UR and less are considered tapped correctly to ensure that normal scores will be punished as little as possible
double lerp = 1 - Math.Clamp((speedDeviation.Value - 20) / (24 - 20), 0, 1);
adjustedSpeedValue = double.Lerp(adjustedSpeedValue, speedValue, lerp);

return adjustedSpeedValue / speedValue;
}

// Miss penalty assumes that a player will miss on the hardest parts of a map,
// so we use the amount of relatively difficult sections to adjust miss penalty
// to make it more punishing on maps with lower amount of hard sections.
private double calculateMissPenalty(double missCount, double difficultStrainCount) => 0.96 / ((missCount / (4 * Math.Pow(Math.Log(difficultStrainCount), 0.94))) + 1);
private double getComboScalingFactor(OsuDifficultyAttributes attributes) => attributes.MaxCombo <= 0 ? 1.0 : Math.Min(Math.Pow(scoreMaxCombo, 0.8) / Math.Pow(attributes.MaxCombo, 0.8), 1.0);

private int totalHits => countGreat + countOk + countMeh + countMiss;
private int totalSuccessfulHits => countGreat + countOk + countMeh;
private int totalImperfectHits => countOk + countMeh + countMiss;
}
}
1 change: 1 addition & 0 deletions osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public class DifficultyAttributes
protected const int ATTRIB_ID_OK_HIT_WINDOW = 27;
protected const int ATTRIB_ID_MONO_STAMINA_FACTOR = 29;
protected const int ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT = 31;
protected const int ATTRIB_ID_MEH_HIT_WINDOW = 33;

/// <summary>
/// The mods which were applied to the beatmap.
Expand Down

0 comments on commit b21c645

Please sign in to comment.