1
0
mirror of https://github.com/ppy/osu.git synced 2025-01-12 12:22:56 +08:00

Merge pull request #24166 from Zyfarok/scorev3

Modify osu! standardised scoring to introduce a combo exponent
This commit is contained in:
Dan Balasescu 2023-12-12 17:38:44 +09:00 committed by GitHub
commit 987fe9322e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 138 additions and 39 deletions

View File

@ -74,6 +74,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty
simulateHit(obj, ref attributes);
attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore;
attributes.BonusScore = legacyBonusScore;
return attributes;
}

View File

@ -74,6 +74,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty
simulateHit(obj, ref attributes);
attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore;
attributes.BonusScore = legacyBonusScore;
attributes.MaxCombo = combo;
return attributes;
}

View File

@ -1,7 +1,6 @@
// 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 osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Scoring;
@ -17,12 +16,5 @@ namespace osu.Game.Rulesets.Osu.Scoring
protected override HitEvent CreateHitEvent(JudgementResult result)
=> base.CreateHitEvent(result).With((result as OsuHitCircleJudgementResult)?.CursorPositionAtHit);
protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion)
{
return 700000 * comboProgress
+ 300000 * Math.Pow(Accuracy.Value, 10) * accuracyProgress
+ bonusPortion;
}
}
}

View File

@ -75,6 +75,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
simulateHit(obj, ref attributes);
attributes.BonusScoreRatio = legacyBonusScore == 0 ? 0 : (double)standardisedBonusScore / legacyBonusScore;
attributes.BonusScore = legacyBonusScore;
return attributes;
}

View File

@ -45,11 +45,11 @@ namespace osu.Game.Tests.Rulesets.Scoring
};
}
[TestCase(ScoringMode.Standardised, HitResult.Meh, 116_667)]
[TestCase(ScoringMode.Standardised, HitResult.Ok, 233_338)]
[TestCase(ScoringMode.Standardised, HitResult.Meh, 83_398)]
[TestCase(ScoringMode.Standardised, HitResult.Ok, 168_724)]
[TestCase(ScoringMode.Standardised, HitResult.Great, 1_000_000)]
[TestCase(ScoringMode.Classic, HitResult.Meh, 11_670)]
[TestCase(ScoringMode.Classic, HitResult.Ok, 23_341)]
[TestCase(ScoringMode.Classic, HitResult.Meh, 8_343)]
[TestCase(ScoringMode.Classic, HitResult.Ok, 16_878)]
[TestCase(ScoringMode.Classic, HitResult.Great, 100_033)]
public void TestSingleOsuHit(ScoringMode scoringMode, HitResult hitResult, int expectedScore)
{
@ -75,27 +75,27 @@ namespace osu.Game.Tests.Rulesets.Scoring
/// This test intentionally misses the 3rd hitobject to achieve lower than 75% accuracy and 50% max combo.
/// </remarks>
[TestCase(ScoringMode.Standardised, HitResult.Miss, HitResult.Great, 0)]
[TestCase(ScoringMode.Standardised, HitResult.Meh, HitResult.Great, 79_333)]
[TestCase(ScoringMode.Standardised, HitResult.Ok, HitResult.Great, 158_667)]
[TestCase(ScoringMode.Standardised, HitResult.Good, HitResult.Perfect, 317_626)]
[TestCase(ScoringMode.Standardised, HitResult.Great, HitResult.Great, 492_894)]
[TestCase(ScoringMode.Standardised, HitResult.Perfect, HitResult.Perfect, 492_894)]
[TestCase(ScoringMode.Standardised, HitResult.Meh, HitResult.Great, 34_734)]
[TestCase(ScoringMode.Standardised, HitResult.Ok, HitResult.Great, 69_925)]
[TestCase(ScoringMode.Standardised, HitResult.Good, HitResult.Perfect, 154_499)]
[TestCase(ScoringMode.Standardised, HitResult.Great, HitResult.Great, 326_963)]
[TestCase(ScoringMode.Standardised, HitResult.Perfect, HitResult.Perfect, 326_963)]
[TestCase(ScoringMode.Standardised, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)]
[TestCase(ScoringMode.Standardised, HitResult.SmallTickHit, HitResult.SmallTickHit, 541_894)]
[TestCase(ScoringMode.Standardised, HitResult.SmallTickHit, HitResult.SmallTickHit, 493_652)]
[TestCase(ScoringMode.Standardised, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)]
[TestCase(ScoringMode.Standardised, HitResult.LargeTickHit, HitResult.LargeTickHit, 492_894)]
[TestCase(ScoringMode.Standardised, HitResult.LargeTickHit, HitResult.LargeTickHit, 326_963)]
[TestCase(ScoringMode.Standardised, HitResult.SmallBonus, HitResult.SmallBonus, 1_000_030)]
[TestCase(ScoringMode.Standardised, HitResult.LargeBonus, HitResult.LargeBonus, 1_000_150)]
[TestCase(ScoringMode.Classic, HitResult.Miss, HitResult.Great, 0)]
[TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 7_975)]
[TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 15_949)]
[TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 31_928)]
[TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 49_546)]
[TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 49_546)]
[TestCase(ScoringMode.Classic, HitResult.Meh, HitResult.Great, 3_492)]
[TestCase(ScoringMode.Classic, HitResult.Ok, HitResult.Great, 7_029)]
[TestCase(ScoringMode.Classic, HitResult.Good, HitResult.Perfect, 15_530)]
[TestCase(ScoringMode.Classic, HitResult.Great, HitResult.Great, 32_867)]
[TestCase(ScoringMode.Classic, HitResult.Perfect, HitResult.Perfect, 32_867)]
[TestCase(ScoringMode.Classic, HitResult.SmallTickMiss, HitResult.SmallTickHit, 0)]
[TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 54_189)]
[TestCase(ScoringMode.Classic, HitResult.SmallTickHit, HitResult.SmallTickHit, 49_365)]
[TestCase(ScoringMode.Classic, HitResult.LargeTickMiss, HitResult.LargeTickHit, 0)]
[TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 49_289)]
[TestCase(ScoringMode.Classic, HitResult.LargeTickHit, HitResult.LargeTickHit, 32_696)]
[TestCase(ScoringMode.Classic, HitResult.SmallBonus, HitResult.SmallBonus, 100_003)]
[TestCase(ScoringMode.Classic, HitResult.LargeBonus, HitResult.LargeBonus, 100_015)]
public void TestFourVariousResultsOneMiss(ScoringMode scoringMode, HitResult hitResult, HitResult maxResult, int expectedScore)

