mirror of
https://github.com/ppy/osu.git
synced 2026-05-26 03:09:54 +08:00
ea87e82a91
Right now they're summed normally what opens a problem that FL rewards too much pp when combined with reading map, since you're memorizing FL anyway. Co-authored-by: James Wilson <tsunyoku@gmail.com>
541 lines
27 KiB
C#
541 lines
27 KiB
C#
// 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.Framework.Extensions.IEnumerableExtensions;
|
|
using osu.Game.Rulesets.Difficulty.Utils;
|
|
using osu.Game.Rulesets.Mods;
|
|
using osu.Game.Rulesets.Osu.Scoring;
|
|
using osu.Game.Rulesets.Difficulty;
|
|
using osu.Game.Rulesets.Difficulty.Skills;
|
|
using osu.Game.Rulesets.Osu.Difficulty.Skills;
|
|
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
|
|
{
|
|
public class OsuPerformanceCalculator : PerformanceCalculator
|
|
{
|
|
public const double PERFORMANCE_BASE_MULTIPLIER = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
|
|
public const double PERFORMANCE_NORM_EXPONENT = 1.1;
|
|
|
|
private bool usingClassicSliderAccuracy;
|
|
private bool usingScoreV2;
|
|
|
|
private double accuracy;
|
|
private int scoreMaxCombo;
|
|
private int countGreat;
|
|
private int countOk;
|
|
private int countMeh;
|
|
private int countMiss;
|
|
|
|
/// <summary>
|
|
/// Missed slider ticks that includes missed reverse arrows. Will only be correct on non-classic scores
|
|
/// </summary>
|
|
private int countSliderTickMiss;
|
|
|
|
/// <summary>
|
|
/// Amount of missed slider tails that don't break combo. Will only be correct on non-classic scores
|
|
/// </summary>
|
|
private int countSliderEndsDropped;
|
|
|
|
/// <summary>
|
|
/// Estimated total amount of combo breaks
|
|
/// </summary>
|
|
private double effectiveMissCount;
|
|
|
|
private double clockRate;
|
|
private double greatHitWindow;
|
|
private double okHitWindow;
|
|
private double mehHitWindow;
|
|
|
|
private double overallDifficulty;
|
|
private double approachRate;
|
|
private double drainRate;
|
|
|
|
private double? speedDeviation;
|
|
|
|
private double aimEstimatedSliderBreaks;
|
|
private double speedEstimatedSliderBreaks;
|
|
|
|
public OsuPerformanceCalculator()
|
|
: base(new OsuRuleset())
|
|
{
|
|
}
|
|
|
|
protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo score, DifficultyAttributes attributes)
|
|
{
|
|
var osuAttributes = (OsuDifficultyAttributes)attributes;
|
|
|
|
usingClassicSliderAccuracy = score.Mods.OfType<OsuModClassic>().Any(m => m.NoSliderHeadAccuracy.Value);
|
|
usingScoreV2 = score.Mods.Any(m => m is ModScoreV2);
|
|
|
|
accuracy = score.Accuracy;
|
|
scoreMaxCombo = score.MaxCombo;
|
|
countGreat = score.Statistics.GetValueOrDefault(HitResult.Great);
|
|
countOk = score.Statistics.GetValueOrDefault(HitResult.Ok);
|
|
countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
|
|
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
|
|
countSliderEndsDropped = osuAttributes.SliderCount - score.Statistics.GetValueOrDefault(HitResult.SliderTailHit);
|
|
countSliderTickMiss = score.Statistics.GetValueOrDefault(HitResult.LargeTickMiss);
|
|
effectiveMissCount = countMiss;
|
|
|
|
var difficulty = score.BeatmapInfo!.Difficulty.Clone();
|
|
|
|
score.Mods.OfType<IApplicableToDifficulty>().ForEach(m => m.ApplyToDifficulty(difficulty));
|
|
|
|
clockRate = ModUtils.CalculateRateWithMods(score.Mods);
|
|
|
|
HitWindows hitWindows = new OsuHitWindows();
|
|
hitWindows.SetDifficulty(difficulty.OverallDifficulty);
|
|
|
|
greatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate;
|
|
okHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate;
|
|
mehHitWindow = hitWindows.WindowFor(HitResult.Meh) / clockRate;
|
|
|
|
approachRate = OsuDifficultyCalculator.CalculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate);
|
|
overallDifficulty = OsuDifficultyCalculator.CalculateRateAdjustedOverallDifficulty(difficulty.OverallDifficulty, clockRate);
|
|
drainRate = difficulty.DrainRate;
|
|
|
|
double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes);
|
|
double? scoreBasedEstimatedMissCount = null;
|
|
|
|
if (usingClassicSliderAccuracy && !usingScoreV2 && score.LegacyTotalScore != null)
|
|
{
|
|
var legacyScoreMissCalculator = new OsuLegacyScoreMissCalculator(score, osuAttributes);
|
|
scoreBasedEstimatedMissCount = legacyScoreMissCalculator.Calculate();
|
|
|
|
effectiveMissCount = scoreBasedEstimatedMissCount.Value;
|
|
}
|
|
else
|
|
{
|
|
// Use combo-based miss count if this isn't a legacy score
|
|
effectiveMissCount = comboBasedEstimatedMissCount;
|
|
}
|
|
|
|
effectiveMissCount = Math.Max(countMiss, effectiveMissCount);
|
|
effectiveMissCount = Math.Min(totalHits, effectiveMissCount);
|
|
|
|
double multiplier = PERFORMANCE_BASE_MULTIPLIER;
|
|
|
|
if (score.Mods.Any(m => m is OsuModNoFail))
|
|
multiplier *= Math.Max(0.90, 1.0 - 0.02 * effectiveMissCount);
|
|
|
|
if (score.Mods.Any(m => m is OsuModSpunOut) && totalHits > 0)
|
|
multiplier *= 1.0 - Math.Pow((double)osuAttributes.SpinnerCount / totalHits, 0.85);
|
|
|
|
if (score.Mods.Any(h => h is OsuModRelax))
|
|
{
|
|
// https://www.desmos.com/calculator/vspzsop6td
|
|
// we use OD13.3 as maximum since it's the value at which great hitwidow becomes 0
|
|
// this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11)
|
|
double okMultiplier = 0.75 * Math.Max(0.0, overallDifficulty > 0.0 ? 1 - overallDifficulty / 13.33 : 1.0);
|
|
double mehMultiplier = Math.Max(0.0, overallDifficulty > 0.0 ? 1 - Math.Pow(overallDifficulty / 13.33, 5) : 1.0);
|
|
|
|
// As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it.
|
|
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 readingValue = computeReadingValue(osuAttributes);
|
|
double flashlightValue = computeFlashlightValue(score, osuAttributes);
|
|
double cognitionValue = DifficultyCalculationUtils.Norm(2, readingValue, flashlightValue);
|
|
|
|
double totalValue = DifficultyCalculationUtils.Norm(PERFORMANCE_NORM_EXPONENT, aimValue, speedValue, accuracyValue, cognitionValue) * multiplier;
|
|
|
|
return new OsuPerformanceAttributes
|
|
{
|
|
Aim = aimValue,
|
|
Speed = speedValue,
|
|
Accuracy = accuracyValue,
|
|
Flashlight = flashlightValue,
|
|
Reading = readingValue,
|
|
EffectiveMissCount = effectiveMissCount,
|
|
ComboBasedEstimatedMissCount = comboBasedEstimatedMissCount,
|
|
ScoreBasedEstimatedMissCount = scoreBasedEstimatedMissCount,
|
|
AimEstimatedSliderBreaks = aimEstimatedSliderBreaks,
|
|
SpeedEstimatedSliderBreaks = speedEstimatedSliderBreaks,
|
|
SpeedDeviation = speedDeviation,
|
|
Total = totalValue
|
|
};
|
|
}
|
|
|
|
private double computeAimValue(ScoreInfo score, OsuDifficultyAttributes attributes)
|
|
{
|
|
if (score.Mods.Any(h => h is OsuModAutopilot))
|
|
return 0.0;
|
|
|
|
double aimDifficulty = attributes.AimDifficulty;
|
|
|
|
if (attributes.SliderCount > 0 && attributes.AimDifficultSliderCount > 0)
|
|
{
|
|
double estimateImproperlyFollowedDifficultSliders;
|
|
|
|
if (usingClassicSliderAccuracy)
|
|
{
|
|
// When the score is considered classic (regardless if it was made on old client or not) we consider all missing combo to be dropped difficult sliders
|
|
int maximumPossibleDroppedSliders = totalImperfectHits;
|
|
estimateImproperlyFollowedDifficultSliders = Math.Clamp(Math.Min(maximumPossibleDroppedSliders, attributes.MaxCombo - scoreMaxCombo), 0, attributes.AimDifficultSliderCount);
|
|
}
|
|
else
|
|
{
|
|
// We add tick misses here since they too mean that the player didn't follow the slider properly
|
|
// We however aren't adding misses here because missing slider heads has a harsh penalty by itself and doesn't mean that the rest of the slider wasn't followed properly
|
|
estimateImproperlyFollowedDifficultSliders = Math.Clamp(countSliderEndsDropped + countSliderTickMiss, 0, attributes.AimDifficultSliderCount);
|
|
}
|
|
|
|
double sliderNerfFactor = (1 - attributes.SliderFactor) * Math.Pow(1 - estimateImproperlyFollowedDifficultSliders / attributes.AimDifficultSliderCount, 3) + attributes.SliderFactor;
|
|
aimDifficulty *= sliderNerfFactor;
|
|
}
|
|
|
|
double aimValue = OsuStrainSkill.DifficultyToPerformance(aimDifficulty);
|
|
|
|
double lengthBonus = 0.95 + 0.3 * Math.Min(1.0, totalHits / 2000.0) +
|
|
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
|
|
aimValue *= lengthBonus;
|
|
|
|
if (effectiveMissCount > 0)
|
|
{
|
|
aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.AimTopWeightedSliderFactor, attributes);
|
|
|
|
double relevantMissCount = Math.Min(effectiveMissCount + aimEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss);
|
|
|
|
aimValue *= calculateMissPenalty(relevantMissCount, attributes.AimDifficultStrainCount);
|
|
}
|
|
|
|
// TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen.
|
|
if (score.Mods.Any(m => m is OsuModBlinds))
|
|
aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * drainRate * drainRate);
|
|
else if (score.Mods.Any(m => m is OsuModTraceable))
|
|
{
|
|
aimValue *= 1.0 + calculateTraceableBonus(attributes.SliderFactor);
|
|
}
|
|
|
|
aimValue *= accuracy;
|
|
|
|
return aimValue;
|
|
}
|
|
|
|
private double computeSpeedValue(ScoreInfo score, OsuDifficultyAttributes attributes)
|
|
{
|
|
if (score.Mods.Any(h => h is OsuModRelax) || speedDeviation == null)
|
|
return 0.0;
|
|
|
|
double speedValue = HarmonicSkill.DifficultyToPerformance(attributes.SpeedDifficulty);
|
|
|
|
if (effectiveMissCount > 0)
|
|
{
|
|
speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.SpeedTopWeightedSliderFactor, attributes);
|
|
|
|
double relevantMissCount = Math.Min(effectiveMissCount + speedEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss);
|
|
|
|
speedValue *= calculateMissPenalty(relevantMissCount, attributes.SpeedDifficultStrainCount);
|
|
}
|
|
|
|
// TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen.
|
|
if (score.Mods.Any(m => m is OsuModBlinds))
|
|
{
|
|
// Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given.
|
|
speedValue *= 1.12;
|
|
}
|
|
else if (score.Mods.Any(m => m is OsuModTraceable))
|
|
{
|
|
speedValue *= 1.0 + calculateTraceableBonus();
|
|
}
|
|
|
|
double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes);
|
|
speedValue *= speedHighDeviationMultiplier;
|
|
|
|
// An effective hit window is created based on the speed SR. The higher the speed difficulty, the shorter the hit window.
|
|
// For example, a speed SR of 3.0 leads to an effective hit window of 20ms, which is OD 10.
|
|
double effectiveHitWindow = Math.Sqrt(30 * 60 / attributes.SpeedDifficulty);
|
|
|
|
// Find the proportion of 300s on speed notes assuming the hit window was the effective hit window.
|
|
double effectiveAccuracy = DifficultyCalculationUtils.Erf(effectiveHitWindow / (double)speedDeviation);
|
|
|
|
// Scale speed value by normalized accuracy.
|
|
speedValue *= Math.Pow(effectiveAccuracy, 2);
|
|
|
|
return speedValue;
|
|
}
|
|
|
|
private double computeAccuracyValue(ScoreInfo score, OsuDifficultyAttributes attributes)
|
|
{
|
|
if (score.Mods.Any(h => h is OsuModRelax))
|
|
return 0.0;
|
|
|
|
// This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window.
|
|
double betterAccuracyPercentage;
|
|
int amountHitObjectsWithAccuracy = attributes.HitCircleCount;
|
|
if (!usingClassicSliderAccuracy || usingScoreV2)
|
|
amountHitObjectsWithAccuracy += attributes.SliderCount;
|
|
|
|
if (amountHitObjectsWithAccuracy > 0)
|
|
betterAccuracyPercentage = ((countGreat - Math.Max(totalHits - amountHitObjectsWithAccuracy, 0)) * 6 + countOk * 2 + countMeh) / (double)(amountHitObjectsWithAccuracy * 6);
|
|
else
|
|
betterAccuracyPercentage = 0;
|
|
|
|
// It is possible to reach a negative accuracy with this formula. Cap it at zero - zero points.
|
|
if (betterAccuracyPercentage < 0)
|
|
betterAccuracyPercentage = 0;
|
|
|
|
// Lots of arbitrary values from testing.
|
|
// Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution.
|
|
double accuracyValue = Math.Pow(1.52163, overallDifficulty) * Math.Pow(betterAccuracyPercentage, 24) * 2.83;
|
|
|
|
// Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
|
|
accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3));
|
|
|
|
// Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given.
|
|
if (score.Mods.Any(m => m is OsuModBlinds))
|
|
accuracyValue *= 1.14;
|
|
else if (score.Mods.Any(m => m is OsuModTraceable))
|
|
{
|
|
// Decrease bonus for AR > 10
|
|
accuracyValue *= 1 + 0.08 * DifficultyCalculationUtils.ReverseLerp(approachRate, 11.5, 10);
|
|
}
|
|
|
|
if (score.Mods.Any(m => m is OsuModFlashlight))
|
|
accuracyValue *= 1.02;
|
|
|
|
return accuracyValue;
|
|
}
|
|
|
|
private double computeFlashlightValue(ScoreInfo score, OsuDifficultyAttributes attributes)
|
|
{
|
|
if (!score.Mods.Any(h => h is OsuModFlashlight))
|
|
return 0.0;
|
|
|
|
double flashlightValue = Flashlight.DifficultyToPerformance(attributes.FlashlightDifficulty);
|
|
|
|
// Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
|
|
if (effectiveMissCount > 0)
|
|
flashlightValue *= 0.97 * Math.Pow(1 - Math.Pow(effectiveMissCount / totalHits, 0.775), Math.Pow(effectiveMissCount, .875));
|
|
|
|
flashlightValue *= getComboScalingFactor(attributes);
|
|
|
|
// Scale the flashlight value with accuracy _slightly_.
|
|
flashlightValue *= 0.5 + accuracy / 2.0;
|
|
|
|
return flashlightValue;
|
|
}
|
|
|
|
private double computeReadingValue(OsuDifficultyAttributes attributes)
|
|
{
|
|
double readingValue = HarmonicSkill.DifficultyToPerformance(attributes.ReadingDifficulty);
|
|
|
|
if (effectiveMissCount > 0)
|
|
readingValue *= calculateMissPenalty(effectiveMissCount + aimEstimatedSliderBreaks, attributes.ReadingDifficultNoteCount);
|
|
|
|
// Scale the reading value with accuracy _harshly_.
|
|
readingValue *= Math.Pow(accuracy, 3);
|
|
|
|
return readingValue;
|
|
}
|
|
|
|
private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes attributes)
|
|
{
|
|
if (attributes.SliderCount <= 0)
|
|
return countMiss;
|
|
|
|
double missCount = countMiss;
|
|
|
|
if (usingClassicSliderAccuracy)
|
|
{
|
|
// If sliders in the map are hard - it's likely for player to drop sliderends
|
|
// If map has easy sliders - it's more likely for player to sliderbreak
|
|
double likelyMissedSliderendPortion = 0.04 + 0.06 * Math.Pow(Math.Min(attributes.AimTopWeightedSliderFactor, 1), 2);
|
|
|
|
// Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it
|
|
// In classic scores we can't know the amount of dropped sliders so we estimate it
|
|
double fullComboThreshold = attributes.MaxCombo - Math.Min(4 + likelyMissedSliderendPortion * attributes.SliderCount, attributes.SliderCount);
|
|
|
|
if (scoreMaxCombo < fullComboThreshold)
|
|
missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
|
|
|
|
// In classic scores there can't be more misses than a sum of all non-perfect judgements
|
|
missCount = Math.Min(missCount, totalImperfectHits);
|
|
|
|
// Every slider has *at least* 2 combo attributed in classic mechanics.
|
|
// If they broke on a slider with a tick, then this still works since they would have lost at least 2 combo (the tick and the end)
|
|
// Using this as a max means a score that loses 1 combo on a map can't possibly have been a slider break.
|
|
// It must have been a slider end.
|
|
int maxPossibleSliderBreaks = Math.Min(attributes.SliderCount, (attributes.MaxCombo - scoreMaxCombo) / 2);
|
|
|
|
double sliderBreaks = missCount - countMiss;
|
|
|
|
if (sliderBreaks > maxPossibleSliderBreaks)
|
|
missCount = countMiss + maxPossibleSliderBreaks;
|
|
}
|
|
else
|
|
{
|
|
double fullComboThreshold = attributes.MaxCombo - countSliderEndsDropped;
|
|
|
|
if (scoreMaxCombo < fullComboThreshold)
|
|
missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
|
|
|
|
// Combine regular misses with tick misses since tick misses break combo as well
|
|
missCount = Math.Min(missCount, countSliderTickMiss + countMiss);
|
|
}
|
|
|
|
return missCount;
|
|
}
|
|
|
|
private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, OsuDifficultyAttributes attributes)
|
|
{
|
|
if (!usingClassicSliderAccuracy || countOk == 0)
|
|
return 0;
|
|
|
|
double missedComboPercent = 1.0 - (double)scoreMaxCombo / attributes.MaxCombo;
|
|
double estimatedSliderBreaks = Math.Min(countOk, effectiveMissCount * topWeightedSliderFactor);
|
|
|
|
// Scores with more Oks are more likely to have slider breaks.
|
|
double okAdjustment = ((countOk - estimatedSliderBreaks) + 0.5) / countOk;
|
|
|
|
// There is a low probability of extra slider breaks on effective miss counts close to 1, as score based calculations are good at indicating if only a single break occurred.
|
|
estimatedSliderBreaks *= DifficultyCalculationUtils.Smoothstep(effectiveMissCount, 1, 2);
|
|
|
|
return estimatedSliderBreaks * okAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15);
|
|
}
|
|
|
|
/// <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(relevantCountGreat, relevantCountOk, relevantCountMeh);
|
|
}
|
|
|
|
/// <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(double relevantCountGreat, double relevantCountOk, double relevantCountMeh)
|
|
{
|
|
if (relevantCountGreat + relevantCountOk + relevantCountMeh <= 0)
|
|
return null;
|
|
|
|
// The sample proportion of successful hits.
|
|
double n = Math.Max(1, relevantCountGreat + relevantCountOk);
|
|
double p = relevantCountGreat / n;
|
|
|
|
// 99% critical value for the normal distribution (one-tailed).
|
|
const double z = 2.32634787404;
|
|
|
|
// We can be 99% confident that the population proportion is at least this value.
|
|
double pLowerBound = Math.Min(p, (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4));
|
|
|
|
double deviation;
|
|
|
|
// Tested max precision for the deviation calculation.
|
|
if (pLowerBound > 0.01)
|
|
{
|
|
// Compute deviation assuming greats and oks are normally distributed.
|
|
deviation = greatHitWindow / (Math.Sqrt(2) * DifficultyCalculationUtils.ErfInv(pLowerBound));
|
|
|
|
// Subtract the deviation provided by tails that land outside the ok hit window from the deviation computed above.
|
|
// This is equivalent to calculating the deviation of a normal distribution truncated at +-okHitWindow.
|
|
double okHitWindowTailAmount = Math.Sqrt(2 / Math.PI) * okHitWindow * Math.Exp(-0.5 * Math.Pow(okHitWindow / deviation, 2))
|
|
/ (deviation * DifficultyCalculationUtils.Erf(okHitWindow / (Math.Sqrt(2) * deviation)));
|
|
|
|
deviation *= Math.Sqrt(1 - okHitWindowTailAmount);
|
|
}
|
|
else
|
|
{
|
|
// A tested limit value for the case of a score only containing oks.
|
|
deviation = okHitWindow / Math.Sqrt(3);
|
|
}
|
|
|
|
// Compute and add the variance for mehs, assuming that they are uniformly distributed.
|
|
double mehVariance = (mehHitWindow * mehHitWindow + okHitWindow * mehHitWindow + okHitWindow * okHitWindow) / 3;
|
|
|
|
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);
|
|
|
|
// 220 UR and less are considered tapped correctly to ensure that normal scores will be punished as little as possible
|
|
double lerp = 1 - DifficultyCalculationUtils.ReverseLerp(speedDeviation.Value, 22.0, 27.0);
|
|
adjustedSpeedValue = double.Lerp(adjustedSpeedValue, speedValue, lerp);
|
|
|
|
return adjustedSpeedValue / speedValue;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculates a visibility bonus that is applicable to Traceable.
|
|
/// </summary>
|
|
private double calculateTraceableBonus(double sliderFactor = 1)
|
|
{
|
|
// Start from normal curve, rewarding lower AR up to AR7
|
|
double traceableBonus = 0.025 * (12.0 - Math.Max(approachRate, 7));
|
|
|
|
// We want to reward slider aim on low AR less
|
|
double sliderVisibilityFactor = Math.Pow(sliderFactor, 3);
|
|
|
|
// For AR up to 0 - reduce reward for very low ARs when object is visible
|
|
if (approachRate < 7)
|
|
traceableBonus += 0.02 * (7.0 - Math.Max(approachRate, 0)) * sliderVisibilityFactor;
|
|
|
|
// Starting from AR0 - cap values so they won't grow to infinity
|
|
if (approachRate < 0)
|
|
traceableBonus += 0.01 * (1 - Math.Pow(1.5, approachRate)) * sliderVisibilityFactor;
|
|
|
|
return traceableBonus;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|