2023-06-13 00:08:51 +08:00
// 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.
2023-06-16 03:48:57 +08:00
using System ;
2023-06-13 00:08:51 +08:00
using System.Collections.Generic ;
2024-01-23 18:05:29 +08:00
using System.Diagnostics ;
2023-06-13 00:08:51 +08:00
using System.Linq ;
2023-07-11 03:10:01 +08:00
using osu.Framework.Logging ;
2023-06-13 00:08:51 +08:00
using osu.Game.Beatmaps ;
2023-07-11 03:10:01 +08:00
using osu.Game.Extensions ;
using osu.Game.IO.Legacy ;
2023-06-29 16:24:37 +08:00
using osu.Game.Rulesets ;
2023-06-13 00:08:51 +08:00
using osu.Game.Rulesets.Judgements ;
2023-07-09 23:01:28 +08:00
using osu.Game.Rulesets.Mods ;
2023-06-13 01:12:23 +08:00
using osu.Game.Rulesets.Objects ;
2023-06-13 00:08:51 +08:00
using osu.Game.Rulesets.Scoring ;
2023-09-04 16:43:23 +08:00
using osu.Game.Rulesets.Scoring.Legacy ;
2023-06-13 00:08:51 +08:00
using osu.Game.Scoring ;
namespace osu.Game.Database
{
public static class StandardisedScoreMigrationTools
{
2023-06-16 03:48:57 +08:00
public static bool ShouldMigrateToNewStandardised ( ScoreInfo score )
{
if ( score . IsLegacyScore )
return false ;
2023-12-19 15:13:02 +08:00
if ( score . TotalScoreVersion > 30000002 )
2023-07-14 19:02:25 +08:00
return false ;
2023-06-16 03:48:57 +08:00
// Recalculate the old-style standardised score to see if this was an old lazer score.
bool oldScoreMatchesExpectations = GetOldStandardised ( score ) = = score . TotalScore ;
// Some older scores don't have correct statistics populated, so let's give them benefit of doubt.
bool scoreIsVeryOld = score . Date < new DateTime ( 2023 , 1 , 1 , 0 , 0 , 0 ) ;
return oldScoreMatchesExpectations | | scoreIsVeryOld ;
}
2023-06-13 00:08:51 +08:00
public static long GetNewStandardised ( ScoreInfo score )
{
2023-06-13 04:24:04 +08:00
int maxJudgementIndex = 0 ;
2023-06-13 01:05:00 +08:00
// Avoid retrieving from realm inside loops.
int maxCombo = score . MaxCombo ;
2023-06-13 00:39:38 +08:00
var ruleset = score . Ruleset . CreateInstance ( ) ;
var processor = ruleset . CreateScoreProcessor ( ) ;
2023-06-13 00:08:51 +08:00
2023-06-13 01:15:08 +08:00
processor . TrackHitEvents = false ;
2023-06-13 00:08:51 +08:00
var beatmap = new Beatmap ( ) ;
2023-06-13 00:39:38 +08:00
HitResult maxRulesetJudgement = ruleset . GetHitResults ( ) . First ( ) . result ;
2023-06-13 00:08:51 +08:00
// This is a list of all results, ordered from best to worst.
// We are constructing a "best possible" score from the statistics provided because it's the best we can do.
List < HitResult > sortedHits = score . Statistics
2023-06-13 00:39:38 +08:00
. Where ( kvp = > kvp . Key . AffectsCombo ( ) )
2023-12-21 13:58:23 +08:00
. OrderByDescending ( kvp = > processor . GetBaseScoreForResult ( kvp . Key ) )
2023-06-13 00:08:51 +08:00
. SelectMany ( kvp = > Enumerable . Repeat ( kvp . Key , kvp . Value ) )
. ToList ( ) ;
2023-06-13 04:20:29 +08:00
// Attempt to use maximum statistics from the database.
var maximumJudgements = score . MaximumStatistics
. Where ( kvp = > kvp . Key . AffectsCombo ( ) )
2023-12-21 13:58:23 +08:00
. OrderByDescending ( kvp = > processor . GetBaseScoreForResult ( kvp . Key ) )
2023-06-13 04:20:29 +08:00
. SelectMany ( kvp = > Enumerable . Repeat ( new FakeJudgement ( kvp . Key ) , kvp . Value ) )
. ToList ( ) ;
// Some older scores may not have maximum statistics populated correctly.
// In this case we need to fill them with best-known-defaults.
2023-06-13 00:39:38 +08:00
if ( maximumJudgements . Count ! = sortedHits . Count )
{
maximumJudgements = sortedHits
. Select ( r = > new FakeJudgement ( getMaxJudgementFor ( r , maxRulesetJudgement ) ) )
. ToList ( ) ;
}
2023-06-13 04:20:29 +08:00
// This is required to get the correct maximum combo portion.
2023-06-13 00:08:51 +08:00
foreach ( var judgement in maximumJudgements )
beatmap . HitObjects . Add ( new FakeHit ( judgement ) ) ;
processor . ApplyBeatmap ( beatmap ) ;
2023-06-14 00:45:35 +08:00
processor . Mods . Value = score . Mods ;
2023-06-13 00:08:51 +08:00
2023-06-13 04:20:29 +08:00
// Insert all misses into a queue.
// These will be nibbled at whenever we need to reset the combo.
2023-06-13 00:08:51 +08:00
Queue < HitResult > misses = new Queue < HitResult > ( score . Statistics
. Where ( kvp = > kvp . Key = = HitResult . Miss | | kvp . Key = = HitResult . LargeTickMiss )
. SelectMany ( kvp = > Enumerable . Repeat ( kvp . Key , kvp . Value ) ) ) ;
foreach ( var result in sortedHits )
{
2023-06-13 04:20:29 +08:00
// For the main part of this loop, ignore all misses, as they will be inserted from the queue.
2023-06-13 00:39:38 +08:00
if ( result = = HitResult . Miss | | result = = HitResult . LargeTickMiss )
continue ;
2023-06-13 04:24:04 +08:00
// Reset combo if required.
2023-06-13 01:05:00 +08:00
if ( processor . Combo . Value = = maxCombo )
2023-06-13 04:24:04 +08:00
insertMiss ( ) ;
2023-06-13 00:08:51 +08:00
processor . ApplyResult ( new JudgementResult ( null ! , maximumJudgements [ maxJudgementIndex + + ] )
{
Type = result
} ) ;
}
2023-06-13 04:24:04 +08:00
// Ensure we haven't forgotten any misses.
while ( misses . Count > 0 )
insertMiss ( ) ;
2023-06-13 00:08:51 +08:00
var bonusHits = score . Statistics
. Where ( kvp = > kvp . Key . IsBonus ( ) )
. SelectMany ( kvp = > Enumerable . Repeat ( kvp . Key , kvp . Value ) ) ;
foreach ( var result in bonusHits )
processor . ApplyResult ( new JudgementResult ( null ! , new FakeJudgement ( result ) ) { Type = result } ) ;
2023-06-13 00:39:38 +08:00
// Not true for all scores for whatever reason. Oh well.
// Debug.Assert(processor.HighestCombo.Value == score.MaxCombo);
2023-06-13 00:08:51 +08:00
return processor . TotalScore . Value ;
2023-06-13 04:24:04 +08:00
void insertMiss ( )
{
if ( misses . Count > 0 )
{
processor . ApplyResult ( new JudgementResult ( null ! , maximumJudgements [ maxJudgementIndex + + ] )
{
Type = misses . Dequeue ( ) ,
} ) ;
}
else
{
// We ran out of misses. But we can't let max combo increase beyond the known value,
// so let's forge a miss.
processor . ApplyResult ( new JudgementResult ( null ! , new FakeJudgement ( getMaxJudgementFor ( HitResult . Miss , maxRulesetJudgement ) ) )
{
Type = HitResult . Miss ,
} ) ;
}
}
2023-06-13 00:08:51 +08:00
}
2023-06-13 00:39:38 +08:00
private static HitResult getMaxJudgementFor ( HitResult hitResult , HitResult max )
{
switch ( hitResult )
{
case HitResult . Miss :
case HitResult . Meh :
case HitResult . Ok :
case HitResult . Good :
case HitResult . Great :
case HitResult . Perfect :
return max ;
case HitResult . SmallTickMiss :
case HitResult . SmallTickHit :
return HitResult . SmallTickHit ;
case HitResult . LargeTickMiss :
case HitResult . LargeTickHit :
return HitResult . LargeTickHit ;
}
return HitResult . IgnoreHit ;
}
2023-06-13 00:08:51 +08:00
public static long GetOldStandardised ( ScoreInfo score )
{
double accuracyScore =
2023-12-20 19:23:43 +08:00
( double ) score . Statistics . Where ( kvp = > kvp . Key . AffectsAccuracy ( ) ) . Sum ( kvp = > numericScoreFor ( kvp . Key ) * kvp . Value )
/ score . MaximumStatistics . Where ( kvp = > kvp . Key . AffectsAccuracy ( ) ) . Sum ( kvp = > numericScoreFor ( kvp . Key ) * kvp . Value ) ;
2023-06-13 00:08:51 +08:00
double comboScore = ( double ) score . MaxCombo / score . MaximumStatistics . Where ( kvp = > kvp . Key . AffectsCombo ( ) ) . Sum ( kvp = > kvp . Value ) ;
2023-12-20 19:23:43 +08:00
double bonusScore = score . Statistics . Where ( kvp = > kvp . Key . IsBonus ( ) ) . Sum ( kvp = > numericScoreFor ( kvp . Key ) * kvp . Value ) ;
2023-06-13 00:08:51 +08:00
double accuracyPortion = 0.3 ;
switch ( score . RulesetID )
{
case 1 :
accuracyPortion = 0.75 ;
break ;
case 3 :
accuracyPortion = 0.99 ;
break ;
}
2023-06-14 00:45:35 +08:00
double modMultiplier = 1 ;
foreach ( var mod in score . Mods )
modMultiplier * = mod . ScoreMultiplier ;
2023-06-16 13:01:56 +08:00
return ( long ) Math . Round ( ( 1000000 * ( accuracyPortion * accuracyScore + ( 1 - accuracyPortion ) * comboScore ) + bonusScore ) * modMultiplier ) ;
2023-12-20 19:23:43 +08:00
static int numericScoreFor ( HitResult result )
{
switch ( result )
{
default :
return 0 ;
case HitResult . SmallTickHit :
return 10 ;
case HitResult . LargeTickHit :
return 30 ;
case HitResult . Meh :
return 50 ;
case HitResult . Ok :
return 100 ;
case HitResult . Good :
return 200 ;
case HitResult . Great :
return 300 ;
case HitResult . Perfect :
return 315 ;
case HitResult . SmallBonus :
return 10 ;
case HitResult . LargeBonus :
return 50 ;
}
}
2023-06-13 00:08:51 +08:00
}
2023-06-13 01:12:23 +08:00
2023-12-21 14:08:10 +08:00
/// <summary>
2024-01-23 04:13:06 +08:00
/// Updates a <see cref="ScoreInfo"/> to standardised scoring.
/// This will recompite the score's <see cref="ScoreInfo.Accuracy"/> (always), <see cref="ScoreInfo.Rank"/> (always),
/// and <see cref="ScoreInfo.TotalScore"/> (if the score comes from stable).
/// The total score from stable - if any applicable - will be stored to <see cref="ScoreInfo.LegacyTotalScore"/>.
2023-12-21 14:08:10 +08:00
/// </summary>
/// <param name="score">The score to update.</param>
2024-01-23 03:23:37 +08:00
/// <param name="beatmap">The <see cref="WorkingBeatmap"/> applicable for this score.</param>
public static void UpdateFromLegacy ( ScoreInfo score , WorkingBeatmap beatmap )
2023-12-21 14:08:10 +08:00
{
2024-01-23 03:23:37 +08:00
var ruleset = score . Ruleset . CreateInstance ( ) ;
var scoreProcessor = ruleset . CreateScoreProcessor ( ) ;
2024-01-23 03:25:29 +08:00
// warning: ordering is important here - both total score and ranks are dependent on accuracy!
2024-01-23 03:23:37 +08:00
score . Accuracy = computeAccuracy ( score , scoreProcessor ) ;
score . Rank = computeRank ( score , scoreProcessor ) ;
2024-05-17 20:43:06 +08:00
( score . TotalScoreWithoutMods , score . TotalScore ) = convertFromLegacyTotalScore ( score , ruleset , beatmap ) ;
2023-12-21 14:08:10 +08:00
}
/// <summary>
2024-01-23 04:13:06 +08:00
/// Updates a <see cref="ScoreInfo"/> to standardised scoring.
/// This will recompute the score's <see cref="ScoreInfo.Accuracy"/> (always), <see cref="ScoreInfo.Rank"/> (always),
/// and <see cref="ScoreInfo.TotalScore"/> (if the score comes from stable).
/// The total score from stable - if any applicable - will be stored to <see cref="ScoreInfo.LegacyTotalScore"/>.
2023-12-21 14:08:10 +08:00
/// </summary>
2024-01-23 03:23:37 +08:00
/// <remarks>
/// This overload is intended for server-side flows.
/// See: https://github.com/ppy/osu-queue-score-statistics/blob/3681e92ac91c6c61922094bdbc7e92e6217dd0fc/osu.Server.Queues.ScoreStatisticsProcessor/Commands/Queue/BatchInserter.cs
/// </remarks>
2023-12-21 14:08:10 +08:00
/// <param name="score">The score to update.</param>
2024-01-23 03:23:37 +08:00
/// <param name="ruleset">The <see cref="Ruleset"/> in which the score was set.</param>
2023-12-21 14:08:10 +08:00
/// <param name="difficulty">The beatmap difficulty.</param>
/// <param name="attributes">The legacy scoring attributes for the beatmap which the score was set on.</param>
2024-01-23 03:23:37 +08:00
public static void UpdateFromLegacy ( ScoreInfo score , Ruleset ruleset , LegacyBeatmapConversionDifficultyInfo difficulty , LegacyScoreAttributes attributes )
2023-12-21 14:08:10 +08:00
{
2024-01-23 03:23:37 +08:00
var scoreProcessor = ruleset . CreateScoreProcessor ( ) ;
2024-01-23 03:25:29 +08:00
// warning: ordering is important here - both total score and ranks are dependent on accuracy!
2024-01-23 03:23:37 +08:00
score . Accuracy = computeAccuracy ( score , scoreProcessor ) ;
score . Rank = computeRank ( score , scoreProcessor ) ;
2024-05-17 20:43:06 +08:00
( score . TotalScoreWithoutMods , score . TotalScore ) = convertFromLegacyTotalScore ( score , ruleset , difficulty , attributes ) ;
2023-12-21 14:08:10 +08:00
}
2023-06-28 14:04:13 +08:00
/// <summary>
/// Converts from <see cref="ScoreInfo.LegacyTotalScore"/> to the new standardised scoring of <see cref="ScoreProcessor"/>.
/// </summary>
/// <param name="score">The score to convert the total score of.</param>
2024-01-23 03:23:37 +08:00
/// <param name="ruleset">The <see cref="Ruleset"/> in which the score was set.</param>
/// <param name="beatmap">The <see cref="WorkingBeatmap"/> applicable for this score.</param>
2023-06-28 14:04:13 +08:00
/// <returns>The standardised total score.</returns>
2024-05-17 20:43:06 +08:00
private static ( long withoutMods , long withMods ) convertFromLegacyTotalScore ( ScoreInfo score , Ruleset ruleset , WorkingBeatmap beatmap )
2023-06-28 14:04:13 +08:00
{
if ( ! score . IsLegacyScore )
2024-05-17 20:43:06 +08:00
return ( score . TotalScoreWithoutMods , score . TotalScore ) ;
2023-06-28 14:04:13 +08:00
2023-06-29 16:24:37 +08:00
if ( ruleset is not ILegacyRuleset legacyRuleset )
2024-05-17 20:43:06 +08:00
return ( score . TotalScoreWithoutMods , score . TotalScore ) ;
2023-07-09 23:01:28 +08:00
2023-07-04 17:35:03 +08:00
var playableBeatmap = beatmap . GetPlayableBeatmap ( ruleset . RulesetInfo , score . Mods ) ;
if ( playableBeatmap . HitObjects . Count = = 0 )
throw new InvalidOperationException ( "Beatmap contains no hit objects!" ) ;
2023-07-04 16:32:54 +08:00
ILegacyScoreSimulator sv1Simulator = legacyRuleset . CreateLegacyScoreSimulator ( ) ;
2023-09-04 16:43:23 +08:00
LegacyScoreAttributes attributes = sv1Simulator . Simulate ( beatmap , playableBeatmap ) ;
2024-05-17 20:43:06 +08:00
var legacyBeatmapConversionDifficultyInfo = LegacyBeatmapConversionDifficultyInfo . FromBeatmap ( beatmap . Beatmap ) ;
2023-06-28 14:04:13 +08:00
2024-05-17 20:43:06 +08:00
var mods = score . Mods ;
if ( mods . Any ( mod = > mod is ModScoreV2 ) )
return ( ( long ) Math . Round ( score . TotalScore / sv1Simulator . GetLegacyScoreMultiplier ( mods , legacyBeatmapConversionDifficultyInfo ) ) , score . TotalScore ) ;
return convertFromLegacyTotalScore ( score , ruleset , legacyBeatmapConversionDifficultyInfo , attributes ) ;
2023-06-28 14:04:13 +08:00
}
/// <summary>
/// Converts from <see cref="ScoreInfo.LegacyTotalScore"/> to the new standardised scoring of <see cref="ScoreProcessor"/>.
/// </summary>
/// <param name="score">The score to convert the total score of.</param>
2024-01-23 03:23:37 +08:00
/// <param name="ruleset">The <see cref="Ruleset"/> in which the score was set.</param>
2023-10-02 15:54:46 +08:00
/// <param name="difficulty">The beatmap difficulty.</param>
/// <param name="attributes">The legacy scoring attributes for the beatmap which the score was set on.</param>
2023-06-28 14:04:13 +08:00
/// <returns>The standardised total score.</returns>
2024-05-17 20:43:06 +08:00
private static ( long withoutMods , long withMods ) convertFromLegacyTotalScore ( ScoreInfo score , Ruleset ruleset , LegacyBeatmapConversionDifficultyInfo difficulty , LegacyScoreAttributes attributes )
2023-06-28 14:04:13 +08:00
{
if ( ! score . IsLegacyScore )
2024-05-17 20:43:06 +08:00
return ( score . TotalScoreWithoutMods , score . TotalScore ) ;
2023-06-28 14:04:13 +08:00
2024-01-23 18:05:29 +08:00
Debug . Assert ( score . LegacyTotalScore ! = null ) ;
2023-10-02 15:54:46 +08:00
if ( ruleset is not ILegacyRuleset legacyRuleset )
2024-05-17 20:43:06 +08:00
return ( score . TotalScoreWithoutMods , score . TotalScore ) ;
2023-06-28 14:04:13 +08:00
2023-10-02 15:54:46 +08:00
double legacyModMultiplier = legacyRuleset . CreateLegacyScoreSimulator ( ) . GetLegacyScoreMultiplier ( score . Mods , difficulty ) ;
2023-09-04 16:43:23 +08:00
int maximumLegacyAccuracyScore = attributes . AccuracyScore ;
2023-10-02 15:54:46 +08:00
long maximumLegacyComboScore = ( long ) Math . Round ( attributes . ComboScore * legacyModMultiplier ) ;
2023-09-04 16:43:23 +08:00
double maximumLegacyBonusRatio = attributes . BonusScoreRatio ;
2023-11-20 06:53:05 +08:00
long maximumLegacyBonusScore = attributes . BonusScore ;
2023-06-28 14:04:13 +08:00
2023-11-25 06:07:27 +08:00
double legacyAccScore = maximumLegacyAccuracyScore * score . Accuracy ;
2024-01-08 17:47:22 +08:00
double comboProportion ;
if ( maximumLegacyComboScore + maximumLegacyBonusScore > 0 )
{
// We can not separate the ComboScore from the BonusScore, so we keep the bonus in the ratio.
comboProportion = Math . Max ( ( double ) score . LegacyTotalScore - legacyAccScore , 0 ) / ( maximumLegacyComboScore + maximumLegacyBonusScore ) ;
}
else
{
// Two possible causes:
// the beatmap has no bonus objects *AND*
// either the active mods have a zero mod multiplier, in which case assume 0,
// or the *beatmap* has a zero `difficultyPeppyStars` (or just no combo-giving objects), in which case assume 1.
comboProportion = legacyModMultiplier = = 0 ? 0 : 1 ;
}
2023-06-28 14:04:13 +08:00
2023-11-25 06:07:27 +08:00
// We assume the bonus proportion only makes up the rest of the score that exceeds maximumLegacyBaseScore.
long maximumLegacyBaseScore = maximumLegacyAccuracyScore + maximumLegacyComboScore ;
2023-06-29 16:19:10 +08:00
double bonusProportion = Math . Max ( 0 , ( ( long ) score . LegacyTotalScore - maximumLegacyBaseScore ) * maximumLegacyBonusRatio ) ;
2023-06-28 14:04:13 +08:00
2023-10-02 15:54:46 +08:00
double modMultiplier = score . Mods . Select ( m = > m . ScoreMultiplier ) . Aggregate ( 1.0 , ( c , n ) = > c * n ) ;
2024-05-17 20:43:06 +08:00
long convertedTotalScoreWithoutMods ;
2023-12-23 20:07:29 +08:00
2023-06-28 14:04:13 +08:00
switch ( score . Ruleset . OnlineID )
{
case 0 :
2023-08-24 13:55:24 +08:00
if ( score . MaxCombo = = 0 | | score . Accuracy = = 0 )
2023-11-20 10:05:46 +08:00
{
2024-05-17 20:43:06 +08:00
convertedTotalScoreWithoutMods = ( long ) Math . Round (
2023-07-16 05:20:49 +08:00
0
2023-11-21 18:27:03 +08:00
+ 500000 * Math . Pow ( score . Accuracy , 5 )
2024-05-17 20:43:06 +08:00
+ bonusProportion ) ;
break ;
2023-11-20 10:05:46 +08:00
}
2023-07-16 05:20:49 +08:00
2024-02-24 16:55:51 +08:00
// see similar check above.
// if there is no legacy combo score, all combo conversion operations below
// are either pointless or wildly wrong.
if ( maximumLegacyComboScore + maximumLegacyBonusScore = = 0 )
{
2024-05-17 20:43:06 +08:00
convertedTotalScoreWithoutMods = ( long ) Math . Round (
2024-02-24 16:55:51 +08:00
500000 * comboProportion // as above, zero if mods result in zero multiplier, one otherwise
+ 500000 * Math . Pow ( score . Accuracy , 5 )
2024-05-17 20:43:06 +08:00
+ bonusProportion ) ;
break ;
2024-02-24 16:55:51 +08:00
}
2023-11-22 11:50:08 +08:00
// 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.
2023-11-21 18:54:07 +08:00
// - the Ok/Meh hit results are uniformly spread in the score, and thus can be ignored without losing much precision.
2023-11-22 11:50:08 +08:00
// 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.
2023-11-25 06:07:27 +08:00
int maximumLegacyCombo = attributes . MaxCombo ;
2023-11-22 11:50:08 +08:00
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 ) ;
// 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 ) ;
2023-12-19 04:56:56 +08:00
// We estimate the combo portion of the score in score V1 terms.
// The division by accuracy is supposed to lessen the impact of accuracy on the combo portion,
// but in some edge cases it cannot sanely undo it.
// Therefore the resultant value is clamped from both sides for sanity.
// The clamp from below to `comboPortionFromLongestComboInScoreV1` targets near-FC scores wherein
// the player had bad accuracy at the end of their longest combo, which causes the division by accuracy
// to underestimate the combo portion.
2023-12-20 00:25:15 +08:00
// Ideally, this would be clamped from above to `maximumAchievableComboPortionInScoreV1` too,
// but in practice this appears to fail for some scores (https://github.com/ppy/osu/pull/25876#issuecomment-1862248413).
// TODO: investigate the above more closely
double comboPortionInScoreV1 = Math . Max ( maximumAchievableComboPortionInScoreV1 * comboProportion / score . Accuracy , comboPortionFromLongestComboInScoreV1 ) ;
2023-12-19 04:56:56 +08:00
2023-11-22 11:50:08 +08:00
// 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.
2024-03-07 04:30:31 +08:00
// This intentionally does not operate on object count and uses only score instead.
2023-11-22 11:50:08 +08:00
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 ) ;
2024-03-06 18:40:10 +08:00
double scoreBasedEstimateOfComboPortionInStandardisedScore
2023-11-22 11:50:08 +08:00
= 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.
remainingComboPortionInScoreV1 = comboPortionInScoreV1 - comboPortionFromLongestComboInScoreV1 ;
2023-11-25 05:05:24 +08:00
double remainingCountOfObjectsGivingCombo = maximumLegacyCombo - score . MaxCombo - score . Statistics . GetValueOrDefault ( HitResult . Miss ) ;
2023-11-22 11:50:08 +08:00
// 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 ) ;
2024-03-06 18:40:10 +08:00
double objectCountBasedEstimateOfComboPortionInStandardisedScore = comboPortionFromLongestComboInStandardisedScore + remainingComboPortionInStandardisedScore ;
// Enforce some invariants on both of the estimates.
// In rare cases they can produce invalid results.
scoreBasedEstimateOfComboPortionInStandardisedScore =
Math . Clamp ( scoreBasedEstimateOfComboPortionInStandardisedScore , 0 , maximumAchievableComboPortionInStandardisedScore ) ;
objectCountBasedEstimateOfComboPortionInStandardisedScore =
Math . Clamp ( objectCountBasedEstimateOfComboPortionInStandardisedScore , 0 , maximumAchievableComboPortionInStandardisedScore ) ;
double lowerEstimateOfComboPortionInStandardisedScore = Math . Min ( scoreBasedEstimateOfComboPortionInStandardisedScore , objectCountBasedEstimateOfComboPortionInStandardisedScore ) ;
double upperEstimateOfComboPortionInStandardisedScore = Math . Max ( scoreBasedEstimateOfComboPortionInStandardisedScore , objectCountBasedEstimateOfComboPortionInStandardisedScore ) ;
2023-11-22 11:50:08 +08:00
// Approximate by combining lower and upper estimates.
2023-07-16 05:20:49 +08:00
// As the lower-estimate is very pessimistic, we use a 30/70 ratio
2023-11-22 11:50:08 +08:00
// 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
2023-07-16 05:20:49 +08:00
) ;
2023-11-22 11:50:08 +08:00
double newComboScoreProportion = estimatedComboPortionInStandardisedScore / maximumAchievableComboPortionInStandardisedScore ;
2023-07-16 05:20:49 +08:00
2024-05-17 20:43:06 +08:00
convertedTotalScoreWithoutMods = ( long ) Math . Round (
2023-11-20 07:02:58 +08:00
500000 * newComboScoreProportion * score . Accuracy
+ 500000 * Math . Pow ( score . Accuracy , 5 )
2024-05-17 20:43:06 +08:00
+ bonusProportion ) ;
2023-12-23 20:07:29 +08:00
break ;
2023-06-28 14:04:13 +08:00
case 1 :
2024-05-17 20:43:06 +08:00
convertedTotalScoreWithoutMods = ( long ) Math . Round (
2023-06-28 14:04:13 +08:00
250000 * comboProportion
+ 750000 * Math . Pow ( score . Accuracy , 3.6 )
2024-05-17 20:43:06 +08:00
+ bonusProportion ) ;
2023-12-23 20:07:29 +08:00
break ;
2023-06-28 14:04:13 +08:00
case 2 :
2024-01-03 22:03:06 +08:00
// 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 ) ;
2024-05-17 20:43:06 +08:00
convertedTotalScoreWithoutMods = ( long ) Math . Round (
2024-01-03 22:03:06 +08:00
comboPortion * estimateComboProportionForCatch ( attributes . MaxCombo , score . MaxCombo , score . Statistics . GetValueOrDefault ( HitResult . Miss ) )
+ dropletsPortion * dropletsHit
2024-05-17 20:43:06 +08:00
+ bonusProportion ) ;
2023-12-23 20:07:29 +08:00
break ;
2023-06-28 14:04:13 +08:00
case 3 :
2024-05-17 20:43:06 +08:00
convertedTotalScoreWithoutMods = ( long ) Math . Round (
2023-12-20 19:03:13 +08:00
850000 * comboProportion
2024-01-23 03:25:29 +08:00
+ 150000 * Math . Pow ( score . Accuracy , 2 + 2 * score . Accuracy )
2024-05-17 20:43:06 +08:00
+ bonusProportion ) ;
2023-12-23 20:07:29 +08:00
break ;
2023-06-28 14:04:13 +08:00
default :
2024-05-17 20:43:06 +08:00
return ( score . TotalScoreWithoutMods , score . TotalScore ) ;
2023-06-28 14:04:13 +08:00
}
2023-12-23 20:07:29 +08:00
2024-05-17 20:43:06 +08:00
if ( convertedTotalScoreWithoutMods < 0 )
throw new InvalidOperationException ( $"Total score conversion operation returned invalid total of {convertedTotalScoreWithoutMods}" ) ;
2023-12-23 20:07:29 +08:00
2024-05-17 20:43:06 +08:00
long convertedTotalScore = ( long ) Math . Round ( convertedTotalScoreWithoutMods * modMultiplier ) ;
return ( convertedTotalScoreWithoutMods , convertedTotalScore ) ;
2023-06-28 14:04:13 +08:00
}
2024-01-03 22:03:06 +08:00
/// <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 )
{
2024-01-09 02:49:22 +08:00
int assumedCombosCount = ( int ) Math . Floor ( ( double ) remainingCombo / assumedLengthOfRemainingCombos ) ;
totalDroppedScore + = assumedCombosCount * estimateDroppedComboScoreAfterMiss ( assumedLengthOfRemainingCombos ) ;
2024-01-03 22:03:06 +08:00
2024-01-09 02:49:22 +08:00
remainingCombo - = assumedCombosCount * assumedLengthOfRemainingCombos ;
if ( remainingCombo > 0 )
totalDroppedScore + = estimateDroppedComboScoreAfterMiss ( remainingCombo ) ;
2024-01-03 22:03:06 +08:00
}
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 ) ;
}
}
2024-01-23 03:23:37 +08:00
private static double computeAccuracy ( ScoreInfo scoreInfo , ScoreProcessor scoreProcessor )
{
2023-12-21 14:08:10 +08:00
int baseScore = scoreInfo . Statistics . Where ( kvp = > kvp . Key . AffectsAccuracy ( ) )
. Sum ( kvp = > kvp . Value * scoreProcessor . GetBaseScoreForResult ( kvp . Key ) ) ;
int maxBaseScore = scoreInfo . MaximumStatistics . Where ( kvp = > kvp . Key . AffectsAccuracy ( ) )
. Sum ( kvp = > kvp . Value * scoreProcessor . GetBaseScoreForResult ( kvp . Key ) ) ;
return maxBaseScore = = 0 ? 1 : baseScore / ( double ) maxBaseScore ;
}
2024-01-23 03:23:37 +08:00
public static ScoreRank ComputeRank ( ScoreInfo scoreInfo ) = > computeRank ( scoreInfo , scoreInfo . Ruleset . CreateInstance ( ) . CreateScoreProcessor ( ) ) ;
private static ScoreRank computeRank ( ScoreInfo scoreInfo , ScoreProcessor scoreProcessor )
2024-01-18 02:14:53 +08:00
{
2024-01-23 03:23:37 +08:00
var rank = scoreProcessor . RankFromScore ( scoreInfo . Accuracy , scoreInfo . Statistics ) ;
2024-01-18 02:14:53 +08:00
foreach ( var mod in scoreInfo . Mods . OfType < IApplicableToScoreProcessor > ( ) )
rank = mod . AdjustRank ( rank , scoreInfo . Accuracy ) ;
return rank ;
}
2023-07-11 03:10:01 +08:00
/// <summary>
/// Used to populate the <paramref name="score"/> model using data parsed from its corresponding replay file.
/// </summary>
/// <param name="score">The score to run population from replay for.</param>
/// <param name="files">A <see cref="RealmFileStore"/> instance to use for fetching replay.</param>
/// <param name="populationFunc">
/// Delegate describing the population to execute.
/// The delegate's argument is a <see cref="SerializationReader"/> instance which permits to read data from the replay stream.
/// </param>
public static void PopulateFromReplay ( this ScoreInfo score , RealmFileStore files , Action < SerializationReader > populationFunc )
{
string? replayFilename = score . Files . FirstOrDefault ( f = > f . Filename . EndsWith ( @".osr" , StringComparison . InvariantCultureIgnoreCase ) ) ? . File . GetStoragePath ( ) ;
if ( replayFilename = = null )
return ;
try
{
using ( var stream = files . Store . GetStream ( replayFilename ) )
{
if ( stream = = null )
return ;
using ( SerializationReader sr = new SerializationReader ( stream ) )
populationFunc . Invoke ( sr ) ;
}
}
catch ( Exception e )
{
Logger . Error ( e , $"Failed to read replay {replayFilename} during score migration" , LoggingTarget . Database ) ;
}
}
2023-06-13 01:12:23 +08:00
private class FakeHit : HitObject
{
private readonly Judgement judgement ;
public override Judgement CreateJudgement ( ) = > judgement ;
public FakeHit ( Judgement judgement )
{
this . judgement = judgement ;
}
}
private class FakeJudgement : Judgement
{
public override HitResult MaxResult { get ; }
2023-06-13 04:20:29 +08:00
public FakeJudgement ( HitResult maxResult )
2023-06-13 01:12:23 +08:00
{
2023-06-13 04:20:29 +08:00
MaxResult = maxResult ;
2023-06-13 01:12:23 +08:00
}
}
2023-06-13 00:08:51 +08:00
}
}