View File

@ -26,7 +26,7 @@ namespace osu.Game.Database
if (score.IsLegacyScore)
return false;
if (score.TotalScoreVersion > 30000002)
if (score.TotalScoreVersion > 30000004)
return false;
// Recalculate the old-style standardised score to see if this was an old lazer score.
@ -249,14 +249,15 @@ namespace osu.Game.Database
int maximumLegacyAccuracyScore = attributes.AccuracyScore;
long maximumLegacyComboScore = (long)Math.Round(attributes.ComboScore * legacyModMultiplier);
double maximumLegacyBonusRatio = attributes.BonusScoreRatio;
long maximumLegacyBonusScore = attributes.BonusScore;
// The part of total score that doesn't include bonus.
double legacyAccScore = maximumLegacyAccuracyScore * score.Accuracy;
// We can not separate the ComboScore from the BonusScore, so we keep the bonus in the ratio.
double comboProportion =
((double)score.LegacyTotalScore - legacyAccScore) / (maximumLegacyComboScore + maximumLegacyBonusScore);
// We assume the bonus proportion only makes up the rest of the score that exceeds maximumLegacyBaseScore.
long maximumLegacyBaseScore = maximumLegacyAccuracyScore + maximumLegacyComboScore;
// The combo proportion is calculated as a proportion of maximumLegacyBaseScore.
double comboProportion = Math.Min(1, (double)score.LegacyTotalScore / maximumLegacyBaseScore);
// The bonus proportion makes up the rest of the score that exceeds maximumLegacyBaseScore.
double bonusProportion = Math.Max(0, ((long)score.LegacyTotalScore - maximumLegacyBaseScore) * maximumLegacyBonusRatio);
double modMultiplier = score.Mods.Select(m => m.ScoreMultiplier).Aggregate(1.0, (c, n) => c * n);
@ -264,9 +265,92 @@ namespace osu.Game.Database
switch (score.Ruleset.OnlineID)
{
case 0:
if (score.MaxCombo == 0 || score.Accuracy == 0)
{
return (long)Math.Round((
0
+ 500000 * Math.Pow(score.Accuracy, 5)
+ bonusProportion) * modMultiplier);
}
// Assumptions:
// - sliders and slider ticks are uniformly distributed in the beatmap, and thus can be ignored without losing much precision.
// We thus consider a map of hit-circles only, which gives objectCount == maximumCombo.
// - the Ok/Meh hit results are uniformly spread in the score, and thus can be ignored without losing much precision.
// We simplify and consider each hit result to have the same hit value of `300 * score.Accuracy`
// (which represents the average hit value over the entire play),
// which allows us to isolate the accuracy multiplier.
// This is a very ballpark estimate of the maximum magnitude of the combo portion in score V1.
// It is derived by assuming a full combo play and summing up the contribution to combo portion from each individual object.
// Because each object's combo contribution is proportional to the current combo at the time of judgement,
// this can be roughly represented by summing / integrating f(combo) = combo.
// All mod- and beatmap-dependent multipliers and constants are not included here,
// as we will only be using the magnitude of this to compute ratios.
int maximumLegacyCombo = attributes.MaxCombo;
double maximumAchievableComboPortionInScoreV1 = Math.Pow(maximumLegacyCombo, 2);
// Similarly, estimate the maximum magnitude of the combo portion in standardised score.
// Roughly corresponds to integrating f(combo) = combo ^ COMBO_EXPONENT (omitting constants)
double maximumAchievableComboPortionInStandardisedScore = Math.Pow(maximumLegacyCombo, 1 + ScoreProcessor.COMBO_EXPONENT);
double comboPortionInScoreV1 = maximumAchievableComboPortionInScoreV1 * comboProportion / score.Accuracy;
// This is - roughly - how much score, in the combo portion, the longest combo on this particular play would gain in score V1.
double comboPortionFromLongestComboInScoreV1 = Math.Pow(score.MaxCombo, 2);
// Same for standardised score.
double comboPortionFromLongestComboInStandardisedScore = Math.Pow(score.MaxCombo, 1 + ScoreProcessor.COMBO_EXPONENT);
// Calculate how many times the longest combo the user has achieved in the play can repeat
// without exceeding the combo portion in score V1 as achieved by the player.
// This is a pessimistic estimate; it intentionally does not operate on object count and uses only score instead.
double maximumOccurrencesOfLongestCombo = Math.Floor(comboPortionInScoreV1 / comboPortionFromLongestComboInScoreV1);
double comboPortionFromRepeatedLongestCombosInScoreV1 = maximumOccurrencesOfLongestCombo * comboPortionFromLongestComboInScoreV1;
double remainingComboPortionInScoreV1 = comboPortionInScoreV1 - comboPortionFromRepeatedLongestCombosInScoreV1;
// `remainingComboPortionInScoreV1` is in the "score ballpark" realm, which means it's proportional to combo squared.
// To convert that back to a raw combo length, we need to take the square root...
double remainingCombo = Math.Sqrt(remainingComboPortionInScoreV1);
// ...and then based on that raw combo length, we calculate how much this last combo is worth in standardised score.
double remainingComboPortionInStandardisedScore = Math.Pow(remainingCombo, 1 + ScoreProcessor.COMBO_EXPONENT);
double lowerEstimateOfComboPortionInStandardisedScore
= maximumOccurrencesOfLongestCombo * comboPortionFromLongestComboInStandardisedScore
+ remainingComboPortionInStandardisedScore;
// Compute approximate upper estimate new score for that play.
// This time, divide the remaining combo among remaining objects equally to achieve longest possible combo lengths.
// There is no rigorous proof that doing this will yield a correct upper bound, but it seems to work out in practice.
remainingComboPortionInScoreV1 = comboPortionInScoreV1 - comboPortionFromLongestComboInScoreV1;
double remainingCountOfObjectsGivingCombo = maximumLegacyCombo - score.MaxCombo - score.Statistics.GetValueOrDefault(HitResult.Miss);
// Because we assumed all combos were equal, `remainingComboPortionInScoreV1`
// can be approximated by n * x^2, wherein n is the assumed number of equal combos,
// and x is the assumed length of every one of those combos.
// The remaining count of objects giving combo is, using those terms, equal to n * x.
// Therefore, dividing the two will result in x, i.e. the assumed length of the remaining combos.
double lengthOfRemainingCombos = remainingCountOfObjectsGivingCombo > 0
? remainingComboPortionInScoreV1 / remainingCountOfObjectsGivingCombo
: 0;
// In standardised scoring, each combo yields a score proportional to combo length to the power 1 + COMBO_EXPONENT.
// Using the symbols introduced above, that would be x ^ 1.5 per combo, n times (because there are n assumed equal-length combos).
// However, because `remainingCountOfObjectsGivingCombo` - using the symbols introduced above - is assumed to be equal to n * x,
// we can skip adding the 1 and just multiply by x ^ 0.5.
remainingComboPortionInStandardisedScore = remainingCountOfObjectsGivingCombo * Math.Pow(lengthOfRemainingCombos, ScoreProcessor.COMBO_EXPONENT);
double upperEstimateOfComboPortionInStandardisedScore = comboPortionFromLongestComboInStandardisedScore + remainingComboPortionInStandardisedScore;
// Approximate by combining lower and upper estimates.
// As the lower-estimate is very pessimistic, we use a 30/70 ratio
// and cap it with 1.2 times the middle-point to avoid overestimates.
double estimatedComboPortionInStandardisedScore = Math.Min(
0.3 * lowerEstimateOfComboPortionInStandardisedScore + 0.7 * upperEstimateOfComboPortionInStandardisedScore,
1.2 * (lowerEstimateOfComboPortionInStandardisedScore + upperEstimateOfComboPortionInStandardisedScore) / 2
);
double newComboScoreProportion = estimatedComboPortionInStandardisedScore / maximumAchievableComboPortionInStandardisedScore;
return (long)Math.Round((
700000 * comboProportion
+ 300000 * Math.Pow(score.Accuracy, 10)
500000 * newComboScoreProportion * score.Accuracy
+ 500000 * Math.Pow(score.Accuracy, 5)
+ bonusProportion) * modMultiplier);
case 1:

View File

@ -19,5 +19,15 @@ namespace osu.Game.Rulesets.Scoring.Legacy
/// A ratio of standardised score to legacy score for the bonus part of total score.
/// </summary>
public double BonusScoreRatio;
/// <summary>
/// The bonus portion of the legacy (ScoreV1) total score.
/// </summary>
public int BonusScore;
/// <summary>
/// The max combo of the legacy (ScoreV1) total score.
/// </summary>
public int MaxCombo;
}
}

