1
0
mirror of https://github.com/ppy/osu.git synced 2024-09-21 16:07:24 +08:00

Merge pull request #26405 from bdach/catch-scoring

Adjust catch scoring to match stable score V2
This commit is contained in:
Dean Herbert 2024-01-10 01:28:56 +09:00 committed by GitHub
commit a4c9e9f84d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 182 additions and 18 deletions

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
@ -20,20 +21,73 @@ namespace osu.Game.Rulesets.Catch.Scoring
private const int combo_cap = 200;
private const double combo_base = 4;
private double fruitTinyScale;
public CatchScoreProcessor()
: base(new CatchRuleset())
{
}
protected override void Reset(bool storeResults)
{
base.Reset(storeResults);
// large ticks are *purposefully* not counted to match stable
int fruitTinyScaleDivisor = MaximumResultCounts.GetValueOrDefault(HitResult.SmallTickHit) + MaximumResultCounts.GetValueOrDefault(HitResult.Great);
fruitTinyScale = fruitTinyScaleDivisor == 0
? 0
: (double)MaximumResultCounts.GetValueOrDefault(HitResult.SmallTickHit) / fruitTinyScaleDivisor;
}
protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion)
{
return 600000 * comboProgress
+ 400000 * Accuracy.Value * accuracyProgress
const int max_tiny_droplets_portion = 400000;
double comboPortion = 1000000 - max_tiny_droplets_portion + max_tiny_droplets_portion * (1 - fruitTinyScale);
double dropletsPortion = max_tiny_droplets_portion * fruitTinyScale;
double dropletsHit = MaximumResultCounts.GetValueOrDefault(HitResult.SmallTickHit) == 0
? 0
: (double)ScoreResultCounts.GetValueOrDefault(HitResult.SmallTickHit) / MaximumResultCounts.GetValueOrDefault(HitResult.SmallTickHit);
return comboPortion * comboProgress
+ dropletsPortion * dropletsHit
+ bonusPortion;
}
public override int GetBaseScoreForResult(HitResult result)
{
switch (result)
{
// dirty hack to emulate accuracy on stable weighting every object equally in accuracy portion
case HitResult.Great:
case HitResult.LargeTickHit:
case HitResult.SmallTickHit:
return 300;
case HitResult.LargeBonus:
return 200;
}
return base.GetBaseScoreForResult(result);
}
protected override double GetComboScoreChange(JudgementResult result)
=> GetBaseScoreForResult(result.Type) * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(combo_cap, combo_base));
{
double baseIncrease = 0;
switch (result.Type)
{
case HitResult.Great:
baseIncrease = 300;
break;
case HitResult.LargeTickHit:
baseIncrease = 100;
break;
}
return baseIncrease * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(combo_cap, combo_base));
}
public override ScoreRank RankFromAccuracy(double accuracy)
{

View File

@ -446,9 +446,30 @@ namespace osu.Game.Database
break;
case 2:
// compare logic in `CatchScoreProcessor`.
// this could technically be slightly incorrect in the case of stable scores.
// because large droplet misses are counted as full misses in stable scores,
// `score.MaximumStatistics.GetValueOrDefault(Great)` will be equal to the count of fruits *and* large droplets
// rather than just fruits (which was the intent).
// this is not fixable without introducing an extra legacy score attribute dedicated for catch,
// and this is a ballpark conversion process anyway, so attempt to trudge on.
int fruitTinyScaleDivisor = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) + score.MaximumStatistics.GetValueOrDefault(HitResult.Great);
double fruitTinyScale = fruitTinyScaleDivisor == 0
? 0
: (double)score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) / fruitTinyScaleDivisor;
const int max_tiny_droplets_portion = 400000;
double comboPortion = 1000000 - max_tiny_droplets_portion + max_tiny_droplets_portion * (1 - fruitTinyScale);
double dropletsPortion = max_tiny_droplets_portion * fruitTinyScale;
double dropletsHit = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) == 0
? 0
: (double)score.Statistics.GetValueOrDefault(HitResult.SmallTickHit) / score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit);
convertedTotalScore = (long)Math.Round((
600000 * comboProportion
+ 400000 * score.Accuracy
comboPortion * estimateComboProportionForCatch(attributes.MaxCombo, score.MaxCombo, score.Statistics.GetValueOrDefault(HitResult.Miss))
+ dropletsPortion * dropletsHit
+ bonusProportion) * modMultiplier);
break;
@ -475,6 +496,94 @@ namespace osu.Game.Database
return convertedTotalScore;
}
/// <summary>
/// <para>
/// For catch, the general method of calculating the combo proportion used for other rulesets is generally useless.
/// This is because in stable score V1, catch has quadratic score progression,
/// while in stable score V2, score progression is logarithmic up to 200 combo and then linear.
/// </para>
/// <para>
/// This means that applying the naive rescale method to scores with lots of short combos (think 10x 100-long combos on a 1000-object map)
/// by linearly rescaling the combo portion as given by score V1 leads to horribly underestimating it.
/// Therefore this method attempts to counteract this by calculating the best case estimate for the combo proportion that takes all of the above into account.
/// </para>
/// <para>
/// The general idea is that aside from the <paramref name="scoreMaxCombo"/> which the player is known to have hit,
/// the remaining misses are evenly distributed across the rest of the objects that give combo.
/// This is therefore a worst-case estimate.
/// </para>
/// </summary>
private static double estimateComboProportionForCatch(int beatmapMaxCombo, int scoreMaxCombo, int scoreMissCount)
{
if (beatmapMaxCombo == 0)
return 1;
if (scoreMaxCombo == 0)
return 0;
if (beatmapMaxCombo == scoreMaxCombo)
return 1;
double estimatedBestCaseTotal = estimateBestCaseComboTotal(beatmapMaxCombo);
int remainingCombo = beatmapMaxCombo - (scoreMaxCombo + scoreMissCount);
double totalDroppedScore = 0;
int assumedLengthOfRemainingCombos = (int)Math.Floor((double)remainingCombo / scoreMissCount);
if (assumedLengthOfRemainingCombos > 0)
{
int assumedCombosCount = (int)Math.Floor((double)remainingCombo / assumedLengthOfRemainingCombos);
totalDroppedScore += assumedCombosCount * estimateDroppedComboScoreAfterMiss(assumedLengthOfRemainingCombos);
remainingCombo -= assumedCombosCount * assumedLengthOfRemainingCombos;
if (remainingCombo > 0)
totalDroppedScore += estimateDroppedComboScoreAfterMiss(remainingCombo);
}
else
{
// there are so many misses that attempting to evenly divide remaining combo results in 0 length per combo,
// i.e. all remaining judgements are combo breaks.
// in that case, presume every single remaining object is a miss and did not give any combo score.
totalDroppedScore = estimatedBestCaseTotal - estimateBestCaseComboTotal(scoreMaxCombo);
}
return estimatedBestCaseTotal == 0
? 1
: 1 - Math.Clamp(totalDroppedScore / estimatedBestCaseTotal, 0, 1);
double estimateBestCaseComboTotal(int maxCombo)
{
if (maxCombo == 0)
return 1;
double estimatedTotal = 0.5 * Math.Min(maxCombo, 2);
if (maxCombo <= 2)
return estimatedTotal;
// int_2^x log_4(t) dt
estimatedTotal += (Math.Min(maxCombo, 200) * (Math.Log(Math.Min(maxCombo, 200)) - 1) + 2 - Math.Log(4)) / Math.Log(4);
if (maxCombo <= 200)
return estimatedTotal;
estimatedTotal += (maxCombo - 200) * Math.Log(200) / Math.Log(4);
return estimatedTotal;
}
double estimateDroppedComboScoreAfterMiss(int lengthOfComboAfterMiss)
{
if (lengthOfComboAfterMiss >= 200)
lengthOfComboAfterMiss = 200;
// int_0^x (log_4(200) - log_4(t)) dt
// note that this is an pessimistic estimate, i.e. it may subtract too much if the miss happened before reaching 200 combo
return lengthOfComboAfterMiss * (1 + Math.Log(200) - Math.Log(lengthOfComboAfterMiss)) / Math.Log(4);
}
}
public static double ComputeAccuracy(ScoreInfo scoreInfo)
{
Ruleset ruleset = scoreInfo.Ruleset.CreateInstance();

View File

@ -169,14 +169,14 @@ namespace osu.Game.Rulesets.Scoring
if (!beatmapApplied)
throw new InvalidOperationException($"Cannot access maximum statistics before calling {nameof(ApplyBeatmap)}.");
return new Dictionary<HitResult, int>(maximumResultCounts);
return new Dictionary<HitResult, int>(MaximumResultCounts);
}
}
private bool beatmapApplied;
private readonly Dictionary<HitResult, int> scoreResultCounts = new Dictionary<HitResult, int>();
private readonly Dictionary<HitResult, int> maximumResultCounts = new Dictionary<HitResult, int>();
protected readonly Dictionary<HitResult, int> ScoreResultCounts = new Dictionary<HitResult, int>();
protected readonly Dictionary<HitResult, int> MaximumResultCounts = new Dictionary<HitResult, int>();
private readonly List<HitEvent> hitEvents = new List<HitEvent>();
private HitObject? lastHitObject;
@ -222,7 +222,7 @@ namespace osu.Game.Rulesets.Scoring
if (result.FailedAtJudgement)
return;
scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) + 1;
ScoreResultCounts[result.Type] = ScoreResultCounts.GetValueOrDefault(result.Type) + 1;
if (result.Type.IncreasesCombo())
Combo.Value++;
@ -278,7 +278,7 @@ namespace osu.Game.Rulesets.Scoring
if (result.FailedAtJudgement)
return;
scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) - 1;
ScoreResultCounts[result.Type] = ScoreResultCounts.GetValueOrDefault(result.Type) - 1;
if (result.Judgement.MaxResult.AffectsAccuracy())
{
@ -400,13 +400,13 @@ namespace osu.Game.Rulesets.Scoring
maximumComboPortion = currentComboPortion;
maximumAccuracyJudgementCount = currentAccuracyJudgementCount;
maximumResultCounts.Clear();
maximumResultCounts.AddRange(scoreResultCounts);
MaximumResultCounts.Clear();
MaximumResultCounts.AddRange(ScoreResultCounts);
MaximumTotalScore = TotalScore.Value;
}
scoreResultCounts.Clear();
ScoreResultCounts.Clear();
currentBaseScore = 0;
currentMaximumBaseScore = 0;
@ -435,10 +435,10 @@ namespace osu.Game.Rulesets.Scoring
score.MaximumStatistics.Clear();
foreach (var result in HitResultExtensions.ALL_TYPES)
score.Statistics[result] = scoreResultCounts.GetValueOrDefault(result);
score.Statistics[result] = ScoreResultCounts.GetValueOrDefault(result);
foreach (var result in HitResultExtensions.ALL_TYPES)
score.MaximumStatistics[result] = maximumResultCounts.GetValueOrDefault(result);
score.MaximumStatistics[result] = MaximumResultCounts.GetValueOrDefault(result);
// Populate total score after everything else.
score.TotalScore = TotalScore.Value;
@ -469,8 +469,8 @@ namespace osu.Game.Rulesets.Scoring
HighestCombo.Value = frame.Header.MaxCombo;
TotalScore.Value = frame.Header.TotalScore;
scoreResultCounts.Clear();
scoreResultCounts.AddRange(frame.Header.Statistics);
ScoreResultCounts.Clear();
ScoreResultCounts.AddRange(frame.Header.Statistics);
SetScoreProcessorStatistics(frame.Header.ScoreProcessorStatistics);

View File

@ -37,9 +37,10 @@ namespace osu.Game.Scoring.Legacy
/// <item><description>30000008: Add accuracy conversion. Reconvert all scores.</description></item>
/// <item><description>30000009: Fix edge cases in conversion for scores which have 0.0x mod multiplier on stable. Reconvert all scores.</description></item>
/// <item><description>30000010: Fix mania score V1 conversion using score V1 accuracy rather than V2 accuracy. Reconvert all scores.</description></item>
/// <item><description>30000011: Re-do catch scoring to mirror stable Score V2 as closely as feasible. Reconvert all scores.</description></item>
/// </list>
/// </remarks>
public const int LATEST_VERSION = 30000010;
public const int LATEST_VERSION = 30000011;
/// <summary>
/// The first stable-compatible YYYYMMDD format version given to lazer usage of replays.