diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index deefeb915c..0bbf1d3df6 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -75,6 +75,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty [JsonProperty("speed_difficult_strain_count")] public double SpeedDifficultStrainCount { get; set; } + [JsonProperty("slider_nested_score_per_object")] + public double SliderNestedScorePerObject { get; set; } + + [JsonProperty("legacy_score_base_multiplier")] + public double LegacyScoreBaseMultiplier { get; set; } + + [JsonProperty("maximum_legacy_combo_score")] + public double MaximumLegacyComboScore { get; set; } + /// /// The beatmap's drain rate. This doesn't scale with rate-adjusting mods. /// @@ -115,6 +124,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty yield return (ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT, AimDifficultSliderCount); yield return (ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR, AimTopWeightedSliderFactor); yield return (ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR, SpeedTopWeightedSliderFactor); + yield return (ATTRIB_ID_SLIDER_NESTED_SCORE_PER_OBJECT, SliderNestedScorePerObject); + yield return (ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER, LegacyScoreBaseMultiplier); + yield return (ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE, MaximumLegacyComboScore); } public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) @@ -132,6 +144,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty AimDifficultSliderCount = values[ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT]; AimTopWeightedSliderFactor = values[ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR]; SpeedTopWeightedSliderFactor = values[ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR]; + SliderNestedScorePerObject = values[ATTRIB_ID_SLIDER_NESTED_SCORE_PER_OBJECT]; + LegacyScoreBaseMultiplier = values[ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER]; + MaximumLegacyComboScore = values[ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE]; DrainRate = onlineInfo.DrainRate; HitCircleCount = onlineInfo.CircleCount; SliderCount = onlineInfo.SliderCount; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index fa142e4429..7c8de87884 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -11,6 +11,7 @@ using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Skills; +using osu.Game.Rulesets.Osu.Difficulty.Utils; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Scoring; @@ -112,6 +113,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty ? Math.Cbrt(multiplier) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0; + double sliderNestedScorePerObject = LegacyScoreUtils.CalculateSliderNestedScorePerObject(beatmap, totalHits); + double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap); + + var simulator = new OsuLegacyScoreSimulator(); + var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap); + OsuDifficultyAttributes attributes = new OsuDifficultyAttributes { StarRating = starRating, @@ -131,6 +138,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty HitCircleCount = hitCircleCount, SliderCount = sliderCount, SpinnerCount = spinnerCount, + SliderNestedScorePerObject = sliderNestedScorePerObject, + LegacyScoreBaseMultiplier = legacyScoreBaseMultiplier, + MaximumLegacyComboScore = scoreAttributes.ComboScore }; return attributes; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs new file mode 100644 index 0000000000..53837b78a0 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreMissCalculator.cs @@ -0,0 +1,187 @@ +// Copyright (c) ppy Pty Ltd . 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.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; + +namespace osu.Game.Rulesets.Osu.Difficulty +{ + public class OsuLegacyScoreMissCalculator + { + private readonly ScoreInfo score; + private readonly OsuDifficultyAttributes attributes; + + public OsuLegacyScoreMissCalculator(ScoreInfo scoreInfo, OsuDifficultyAttributes attributes) + { + score = scoreInfo; + this.attributes = attributes; + } + + public double Calculate() + { + if (attributes.MaxCombo == 0 || score.LegacyTotalScore == null) + return 0; + + double scoreV1Multiplier = attributes.LegacyScoreBaseMultiplier * getLegacyScoreMultiplier(); + double relevantComboPerObject = calculateRelevantScoreComboPerObject(); + + double maximumMissCount = calculateMaximumComboBasedMissCount(); + + double scoreObtainedDuringMaxCombo = calculateScoreAtCombo(score.MaxCombo, relevantComboPerObject, scoreV1Multiplier); + double remainingScore = score.LegacyTotalScore.Value - scoreObtainedDuringMaxCombo; + + if (remainingScore <= 0) + return maximumMissCount; + + double remainingCombo = attributes.MaxCombo - score.MaxCombo; + double expectedRemainingScore = calculateScoreAtCombo(remainingCombo, relevantComboPerObject, scoreV1Multiplier); + + double scoreBasedMissCount = expectedRemainingScore / remainingScore; + + // If there's less then one miss detected - let combo-based miss count decide if this is FC or not + scoreBasedMissCount = Math.Max(scoreBasedMissCount, 1); + + // Cap result by very harsh version of combo-based miss count + return Math.Min(scoreBasedMissCount, maximumMissCount); + } + + /// + /// Calculates the amount of score that would be achieved at a given combo. + /// + private double calculateScoreAtCombo(double combo, double relevantComboPerObject, double scoreV1Multiplier) + { + int countGreat = score.Statistics.GetValueOrDefault(HitResult.Great); + int countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); + int countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); + int countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); + + int totalHits = countGreat + countOk + countMeh + countMiss; + + double estimatedObjects = combo / relevantComboPerObject - 1; + + // The combo portion of ScoreV1 follows arithmetic progression + // Therefore, we calculate the combo portion of score using the combo per object and our current combo. + double comboScore = relevantComboPerObject > 0 ? (2 * (relevantComboPerObject - 1) + (estimatedObjects - 1) * relevantComboPerObject) * estimatedObjects / 2 : 0; + + // We then apply the accuracy and ScoreV1 multipliers to the resulting score. + comboScore *= score.Accuracy * 300 / 25 * scoreV1Multiplier; + + double objectsHit = (totalHits - countMiss) * combo / attributes.MaxCombo; + + // Score also has a non-combo portion we need to create the final score value. + double nonComboScore = (300 + attributes.SliderNestedScorePerObject) * score.Accuracy * objectsHit; + + return comboScore + nonComboScore; + } + + /// + /// Calculates the relevant combo per object for legacy score. + /// This assumes a uniform distribution for circles and sliders. + /// This handles cases where objects (such as buzz sliders) do not fit a normal arithmetic progression model. + /// + private double calculateRelevantScoreComboPerObject() + { + double comboScore = attributes.MaximumLegacyComboScore; + + // We then reverse apply the ScoreV1 multipliers to get the raw value. + comboScore /= 300.0 / 25.0 * attributes.LegacyScoreBaseMultiplier; + + // Reverse the arithmetic progression to work out the amount of combo per object based on the score. + double result = (attributes.MaxCombo - 2) * attributes.MaxCombo; + result /= Math.Max(attributes.MaxCombo + 2 * (comboScore - 1), 1); + + return result; + } + + /// + /// This function is a harsher version of current combo-based miss count, used to provide reasonable value for cases where score-based miss count can't do this. + /// + private double calculateMaximumComboBasedMissCount() + { + int countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); + + if (attributes.SliderCount <= 0) + return countMiss; + + int countOk = score.Statistics.GetValueOrDefault(HitResult.Ok); + int countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); + + int totalImperfectHits = countOk + countMeh + countMiss; + + double missCount = 0; + + // 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 to 10% of all sliders on the map + double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount; + + if (score.MaxCombo < fullComboThreshold) + missCount = Math.Pow(fullComboThreshold / Math.Max(1.0, score.MaxCombo), 2.5); + + // In classic scores there can't be more misses than a sum of all non-perfect judgements + missCount = Math.Min(missCount, totalImperfectHits); + + return missCount; + } + + /// + /// Logic copied from . + /// + private double getLegacyScoreMultiplier() + { + bool scoreV2 = score.Mods.Any(m => m is ModScoreV2); + + double multiplier = 1.0; + + foreach (var mod in score.Mods) + { + switch (mod) + { + case OsuModNoFail: + multiplier *= scoreV2 ? 1.0 : 0.5; + break; + + case OsuModEasy: + multiplier *= 0.5; + break; + + case OsuModHalfTime: + case OsuModDaycore: + multiplier *= 0.3; + break; + + case OsuModHidden: + multiplier *= 1.06; + break; + + case OsuModHardRock: + multiplier *= scoreV2 ? 1.10 : 1.06; + break; + + case OsuModDoubleTime: + case OsuModNightcore: + multiplier *= scoreV2 ? 1.20 : 1.12; + break; + + case OsuModFlashlight: + multiplier *= 1.12; + break; + + case OsuModSpunOut: + multiplier *= 0.9; + break; + + case OsuModRelax: + case OsuModAutopilot: + return 0; + } + } + + return multiplier; + } + } +} diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs index de4491a31b..f889ce3137 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceAttributes.cs @@ -27,6 +27,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty [JsonProperty("speed_deviation")] public double? SpeedDeviation { get; set; } + [JsonProperty("combo_based_estimated_miss_count")] + public double ComboBasedEstimatedMissCount { get; set; } + + [JsonProperty("score_based_estimated_miss_count")] + public double? ScoreBasedEstimatedMissCount { get; set; } + public override IEnumerable GetAttributesForDisplay() { foreach (var attribute in base.GetAttributesForDisplay()) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index 431bc24357..1c9334d208 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -7,12 +7,12 @@ using System.Linq; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Utils; using osu.Game.Beatmaps; -using osu.Game.Rulesets.Difficulty; 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.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Mods; -using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Utils; @@ -95,30 +95,20 @@ namespace osu.Game.Rulesets.Osu.Difficulty overallDifficulty = (80 - greatHitWindow) / 6; approachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5; - if (osuAttributes.SliderCount > 0) + double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes); + double? scoreBasedEstimatedMissCount = null; + + if (usingClassicSliderAccuracy && score.LegacyTotalScore != null) { - if (usingClassicSliderAccuracy) - { - // 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 to 10% of all sliders on the map - double fullComboThreshold = attributes.MaxCombo - 0.1 * osuAttributes.SliderCount; + var legacyScoreMissCalculator = new OsuLegacyScoreMissCalculator(score, osuAttributes); + scoreBasedEstimatedMissCount = legacyScoreMissCalculator.Calculate(); - if (scoreMaxCombo < fullComboThreshold) - effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); - - // In classic scores there can't be more misses than a sum of all non-perfect judgements - effectiveMissCount = Math.Min(effectiveMissCount, totalImperfectHits); - } - else - { - double fullComboThreshold = attributes.MaxCombo - countSliderEndsDropped; - - if (scoreMaxCombo < fullComboThreshold) - effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); - - // Combine regular misses with tick misses since tick misses break combo as well - effectiveMissCount = Math.Min(effectiveMissCount, countSliderTickMiss + countMiss); - } + effectiveMissCount = scoreBasedEstimatedMissCount.Value; + } + else + { + // Use combo-based miss count if this isn't a legacy score + effectiveMissCount = comboBasedEstimatedMissCount; } effectiveMissCount = Math.Max(countMiss, effectiveMissCount); @@ -163,6 +153,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty Accuracy = accuracyValue, Flashlight = flashlightValue, EffectiveMissCount = effectiveMissCount, + ComboBasedEstimatedMissCount = comboBasedEstimatedMissCount, + ScoreBasedEstimatedMissCount = scoreBasedEstimatedMissCount, SpeedDeviation = speedDeviation, Total = totalValue }; @@ -325,6 +317,39 @@ namespace osu.Game.Rulesets.Osu.Difficulty return flashlightValue; } + private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes attributes) + { + if (attributes.SliderCount <= 0) + return countMiss; + + double missCount = countMiss; + + if (usingClassicSliderAccuracy) + { + // 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 to 10% of all sliders on the map + double fullComboThreshold = attributes.MaxCombo - 0.1 * 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); + } + 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) @@ -336,6 +361,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty // scores with more oks are more likely to have sliderbreaks 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); } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs b/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs new file mode 100644 index 0000000000..d1df378b47 --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/Utils/LegacyScoreUtils.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Objects.Legacy; +using osu.Game.Rulesets.Osu.Objects; + +namespace osu.Game.Rulesets.Osu.Difficulty.Utils +{ + public static class LegacyScoreUtils + { + /// + /// Calculates the average amount of score per object that is caused by slider ticks. + /// + public static double CalculateSliderNestedScorePerObject(IBeatmap beatmap, int objectCount) + { + const double big_tick_score = 30; + const double small_tick_score = 10; + + var sliders = beatmap.HitObjects.OfType().ToArray(); + + // 1 for head, 1 for tail + int amountOfBigTicks = sliders.Length * 2; + + // Add slider repeats + amountOfBigTicks += sliders.Select(s => s.RepeatCount).Sum(); + + int amountOfSmallTicks = sliders.Select(s => s.NestedHitObjects.Count(nho => nho is SliderTick)).Sum(); + + double totalScore = amountOfBigTicks * big_tick_score + amountOfSmallTicks * small_tick_score; + + return totalScore / objectCount; + } + + public static int CalculateDifficultyPeppyStars(IBeatmap beatmap) + { + int objectCount = beatmap.HitObjects.Count; + int drainLength = 0; + + if (objectCount > 0) + { + int breakLength = beatmap.Breaks.Select(b => (int)Math.Round(b.EndTime) - (int)Math.Round(b.StartTime)).Sum(); + drainLength = ((int)Math.Round(beatmap.HitObjects[^1].StartTime) - (int)Math.Round(beatmap.HitObjects[0].StartTime) - breakLength) / 1000; + } + + return LegacyRulesetExtensions.CalculateDifficultyPeppyStars(beatmap.Difficulty, objectCount, drainLength); + } + } +} diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index f2b5642236..e01ce6fde5 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -28,6 +28,9 @@ namespace osu.Game.Rulesets.Difficulty protected const int ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT = 31; protected const int ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR = 33; protected const int ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR = 35; + protected const int ATTRIB_ID_SLIDER_NESTED_SCORE_PER_OBJECT = 37; + protected const int ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER = 39; + protected const int ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE = 41; /// /// The mods which were applied to the beatmap. diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index a7eed0dda1..4a404c1e57 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -28,11 +28,15 @@ namespace osu.Game.Rulesets.Difficulty /// protected IBeatmap Beatmap { get; private set; } + /// + /// The working beatmap for which difficulty will be calculated. + /// + protected readonly IWorkingBeatmap WorkingBeatmap; + private Mod[] playableMods; private double clockRate; private readonly IRulesetInfo ruleset; - private readonly IWorkingBeatmap beatmap; /// /// A yymmdd version which is used to discern when reprocessing is required. @@ -42,7 +46,7 @@ namespace osu.Game.Rulesets.Difficulty protected DifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) { this.ruleset = ruleset; - this.beatmap = beatmap; + WorkingBeatmap = beatmap; } /// @@ -178,7 +182,7 @@ namespace osu.Game.Rulesets.Difficulty private void preProcess([NotNull] IEnumerable mods, CancellationToken cancellationToken) { playableMods = mods.Select(m => m.DeepClone()).ToArray(); - Beatmap = beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); + Beatmap = WorkingBeatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); clockRate = ModUtils.CalculateRateWithMods(playableMods); }