View File

@ -21,6 +21,14 @@ namespace osu.Game.Rulesets.Scoring
{
public partial class ScoreProcessor : JudgementProcessor
{
/// <summary>
/// The exponent applied to combo in the default implementation of <see cref="GetComboScoreChange"/>.
/// </summary>
/// <remarks>
/// If a custom implementation overrides <see cref="GetComboScoreChange"/> this may not be relevant.
/// </remarks>
public const double COMBO_EXPONENT = 0.5;
public const double MAX_SCORE = 1000000;
private const double accuracy_cutoff_x = 1;
@ -293,7 +301,7 @@ namespace osu.Game.Rulesets.Scoring
protected virtual double GetBonusScoreChange(JudgementResult result) => Judgement.ToNumericResult(result.Type);
protected virtual double GetComboScoreChange(JudgementResult result) => Judgement.ToNumericResult(result.Type) * (1 + result.ComboAfterJudgement / 10d);
protected virtual double GetComboScoreChange(JudgementResult result) => Judgement.ToNumericResult(result.Judgement.MaxResult) * Math.Pow(result.ComboAfterJudgement, COMBO_EXPONENT);
protected virtual void ApplyScoreChange(JudgementResult result)
{
@ -317,8 +325,8 @@ namespace osu.Game.Rulesets.Scoring
protected virtual double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion)
{
return 700000 * comboProgress +
300000 * Math.Pow(Accuracy.Value, 10) * accuracyProgress +
return 500000 * Accuracy.Value * comboProgress +
500000 * Math.Pow(Accuracy.Value, 5) * accuracyProgress +
bonusPortion;
}

View File

@ -31,9 +31,10 @@ namespace osu.Game.Scoring.Legacy
/// <item><description>30000002: Score stored to replay calculated using the Score V2 algorithm. Legacy scores on this version are candidate to Score V1 -> V2 conversion.</description></item>
/// <item><description>30000003: First version after converting legacy total score to standardised.</description></item>
/// <item><description>30000004: Fixed mod multipliers during legacy score conversion. Reconvert all scores.</description></item>
/// <item><description>30000005: Introduce combo exponent in the osu! gamemode. Reconvert all scores.</description></item>
/// </list>
/// </remarks>
public const int LATEST_VERSION = 30000004;
public const int LATEST_VERSION = 30000005;
/// <summary>
/// The first stable-compatible YYYYMMDD format version given to lazer usage of replays.