mirror of
https://github.com/ppy/osu
synced 2025-01-18 20:10:49 +00:00
Merge pull request #26405 from bdach/catch-scoring
Adjust catch scoring to match stable score V2
This commit is contained in:
commit
a4c9e9f84d
@ -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)
|
||||
{
|
||||
|
@ -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();
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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.
|
||||
|
Loading…
Reference in New Issue
Block a user