diff --git a/osu.Game.Rulesets.Mania.Tests/ManiaUnstableRateEstimationTest.cs b/osu.Game.Rulesets.Mania.Tests/ManiaUnstableRateEstimationTest.cs new file mode 100644 index 000000000000..6780a1a5c455 --- /dev/null +++ b/osu.Game.Rulesets.Mania.Tests/ManiaUnstableRateEstimationTest.cs @@ -0,0 +1,224 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable disable + +using NUnit.Framework; +using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mania.Difficulty; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Scoring; +using System.Collections.Generic; +using System.Linq; +using osu.Game.Rulesets.Mania.Scoring; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Tests +{ + /// + /// This test suite tests ManiaPerformanceCalculator.computeEstimatedUr + /// + /// This suite focuses on the objective aspects of the calculation, not the accuracy of the calculation. + /// + /// + public class ManiaUnstableRateEstimationTest + { + public enum SpeedMod + { + DoubleTime, + NormalTime, + HalfTime + } + + public static IEnumerable TestCaseSourceData() + { + yield return new TestCaseData(1037.4609375d, new[] { 3, 3, 3, 3, 3, 3 }, SpeedMod.DoubleTime); + yield return new TestCaseData(1037.4609375d, new[] { 3, 3, 3, 3, 3, 3 }, SpeedMod.NormalTime); + yield return new TestCaseData(1037.4609375d, new[] { 3, 3, 3, 3, 3, 3 }, SpeedMod.HalfTime); + } + + /// + /// A catch-all hardcoded regression test, inclusive of rate changing. + /// + [TestCaseSource(nameof(TestCaseSourceData))] + public void RegressionTest(double expectedUr, int[] judgementCounts, SpeedMod speedMod) + { + double? estimatedUr = computeUnstableRate(judgementCounts, speedMod: speedMod); + // Platform-dependent math functions (Pow, Cbrt, Exp, etc) and advanced math functions (Erf, FindMinimum) may result in slight differences. + Assert.That( + estimatedUr, Is.EqualTo(expectedUr).Within(0.001), + $"The estimated mania UR {estimatedUr} differed from the expected value {expectedUr}." + ); + } + + /// + /// Test anomalous judgement counts where NULLs can occur. + /// + [TestCase(false, new[] { 1, 0, 0, 0, 0, 0 })] + [TestCase(true, new[] { 0, 0, 0, 0, 0, 1 })] + [TestCase(true, new[] { 0, 0, 0, 0, 0, 0 })] + public void TestNull(bool expectedIsNull, int[] judgementCounts) + { + double? estimatedUr = computeUnstableRate(judgementCounts); + bool isNull = estimatedUr == null; + + Assert.That(isNull, Is.EqualTo(expectedIsNull), $"Estimated mania UR {estimatedUr} was/wasn't null."); + } + + /// + /// Ensure that the worst case scenarios don't result in unbounded URs. + /// Given Int.MaxValue judgements, it can result in + /// . + /// However, we'll only test realistic scenarios. + /// + [Test, Combinatorial] + public void TestEdge( + [Values(100_000, 1, 0)] int judgeMax, // We're only interested in the edge judgements. + [Values(100_000, 1, 0)] int judge50, + [Values(100_000, 1, 0)] int judge0, + [Values(SpeedMod.DoubleTime, SpeedMod.HalfTime, SpeedMod.NormalTime)] + SpeedMod speedMod, + [Values(true, false)] bool isHoldsLegacy, + [Values(true, false)] bool isAllHolds, // This will determine if we use all holds or all notes. + [Values(10, 5, 0)] double od + ) + { + // This is tested in TestNull. + if (judgeMax + judge50 == 0) Assert.Ignore(); + + int noteCount = isAllHolds ? 0 : judgeMax + judge50 + judge0; + int holdCount = isAllHolds ? judgeMax + judge50 + judge0 : 0; + + double? estimatedUr = computeUnstableRate( + new[] { judgeMax, 0, 0, 0, judge50, judge0 }, + noteCount, + holdCount, + od, + speedMod, + isHoldsLegacy + ); + Assert.That( + estimatedUr, Is.AtMost(1_000_000_000), + $"The estimated mania UR {estimatedUr} returned too high for a single note." + ); + } + + /// + /// This tests if the UR gets smaller, given more MAX judgements. + /// This follows the logic that: + /// - More MAX judgements implies stronger evidence of smaller UR, as the probability of hitting a MAX judgement is higher. + /// + /// It's not necessary, nor logical to test other behaviors. + /// + /// + [Test] + public void TestMoreMaxJudgementsSmallerUr( + [Values(1, 10, 1000)] int count, + [Values(1, 10, 1000)] int step + ) + { + int[] judgementCountsLess = { count, 0, 0, 0, 0, 0 }; + int[] judgementCountsMore = { count + step, 0, 0, 0, 0, 0 }; + double? estimatedUrLessJudgements = computeUnstableRate(judgementCountsLess); + double? estimatedUrMoreJudgements = computeUnstableRate(judgementCountsMore); + + // Assert that More Judgements results in a smaller UR. + Assert.That( + estimatedUrMoreJudgements, Is.LessThan(estimatedUrLessJudgements), + $"UR {estimatedUrMoreJudgements} with More Judgements {string.Join(",", judgementCountsMore)} >= " + + $"UR {estimatedUrLessJudgements} than Less Judgements {string.Join(",", judgementCountsLess)} " + ); + } + + /// + /// Evaluates the Unstable Rate + /// + /// Size-6 Int List of Judgements, starting from MAX + /// Number of notes + /// Number of holds + /// Overall Difficulty + /// Speed Mod, + /// Whether to append ClassicMod to simulate Legacy Holds + private double? computeUnstableRate( + IReadOnlyList judgementCounts, + int? noteCount = null, + int holdCount = 0, + double od = 5, + SpeedMod speedMod = SpeedMod.NormalTime, + bool isHoldsLegacy = false) + { + var judgements = new Dictionary + { + { HitResult.Perfect, judgementCounts[0] }, + { HitResult.Great, judgementCounts[1] }, + { HitResult.Good, judgementCounts[2] }, + { HitResult.Ok, judgementCounts[3] }, + { HitResult.Meh, judgementCounts[4] }, + { HitResult.Miss, judgementCounts[5] } + }; + noteCount ??= judgements.Sum(kvp => kvp.Value); + + var mods = new Mod[] { }; + + if (isHoldsLegacy) mods = mods.Append(new ManiaModClassic()).ToArray(); + + switch (speedMod) + { + case SpeedMod.DoubleTime: + mods = mods.Append(new ManiaModDoubleTime()).ToArray(); + break; + + case SpeedMod.HalfTime: + mods = mods.Append(new ManiaModHalfTime()).ToArray(); + break; + } + + ManiaPerformanceAttributes perfAttributes = new ManiaPerformanceCalculator().Calculate( + new ScoreInfo + { + Mods = mods, + Statistics = judgements + }, + new ManiaDifficultyAttributes + { + NoteCount = (int)noteCount, + HoldNoteCount = holdCount, + OverallDifficulty = od, + Mods = mods + } + ); + + return perfAttributes.EstimatedUr; + } + + /// + /// This ensures that external changes of hit windows don't break the ur calculator. + /// This includes all ODs. + /// + [Test] + public void RegressionTestHitWindows( + [Range(0, 10, 0.5)] double overallDifficulty + ) + { + DifficultyAttributes attributes = new ManiaDifficultyAttributes { OverallDifficulty = overallDifficulty }; + + var hitWindows = new ManiaHitWindows(); + hitWindows.SetDifficulty(overallDifficulty); + + double[] trueHitWindows = + { + hitWindows.WindowFor(HitResult.Perfect), + hitWindows.WindowFor(HitResult.Great), + hitWindows.WindowFor(HitResult.Good), + hitWindows.WindowFor(HitResult.Ok), + hitWindows.WindowFor(HitResult.Meh) + }; + + ManiaPerformanceAttributes perfAttributes = new ManiaPerformanceCalculator().Calculate(new ScoreInfo(), attributes); + + // Platform-dependent math functions (Pow, Cbrt, Exp, etc) may result in minute differences. + Assert.That(perfAttributes.HitWindows, Is.EqualTo(trueHitWindows).Within(0.000001), "The true mania hit windows are different to the ones calculated in ManiaPerformanceCalculator."); + } + } +} diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs index db60e757e111..df2a22e91965 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs @@ -19,6 +19,22 @@ public class ManiaDifficultyAttributes : DifficultyAttributes [JsonProperty("great_hit_window")] public double GreatHitWindow { get; set; } + /// + /// The perceived overall difficulty of the map. + /// + [JsonProperty("overall_difficulty")] + public double OverallDifficulty { get; set; } + + /// + /// The number of notes in the beatmap. + /// + public int NoteCount { get; set; } + + /// + /// The number of hold notes in the beatmap. + /// + public int HoldNoteCount { get; set; } + public override IEnumerable<(int attributeId, object value)> ToDatabaseAttributes() { foreach (var v in base.ToDatabaseAttributes()) @@ -26,6 +42,9 @@ public class ManiaDifficultyAttributes : DifficultyAttributes yield return (ATTRIB_ID_DIFFICULTY, StarRating); yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow); + yield return (ATTRIB_ID_OVERALL_DIFFICULTY, OverallDifficulty); + yield return (ATTRIB_ID_NOTE_COUNT, NoteCount); + yield return (ATTRIB_ID_HOLD_NOTE_COUNT, HoldNoteCount); } public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) @@ -34,6 +53,9 @@ public override void FromDatabaseAttributes(IReadOnlyDictionary val StarRating = values[ATTRIB_ID_DIFFICULTY]; GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW]; + OverallDifficulty = values[ATTRIB_ID_OVERALL_DIFFICULTY]; + NoteCount = (int)values[ATTRIB_ID_NOTE_COUNT]; + HoldNoteCount = (int)values[ATTRIB_ID_HOLD_NOTE_COUNT]; } } } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index 4190e74e5174..6f9d4c1c3063 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -46,6 +46,9 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat HitWindows hitWindows = new ManiaHitWindows(); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); + int noteCount = beatmap.HitObjects.Count(h => h is Note); + int holdNoteCount = beatmap.HitObjects.Count(h => h is HoldNote); + ManiaDifficultyAttributes attributes = new ManiaDifficultyAttributes { StarRating = skills[0].DifficultyValue() * star_scaling_factor, @@ -54,6 +57,9 @@ protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beat // This is done the way it is to introduce fractional differences in order to match osu-stable for the time being. GreatHitWindow = Math.Ceiling((int)(getHitWindow300(mods) * clockRate) / clockRate), MaxCombo = beatmap.HitObjects.Sum(maxComboForObject), + OverallDifficulty = beatmap.Difficulty.OverallDifficulty, + NoteCount = noteCount, + HoldNoteCount = holdNoteCount, }; return attributes; diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceAttributes.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceAttributes.cs index 64f8b026c2aa..fb14100f04dc 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceAttributes.cs @@ -12,6 +12,12 @@ public class ManiaPerformanceAttributes : PerformanceAttributes [JsonProperty("difficulty")] public double Difficulty { get; set; } + [JsonProperty("estimated_ur")] + public double? EstimatedUr { get; set; } + + [JsonProperty("hit_windows")] + public double[] HitWindows { get; set; } = null!; + public override IEnumerable GetAttributesForDisplay() { foreach (var attribute in base.GetAttributesForDisplay()) diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs index d9f947924702..1fa32e0dec25 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaPerformanceCalculator.cs @@ -4,39 +4,64 @@ using System; using System.Collections.Generic; using System.Linq; +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; using osu.Game.Scoring; +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. + + // 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; + private int countPerfect; private int countGreat; private int countGood; private int countOk; private int countMeh; private int countMiss; - private double scoreAccuracy; + private double? estimatedUr; + private bool isLegacyScore; + private double[] hitWindows = null!; + private bool isConvert; public ManiaPerformanceCalculator() : base(new ManiaRuleset()) { } + public new ManiaPerformanceAttributes Calculate(ScoreInfo score, DifficultyAttributes attributes) + => (ManiaPerformanceAttributes)CreatePerformanceAttributes(score, attributes); + protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo score, DifficultyAttributes attributes) { var maniaAttributes = (ManiaDifficultyAttributes)attributes; + 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); - scoreAccuracy = calculateCustomAccuracy(); + isLegacyScore = score.Mods.Any(m => m is ManiaModClassic) && !Precision.DefinitelyBigger(totalJudgements, maniaAttributes.NoteCount + maniaAttributes.HoldNoteCount); + + hitWindows = isLegacyScore + ? GetLegacyHitWindows(score.Mods, isConvert, maniaAttributes.OverallDifficulty) + : GetLazerHitWindows(score.Mods, maniaAttributes.OverallDifficulty); + + estimatedUr = computeEstimatedUr(maniaAttributes.NoteCount, maniaAttributes.HoldNoteCount); // 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. @@ -53,30 +78,267 @@ protected override PerformanceAttributes CreatePerformanceAttributes(ScoreInfo s return new ManiaPerformanceAttributes { Difficulty = difficultyValue, - Total = totalValue + Total = totalValue, + EstimatedUr = estimatedUr, + HitWindows = hitWindows }; } private double computeDifficultyValue(ManiaDifficultyAttributes attributes) { - double difficultyValue = Math.Pow(Math.Max(attributes.StarRating - 0.15, 0.05), 2.2) // Star rating to pp curve - * Math.Max(0, 5 * scoreAccuracy - 4) // From 80% accuracy, 1/20th of total pp is awarded per additional 1% accuracy - * (1 + 0.1 * Math.Min(1, totalHits / 1500)); // Length bonus, capped at 1500 notes + 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. + // 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)); + + difficultyValue *= Math.Max(1 - Math.Pow(noteUnstableRate / 500, 1.9), 0); return difficultyValue; } - private double totalHits => countPerfect + countOk + countGreat + countGood + countMeh + countMiss; + private double totalJudgements => countPerfect + countOk + countGreat + countGood + countMeh + countMiss; + private double totalSuccessfulJudgements => countPerfect + countOk + countGreat + countGood + countMeh; /// - /// Accuracy used to weight judgements independently from the score's actual accuracy. + /// Returns the estimated unstable rate of the score, assuming the average hit location is in the center of the hit window. + /// + /// Thrown when the optimization algorithm fails to converge. + /// This will never happen in any sane (humanly achievable) case. When tested up to 100 Million misses, the algorithm converges with default settings. + /// + /// + /// Returns Estimated UR, or null if the score is a miss-only score. + /// /// - private double calculateCustomAccuracy() + private double? computeEstimatedUr(int noteCount, int holdNoteCount) { - if (totalHits == 0) - return 0; + if (totalSuccessfulJudgements == 0 || noteCount + holdNoteCount == 0) + return null; + + double noteHeadPortion = (double)(noteCount + holdNoteCount) / (noteCount + holdNoteCount * 2); + double tailPortion = (double)holdNoteCount / (noteCount + holdNoteCount * 2); + + double likelihoodGradient(double d) + { + if (d <= 0) + 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; + + JudgementProbs pNotes = logJudgementProbsNote(dNote); + // Since lazer tails have the same hit behaviour as Notes, return pNote instead of pHold for them. + JudgementProbs pHolds = isLegacyScore ? logJudgementProbsLegacyHold(dNote, dTail) : logJudgementProbsNote(dTail, tail_multiplier); + + return -calculateLikelihoodOfDeviation(pNotes, pHolds, noteCount, holdNoteCount); + } + + // 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); + + return deviation * 10; + } + + public static double[] GetLegacyHitWindows(Mod[] mods, bool isConvert, double overallDifficulty) + { + double[] legacyHitWindows = new double[5]; + + double greatWindowLeniency = 0; + double goodWindowLeniency = 0; + + // 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. + if (isConvert) + { + overallDifficulty = 10; + + if (overallDifficulty <= 4) + { + greatWindowLeniency = 13; + goodWindowLeniency = 10; + } + } + + double windowMultiplier = 1; + + if (mods.Any(m => m is ModHardRock)) + windowMultiplier *= 1 / 1.4; + else if (mods.Any(m => m is ModEasy)) + 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); + + return legacyHitWindows; + } + + public static double[] GetLazerHitWindows(Mod[] mods, double overallDifficulty) + { + double[] lazerHitWindows = new double[5]; + + double windowMultiplier = 1; + + if (mods.Any(m => m is ModHardRock)) + windowMultiplier *= 1 / 1.4; + else if (mods.Any(m => m is ModEasy)) + windowMultiplier *= 1.4; + + if (overallDifficulty < 5) + lazerHitWindows[0] = (22.4 - 0.6 * overallDifficulty) * windowMultiplier; + else + 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; + + return lazerHitWindows; + } + + 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. + private JudgementProbs logJudgementProbsNote(double d, double multiplier = 1) + { + JudgementProbs probabilities = new JudgementProbs + { + 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. + private JudgementProbs logJudgementProbsLegacyHold(double dHead, double dTail) + { + JudgementProbs probabilities = new JudgementProbs + { + 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) + }; + + return probabilities; + } + + /// + /// 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. + /// + private double calculateLikelihoodOfDeviation(JudgementProbs noteProbabilities, JudgementProbs lnProbabilities, double noteCount, double lnCount) + { + // 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; + } + + /// + /// The log complementary probability of getting a certain judgement with a certain deviation. + /// + /// + /// A value from 0 (log of 1, 0% chance) to negative infinity (log of 0, 100% chance). + /// + private double logCompProbHitNote(double window, double deviation) => logErfc(window / (deviation * Math.Sqrt(2))); + + /// + /// 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). + /// + /// + /// A value from 0 (log of 1, 0% chance) to negative infinity (log of 0, 100% chance). + /// + private double logCompProbHitLegacyHold(double window, double headDeviation, double tailDeviation) + { + 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); + } + + private double logErfc(double x) => x <= 5 + ? Math.Log(SpecialFunctions.Erfc(x)) + : -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); + + // 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)); + } + + private double logDiff(double firstLog, double secondLog) + { + double maxVal = Math.Max(firstLog, secondLog); + + // Avoid negative infinity - negative infinity (NaN) by checking if the higher value is negative infinity. + if (double.IsNegativeInfinity(maxVal)) + { + return maxVal; + } - return (countPerfect * 320 + countGreat * 300 + countGood * 200 + countOk * 100 + countMeh * 50) / (totalHits * 320); + return firstLog + SpecialFunctions.Log1p(-Math.Exp(-(firstLog - secondLog))); } } } diff --git a/osu.Game.Rulesets.Mania/osu.Game.Rulesets.Mania.csproj b/osu.Game.Rulesets.Mania/osu.Game.Rulesets.Mania.csproj index 3bca9384506b..b405d5378ba7 100644 --- a/osu.Game.Rulesets.Mania/osu.Game.Rulesets.Mania.csproj +++ b/osu.Game.Rulesets.Mania/osu.Game.Rulesets.Mania.csproj @@ -15,4 +15,8 @@ + + + + diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index 9690924b1c46..9c98c9e989f0 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -26,6 +26,8 @@ public class DifficultyAttributes protected const int ATTRIB_ID_FLASHLIGHT = 17; protected const int ATTRIB_ID_SLIDER_FACTOR = 19; protected const int ATTRIB_ID_SPEED_NOTE_COUNT = 21; + protected const int ATTRIB_ID_NOTE_COUNT = 23; + protected const int ATTRIB_ID_HOLD_NOTE_COUNT = 25; /// /// The mods which were applied to the beatmap.