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;
|
2023-06-29 16:19:10 +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>
|
|
|
|
/// Updates a legacy <see cref="ScoreInfo"/> to standardised scoring.
|
|
|
|
/// </summary>
|
|
|
|
/// <param name="score">The score to update.</param>
|
|
|
|
/// <param name="beatmaps">A <see cref="BeatmapManager"/> used for <see cref="WorkingBeatmap"/> lookups.</param>
|
|
|
|
public static void UpdateFromLegacy(ScoreInfo score, BeatmapManager beatmaps)
|
|
|
|
{
|
|
|
|
score.TotalScore = convertFromLegacyTotalScore(score, beatmaps);
|
|
|
|
score.Accuracy = ComputeAccuracy(score);
|
|
|
|
}
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
/// Updates a legacy <see cref="ScoreInfo"/> to standardised scoring.
|
|
|
|
/// </summary>
|
|
|
|
/// <param name="score">The score to update.</param>
|
|
|
|
/// <param name="difficulty">The beatmap difficulty.</param>
|
|
|
|
/// <param name="attributes">The legacy scoring attributes for the beatmap which the score was set on.</param>
|
|
|
|
public static void UpdateFromLegacy(ScoreInfo score, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes)
|
|
|
|
{
|
|
|
|
score.TotalScore = convertFromLegacyTotalScore(score, difficulty, attributes);
|
|
|
|
score.Accuracy = ComputeAccuracy(score);
|
|
|
|
}
|
|
|
|
|
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>
|
|
|
|
/// <param name="beatmaps">A <see cref="BeatmapManager"/> used for <see cref="WorkingBeatmap"/> lookups.</param>
|
|
|
|
/// <returns>The standardised total score.</returns>
|
2023-12-21 14:08:10 +08:00
|
|
|
private static long convertFromLegacyTotalScore(ScoreInfo score, BeatmapManager beatmaps)
|
2023-06-28 14:04:13 +08:00
|
|
|
{
|
|
|
|
if (!score.IsLegacyScore)
|
|
|
|
return score.TotalScore;
|
|
|
|
|
2023-07-04 13:19:25 +08:00
|
|
|
WorkingBeatmap beatmap = beatmaps.GetWorkingBeatmap(score.BeatmapInfo);
|
|
|
|
Ruleset ruleset = score.Ruleset.CreateInstance();
|
2023-06-28 14:04:13 +08:00
|
|
|
|
2023-06-29 16:24:37 +08:00
|
|
|
if (ruleset is not ILegacyRuleset legacyRuleset)
|
|
|
|
return score.TotalScore;
|
|
|
|
|
2023-07-09 23:01:28 +08:00
|
|
|
var mods = score.Mods;
|
|
|
|
if (mods.Any(mod => mod is ModScoreV2))
|
|
|
|
return score.TotalScore;
|
|
|
|
|
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);
|
2023-06-28 14:04:13 +08:00
|
|
|
|
2023-12-21 14:08:10 +08:00
|
|
|
return convertFromLegacyTotalScore(score, LegacyBeatmapConversionDifficultyInfo.FromBeatmap(beatmap.Beatmap), 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>
|
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>
|
2023-12-21 14:08:10 +08:00
|
|
|
private static long convertFromLegacyTotalScore(ScoreInfo score, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes)
|
2023-06-28 14:04:13 +08:00
|
|
|
{
|
|
|
|
if (!score.IsLegacyScore)
|
|
|
|
return score.TotalScore;
|
|
|
|
|
2023-06-29 16:19:10 +08:00
|
|
|
Debug.Assert(score.LegacyTotalScore != null);
|
|
|
|
|
2023-10-02 15:54:46 +08:00
|
|
|
Ruleset ruleset = score.Ruleset.CreateInstance();
|
|
|
|
if (ruleset is not ILegacyRuleset legacyRuleset)
|
|
|
|
return 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;
|
|
|
|
// We can not separate the ComboScore from the BonusScore, so we keep the bonus in the ratio.
|
2023-12-23 20:25:20 +08:00
|
|
|
// Note that `maximumLegacyComboScore + maximumLegacyBonusScore` can actually be 0
|
|
|
|
// when playing a beatmap with no bonus objects, with mods that have a 0.0x multiplier on stable (relax/autopilot).
|
|
|
|
// In such cases, just assume 0.
|
|
|
|
double comboProportion = maximumLegacyComboScore + maximumLegacyBonusScore > 0
|
|
|
|
? ((double)score.LegacyTotalScore - legacyAccScore) / (maximumLegacyComboScore + maximumLegacyBonusScore)
|
|
|
|
: 0;
|
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);
|
|
|
|
|
2023-12-23 20:07:29 +08:00
|
|
|
long convertedTotalScore;
|
|
|
|
|
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
|
|
|
{
|
2023-07-16 05:20:49 +08:00
|
|
|
return (long)Math.Round((
|
|
|
|
0
|
2023-11-21 18:27:03 +08:00
|
|
|
+ 500000 * Math.Pow(score.Accuracy, 5)
|
2023-07-16 05:20:49 +08:00
|
|
|
+ bonusProportion) * modMultiplier);
|
2023-11-20 10:05:46 +08:00
|
|
|
}
|
2023-07-16 05:20:49 +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.
|
|
|
|
// 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;
|
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);
|
|
|
|
|
|
|
|
double upperEstimateOfComboPortionInStandardisedScore = comboPortionFromLongestComboInStandardisedScore + remainingComboPortionInStandardisedScore;
|
|
|
|
|
|
|
|
// 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
|
|
|
|
2023-12-23 20:07:29 +08:00
|
|
|
convertedTotalScore = (long)Math.Round((
|
2023-11-20 07:02:58 +08:00
|
|
|
500000 * newComboScoreProportion * score.Accuracy
|
|
|
|
+ 500000 * Math.Pow(score.Accuracy, 5)
|
2023-06-28 14:04:13 +08:00
|
|
|
+ bonusProportion) * modMultiplier);
|
2023-12-23 20:07:29 +08:00
|
|
|
break;
|
2023-06-28 14:04:13 +08:00
|
|
|
|
|
|
|
case 1:
|
2023-12-23 20:07:29 +08:00
|
|
|
convertedTotalScore = (long)Math.Round((
|
2023-06-28 14:04:13 +08:00
|
|
|
250000 * comboProportion
|
|
|
|
+ 750000 * Math.Pow(score.Accuracy, 3.6)
|
|
|
|
+ bonusProportion) * modMultiplier);
|
2023-12-23 20:07:29 +08:00
|
|
|
break;
|
2023-06-28 14:04:13 +08:00
|
|
|
|
|
|
|
case 2:
|
2023-12-23 20:07:29 +08:00
|
|
|
convertedTotalScore = (long)Math.Round((
|
2023-06-28 14:04:13 +08:00
|
|
|
600000 * comboProportion
|
|
|
|
+ 400000 * score.Accuracy
|
|
|
|
+ bonusProportion) * modMultiplier);
|
2023-12-23 20:07:29 +08:00
|
|
|
break;
|
2023-06-28 14:04:13 +08:00
|
|
|
|
|
|
|
case 3:
|
2023-12-23 20:07:29 +08:00
|
|
|
convertedTotalScore = (long)Math.Round((
|
2023-12-20 19:03:13 +08:00
|
|
|
850000 * comboProportion
|
|
|
|
+ 150000 * Math.Pow(score.Accuracy, 2 + 2 * score.Accuracy)
|
2023-06-28 14:04:13 +08:00
|
|
|
+ bonusProportion) * modMultiplier);
|
2023-12-23 20:07:29 +08:00
|
|
|
break;
|
2023-06-28 14:04:13 +08:00
|
|
|
|
|
|
|
default:
|
2023-12-23 20:07:29 +08:00
|
|
|
convertedTotalScore = score.TotalScore;
|
|
|
|
break;
|
2023-06-28 14:04:13 +08:00
|
|
|
}
|
2023-12-23 20:07:29 +08:00
|
|
|
|
|
|
|
if (convertedTotalScore < 0)
|
|
|
|
throw new InvalidOperationException($"Total score conversion operation returned invalid total of {convertedTotalScore}");
|
|
|
|
|
|
|
|
return convertedTotalScore;
|
2023-06-28 14:04:13 +08:00
|
|
|
}
|
|
|
|
|
2023-12-21 14:08:10 +08:00
|
|
|
public static double ComputeAccuracy(ScoreInfo scoreInfo)
|
|
|
|
{
|
|
|
|
Ruleset ruleset = scoreInfo.Ruleset.CreateInstance();
|
|
|
|
ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor();
|
|
|
|
|
|
|
|
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;
|
|
|
|
}
|
|
|
|
|
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
|
|
|
}
|
|
|
|
}
|