1
0
mirror of https://github.com/ppy/osu.git synced 2025-03-13 07:07:44 +08:00
osu-lazer/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs

Ignoring revisions in .git-blame-ignore-revs. Click here to bypass and see the normal blame view.

345 lines
17 KiB
C#
Raw Normal View History

// 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;
2023-01-27 10:41:47 -05:00
using MathNet.Numerics;
using MathNet.Numerics.Distributions;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Scoring;
2018-11-28 16:12:57 +09:00
using osu.Game.Scoring;
2023-04-15 23:23:30 -04:00
using Precision = osu.Framework.Utils.Precision;
namespace osu.Game.Rulesets.Mania.Difficulty
{
public class ManiaPerformanceCalculator : PerformanceCalculator
{
private const double tail_multiplier = 1.5; // Lazer LN tails have 1.5x the hit window of a Note or an LN head.
private const double tail_deviation_multiplier = 1.8; // Empirical testing shows that players get ~1.8x the deviation on tails.
2024-01-14 20:20:28 -05:00
// Multipliers for legacy LN hit windows. These are made slightly more lenient for some reason.
private const double legacy_max_multiplier = 1.2;
private const double legacy_300_multiplier = 1.1;
2018-05-16 12:44:11 +09:00
private int countPerfect;
private int countGreat;
private int countGood;
private int countOk;
private int countMeh;
private int countMiss;
2023-02-12 19:05:52 -05:00
private double? estimatedUr;
private bool isLegacyScore;
private double[] hitWindows = null!;
2023-08-07 21:35:28 +09:00
private bool isConvert;
2022-03-15 12:37:39 +09:00
public ManiaPerformanceCalculator()
: base(new ManiaRuleset())
{
}
2023-04-15 22:40:06 -04:00
public new ManiaPerformanceAttributes Calculate(ScoreInfo score, DifficultyAttributes attributes)
=> (ManiaPerformanceAttributes)CreatePerformanceAttributes(score, attributes);
protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo score, DifficultyAttributes attributes)
{
var maniaAttributes = (ManiaDifficultyAttributes)attributes;
2023-08-07 21:35:28 +09:00
isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 3;
countPerfect = score.Statistics.GetValueOrDefault(HitResult.Perfect);
countGreat = score.Statistics.GetValueOrDefault(HitResult.Great);
countGood = score.Statistics.GetValueOrDefault(HitResult.Good);
countOk = score.Statistics.GetValueOrDefault(HitResult.Ok);
countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
2023-04-15 23:23:30 -04:00
isLegacyScore = score.Mods.Any(m => m is ManiaModClassic) && !Precision.DefinitelyBigger(totalJudgements, maniaAttributes.NoteCount + maniaAttributes.HoldNoteCount);
2023-08-01 18:41:16 +08:00
hitWindows = isLegacyScore
2023-08-13 22:55:56 -04:00
? GetLegacyHitWindows(score.Mods, isConvert, maniaAttributes.OverallDifficulty)
2023-08-01 18:41:16 +08:00
: GetLazerHitWindows(score.Mods, maniaAttributes.OverallDifficulty);
estimatedUr = computeEstimatedUr(maniaAttributes.NoteCount, maniaAttributes.HoldNoteCount);
2018-05-16 01:34:07 +09:00
// Arbitrary initial value for scaling pp in order to standardize distributions across game modes.
// The specific number has no intrinsic meaning and can be adjusted as needed.
2022-06-17 23:38:22 +08:00
double multiplier = 8.0;
if (score.Mods.Any(m => m is ModNoFail))
2022-06-20 19:55:26 +08:00
multiplier *= 0.75;
if (score.Mods.Any(m => m is ModEasy))
multiplier *= 0.5;
double difficultyValue = computeDifficultyValue(maniaAttributes);
double totalValue = difficultyValue * multiplier;
2021-12-21 13:08:31 +03:00
return new ManiaPerformanceAttributes
{
2021-12-21 13:08:31 +03:00
Difficulty = difficultyValue,
2023-01-27 15:23:03 -05:00
Total = totalValue,
EstimatedUr = estimatedUr,
HitWindows = hitWindows
2021-12-21 13:08:31 +03:00
};
}
private double computeDifficultyValue(ManiaDifficultyAttributes attributes)
{
double difficultyValue = Math.Pow(Math.Max(attributes.StarRating - 0.15, 0.05), 2.2)
* (1 + 0.1 * Math.Min(1, (attributes.NoteCount + attributes.HoldNoteCount) / 1500.0)); // Star rating to pp curve
if (estimatedUr == null)
return 0;
double noteHeadPortion = (double)(attributes.NoteCount + attributes.HoldNoteCount) / (attributes.NoteCount + attributes.HoldNoteCount * 2);
double tailPortion = (double)attributes.HoldNoteCount / (attributes.NoteCount + attributes.HoldNoteCount * 2);
// We increased the deviation of tails for estimation accuracy, but for difficulty scaling we actually
// only care about the deviation on notes and heads, as that's the "accuracy skill" of the player.
2024-01-14 20:20:28 -05:00
// Increasing the tail multiplier will decrease this value, buffing plays with more LNs.
double noteUnstableRate = estimatedUr.Value / Math.Sqrt(noteHeadPortion + tailPortion * Math.Pow(tail_deviation_multiplier, 2));
2024-01-14 20:20:28 -05:00
difficultyValue *= Math.Max(1 - Math.Pow(noteUnstableRate / 500, 1.9), 0);
return difficultyValue;
}
private double totalJudgements => countPerfect + countOk + countGreat + countGood + countMeh + countMiss;
private double totalSuccessfulJudgements => countPerfect + countOk + countGreat + countGood + countMeh;
2022-06-18 12:20:47 +08:00
/// <summary>
/// Returns the estimated unstable rate of the score, assuming the average hit location is in the center of the hit window.
/// <exception cref="MathNet.Numerics.Optimization.MaximumIterationsException">
/// Thrown when the optimization algorithm fails to converge.
2024-01-14 20:20:28 -05:00
/// This will never happen in any sane (humanly achievable) case. When tested up to 100 Million misses, the algorithm converges with default settings.
/// </exception>
/// <returns>
/// Returns Estimated UR, or null if the score is a miss-only score.
/// </returns>
2022-06-18 12:20:47 +08:00
/// </summary>
private double? computeEstimatedUr(int noteCount, int holdNoteCount)
2022-10-10 13:36:52 -04:00
{
2023-07-31 19:27:18 +08:00
if (totalSuccessfulJudgements == 0 || noteCount + holdNoteCount == 0)
2023-02-12 19:05:52 -05:00
return null;
2022-10-10 13:36:52 -04:00
2023-07-31 19:27:18 +08:00
double noteHeadPortion = (double)(noteCount + holdNoteCount) / (noteCount + holdNoteCount * 2);
double tailPortion = (double)holdNoteCount / (noteCount + holdNoteCount * 2);
double likelihoodGradient(double d)
{
if (d <= 0)
2023-02-12 19:05:52 -05:00
return 0;
// Since tails have a higher deviation, find the deviation values for notes/heads and tails that average out to the final deviation value.
double dNote = d / Math.Sqrt(noteHeadPortion + tailPortion * Math.Pow(tail_deviation_multiplier, 2));
double dTail = dNote * tail_deviation_multiplier;
2024-01-14 20:20:28 -05:00
JudgementProbs pNotes = logJudgementProbsNote(dNote);
// Since lazer tails have the same hit behaviour as Notes, return pNote instead of pHold for them.
2024-01-14 20:20:28 -05:00
JudgementProbs pHolds = isLegacyScore ? logJudgementProbsLegacyHold(dNote, dTail) : logJudgementProbsNote(dTail, tail_multiplier);
2024-01-14 20:20:28 -05:00
return -calculateLikelihoodOfDeviation(pNotes, pHolds, noteCount, holdNoteCount);
}
2023-02-19 16:33:32 -05:00
// Finding the minimum of the function returns the most likely deviation for the hit results. UR is deviation * 10.
double deviation = FindMinimum.OfScalarFunction(likelihoodGradient, 30);
2023-02-19 16:33:32 -05:00
return deviation * 10;
2022-10-11 13:53:18 +09:00
}
2023-01-23 18:05:50 -05:00
2023-08-01 18:41:16 +08:00
public static double[] GetLegacyHitWindows(Mod[] mods, bool isConvert, double overallDifficulty)
2023-01-23 18:05:50 -05:00
{
double[] legacyHitWindows = new double[5];
2023-01-23 18:05:50 -05:00
double greatWindowLeniency = 0;
double goodWindowLeniency = 0;
2023-01-23 18:05:50 -05:00
2023-04-16 15:53:11 -04:00
// When converting beatmaps to osu!mania in stable, the resulting hit window sizes are dependent on whether the beatmap's OD is above or below 4.
2023-08-07 21:35:28 +09:00
if (isConvert)
{
2023-01-23 18:05:50 -05:00
overallDifficulty = 10;
2023-08-01 18:41:16 +08:00
if (overallDifficulty <= 4)
{
greatWindowLeniency = 13;
goodWindowLeniency = 10;
}
}
2023-01-23 18:05:50 -05:00
double windowMultiplier = 1;
2023-08-01 18:41:16 +08:00
if (mods.Any(m => m is ModHardRock))
2023-01-23 18:05:50 -05:00
windowMultiplier *= 1 / 1.4;
2023-08-01 18:41:16 +08:00
else if (mods.Any(m => m is ModEasy))
2023-01-23 18:05:50 -05:00
windowMultiplier *= 1.4;
legacyHitWindows[0] = Math.Floor(16 * windowMultiplier);
legacyHitWindows[1] = Math.Floor((64 - 3 * overallDifficulty + greatWindowLeniency) * windowMultiplier);
legacyHitWindows[2] = Math.Floor((97 - 3 * overallDifficulty + goodWindowLeniency) * windowMultiplier);
legacyHitWindows[3] = Math.Floor((127 - 3 * overallDifficulty) * windowMultiplier);
legacyHitWindows[4] = Math.Floor((151 - 3 * overallDifficulty) * windowMultiplier);
2023-01-23 18:05:50 -05:00
return legacyHitWindows;
2023-01-23 18:05:50 -05:00
}
2023-08-01 18:41:16 +08:00
public static double[] GetLazerHitWindows(Mod[] mods, double overallDifficulty)
2023-01-23 18:05:50 -05:00
{
double[] lazerHitWindows = new double[5];
2023-01-23 18:05:50 -05:00
double windowMultiplier = 1;
2023-01-23 18:05:50 -05:00
2023-08-01 18:41:16 +08:00
if (mods.Any(m => m is ModHardRock))
2023-01-23 18:05:50 -05:00
windowMultiplier *= 1 / 1.4;
2023-08-01 18:41:16 +08:00
else if (mods.Any(m => m is ModEasy))
2023-01-23 18:05:50 -05:00
windowMultiplier *= 1.4;
2023-08-01 18:41:16 +08:00
if (overallDifficulty < 5)
lazerHitWindows[0] = (22.4 - 0.6 * overallDifficulty) * windowMultiplier;
2023-01-23 18:05:50 -05:00
else
2023-08-01 18:41:16 +08:00
lazerHitWindows[0] = (24.9 - 1.1 * overallDifficulty) * windowMultiplier;
lazerHitWindows[1] = (64 - 3 * overallDifficulty) * windowMultiplier;
lazerHitWindows[2] = (97 - 3 * overallDifficulty) * windowMultiplier;
lazerHitWindows[3] = (127 - 3 * overallDifficulty) * windowMultiplier;
lazerHitWindows[4] = (151 - 3 * overallDifficulty) * windowMultiplier;
2023-01-23 18:05:50 -05:00
return lazerHitWindows;
2023-01-23 18:05:50 -05:00
}
2023-01-27 10:41:47 -05:00
private struct JudgementProbs
{
public double PMax;
public double P300;
public double P200;
public double P100;
public double P50;
public double P0;
}
// Log Judgement Probabilities of a Note given a deviation.
// The multiplier is for lazer LN tails, which are 1.5x as lenient.
2024-01-14 20:20:28 -05:00
private JudgementProbs logJudgementProbsNote(double d, double multiplier = 1)
{
JudgementProbs probabilities = new JudgementProbs
{
2024-01-14 20:20:28 -05:00
PMax = logDiff(0, logCompProbHitNote(hitWindows[0] * multiplier, d)),
P300 = logDiff(logCompProbHitNote(hitWindows[0] * multiplier, d), logCompProbHitNote(hitWindows[1] * multiplier, d)),
P200 = logDiff(logCompProbHitNote(hitWindows[1] * multiplier, d), logCompProbHitNote(hitWindows[2] * multiplier, d)),
P100 = logDiff(logCompProbHitNote(hitWindows[2] * multiplier, d), logCompProbHitNote(hitWindows[3] * multiplier, d)),
P50 = logDiff(logCompProbHitNote(hitWindows[3] * multiplier, d), logCompProbHitNote(hitWindows[4] * multiplier, d)),
P0 = logCompProbHitNote(hitWindows[4] * multiplier, d)
};
return probabilities;
}
// Log Judgement Probabilities of a Legacy Hold given a deviation.
// This is only used for Legacy Holds, which has a different hit behaviour from Notes and lazer LNs.
2024-01-14 20:20:28 -05:00
private JudgementProbs logJudgementProbsLegacyHold(double dHead, double dTail)
{
2023-07-31 01:08:19 -04:00
JudgementProbs probabilities = new JudgementProbs
{
2024-01-14 20:20:28 -05:00
PMax = logDiff(0, logCompProbHitLegacyHold(hitWindows[0] * legacy_max_multiplier, dHead, dTail)),
P300 = logDiff(logCompProbHitLegacyHold(hitWindows[0] * legacy_max_multiplier, dHead, dTail), logCompProbHitLegacyHold(hitWindows[1] * legacy_300_multiplier, dHead, dTail)),
P200 = logDiff(logCompProbHitLegacyHold(hitWindows[1] * legacy_300_multiplier, dHead, dTail), logCompProbHitLegacyHold(hitWindows[2], dHead, dTail)),
P100 = logDiff(logCompProbHitLegacyHold(hitWindows[2], dHead, dTail), logCompProbHitLegacyHold(hitWindows[3], dHead, dTail)),
P50 = logDiff(logCompProbHitLegacyHold(hitWindows[3], dHead, dTail), logCompProbHitLegacyHold(hitWindows[4], dHead, dTail)),
P0 = logCompProbHitLegacyHold(hitWindows[4], dHead, dTail)
2023-07-31 01:08:19 -04:00
};
return probabilities;
}
2023-04-15 23:23:30 -04:00
/// <summary>
2024-01-14 20:20:28 -05:00
/// Combines the probability of getting each judgement on both note types into a single probability value for each judgement,
/// and compares them to the judgements of the play using a binomial likelihood formula.
2023-04-15 23:23:30 -04:00
/// </summary>
2024-01-14 20:20:28 -05:00
private double calculateLikelihoodOfDeviation(JudgementProbs noteProbabilities, JudgementProbs lnProbabilities, double noteCount, double lnCount)
{
2024-01-14 20:20:28 -05:00
// Lazer mechanics treat the heads of LNs like notes.
double noteProbCount = isLegacyScore ? noteCount : noteCount + lnCount;
double pMax = logSum(noteProbabilities.PMax + Math.Log(noteProbCount), lnProbabilities.PMax + Math.Log(lnCount)) - Math.Log(totalJudgements);
double p300 = logSum(noteProbabilities.P300 + Math.Log(noteProbCount), lnProbabilities.P300 + Math.Log(lnCount)) - Math.Log(totalJudgements);
double p200 = logSum(noteProbabilities.P200 + Math.Log(noteProbCount), lnProbabilities.P200 + Math.Log(lnCount)) - Math.Log(totalJudgements);
double p100 = logSum(noteProbabilities.P100 + Math.Log(noteProbCount), lnProbabilities.P100 + Math.Log(lnCount)) - Math.Log(totalJudgements);
double p50 = logSum(noteProbabilities.P50 + Math.Log(noteProbCount), lnProbabilities.P50 + Math.Log(lnCount)) - Math.Log(totalJudgements);
double p0 = logSum(noteProbabilities.P0 + Math.Log(noteProbCount), lnProbabilities.P0 + Math.Log(lnCount)) - Math.Log(totalJudgements);
double totalProb = Math.Exp(
(countPerfect * pMax
+ (countGreat + 0.5) * p300
+ countGood * p200
+ countOk * p100
+ countMeh * p50
+ countMiss * p0) / totalJudgements
);
return totalProb;
}
2023-04-15 23:23:30 -04:00
/// <summary>
2023-07-31 01:08:19 -04:00
/// The log complementary probability of getting a certain judgement with a certain deviation.
2023-04-15 23:23:30 -04:00
/// </summary>
/// <returns>
/// A value from 0 (log of 1, 0% chance) to negative infinity (log of 0, 100% chance).
/// </returns>
2024-01-14 20:20:28 -05:00
private double logCompProbHitNote(double window, double deviation) => logErfc(window / (deviation * Math.Sqrt(2)));
2023-02-19 16:33:32 -05:00
2023-04-15 23:23:30 -04:00
/// <summary>
2023-07-31 01:08:19 -04:00
/// The log complementary probability of getting a certain judgement with a certain deviation.
/// Exclusively for stable LNs, as they give a result from 2 error values (total error on the head + the tail).
2023-04-15 23:23:30 -04:00
/// </summary>
/// <returns>
/// A value from 0 (log of 1, 0% chance) to negative infinity (log of 0, 100% chance).
/// </returns>
2024-01-14 20:20:28 -05:00
private double logCompProbHitLegacyHold(double window, double headDeviation, double tailDeviation)
2023-07-31 01:08:19 -04:00
{
double root2 = Math.Sqrt(2);
double logPcHead = logErfc(window / (headDeviation * root2));
// Calculate the expected value of the distance from 0 of the head hit, given it lands within the current window.
// We'll subtract this from the tail window to approximate the difficulty of landing both hits within 2x the current window.
double beta = window / headDeviation;
double z = Normal.CDF(0, 1, beta) - 0.5;
double expectedValue = headDeviation * (Normal.PDF(0, 1, 0) - Normal.PDF(0, 1, beta)) / z;
double logPcTail = logErfc((2 * window - expectedValue) / (tailDeviation * root2));
return logDiff(logSum(logPcHead, logPcTail), logPcHead + logPcTail);
}
2023-04-15 23:23:30 -04:00
private double logErfc(double x) => x <= 5
? Math.Log(SpecialFunctions.Erfc(x))
2023-04-15 23:23:30 -04:00
: -Math.Pow(x, 2) - Math.Log(x * Math.Sqrt(Math.PI)); // This is an approximation, https://www.desmos.com/calculator/kdbxwxgf01
private double logSum(double firstLog, double secondLog)
{
double maxVal = Math.Max(firstLog, secondLog);
double minVal = Math.Min(firstLog, secondLog);
2023-02-19 16:33:32 -05:00
// 0 in log form becomes negative infinity, so return negative infinity if both numbers are negative infinity.
if (double.IsNegativeInfinity(maxVal))
{
return maxVal;
}
return maxVal + Math.Log(1 + Math.Exp(minVal - maxVal));
}
2023-02-19 16:33:32 -05:00
private double logDiff(double firstLog, double secondLog)
2023-02-19 16:33:32 -05:00
{
double maxVal = Math.Max(firstLog, secondLog);
2023-02-19 16:33:32 -05:00
2024-01-14 20:20:28 -05:00
// Avoid negative infinity - negative infinity (NaN) by checking if the higher value is negative infinity.
2023-02-19 16:33:32 -05:00
if (double.IsNegativeInfinity(maxVal))
{
return maxVal;
}
return firstLog + SpecialFunctions.Log1p(-Math.Exp(-(firstLog - secondLog)));
2023-02-19 16:33:32 -05:00
}
}
}