2021-09-30 17:21:16 +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.
using System ;
using System.Collections.Generic ;
2022-09-09 12:57:01 +08:00
using System.Diagnostics ;
2021-09-30 17:21:16 +08:00
using System.Linq ;
using System.Threading ;
2022-01-07 23:40:14 +08:00
using Newtonsoft.Json ;
2021-09-30 17:21:16 +08:00
using osu.Framework.Logging ;
using osu.Framework.Platform ;
using osu.Game.Beatmaps ;
using osu.Game.Database ;
using osu.Game.IO.Archives ;
2021-12-14 18:47:11 +08:00
using osu.Game.Rulesets ;
2021-09-30 17:21:16 +08:00
using osu.Game.Scoring.Legacy ;
2022-07-08 11:16:06 +08:00
using osu.Game.Online.API ;
using osu.Game.Online.API.Requests ;
using osu.Game.Online.API.Requests.Responses ;
2022-09-08 21:06:44 +08:00
using osu.Game.Rulesets.Scoring ;
2021-12-06 21:47:00 +08:00
using Realms ;
2021-09-30 17:21:16 +08:00
namespace osu.Game.Scoring
{
2022-06-16 17:53:13 +08:00
public class ScoreImporter : RealmArchiveModelImporter < ScoreInfo >
2021-09-30 17:21:16 +08:00
{
public override IEnumerable < string > HandledExtensions = > new [ ] { ".osr" } ;
protected override string [ ] HashableFileTypes = > new [ ] { ".osr" } ;
2021-12-14 18:47:11 +08:00
private readonly RulesetStore rulesets ;
2021-09-30 17:21:16 +08:00
private readonly Func < BeatmapManager > beatmaps ;
2022-07-08 11:16:06 +08:00
private readonly IAPIProvider api ;
2022-09-09 12:57:01 +08:00
public ScoreImporter ( RulesetStore rulesets , Func < BeatmapManager > beatmaps , Storage storage , RealmAccess realm , IAPIProvider api )
2022-01-24 18:59:58 +08:00
: base ( storage , realm )
2021-09-30 17:21:16 +08:00
{
this . rulesets = rulesets ;
this . beatmaps = beatmaps ;
2022-07-08 11:16:06 +08:00
this . api = api ;
2021-09-30 17:21:16 +08:00
}
2023-09-27 16:02:47 +08:00
protected override ScoreInfo ? CreateModel ( ArchiveReader archive , ImportParameters parameters )
2021-09-30 17:21:16 +08:00
{
2023-01-10 03:51:38 +08:00
string name = archive . Filenames . First ( f = > f . EndsWith ( ".osr" , StringComparison . OrdinalIgnoreCase ) ) ;
using ( var stream = archive . GetStream ( name ) )
2021-09-30 17:21:16 +08:00
{
try
{
return new DatabasedLegacyScoreDecoder ( rulesets , beatmaps ( ) ) . Parse ( stream ) . ScoreInfo ;
}
2023-09-27 15:55:03 +08:00
catch ( LegacyScoreDecoder . BeatmapNotFoundException notFound )
2021-09-30 17:21:16 +08:00
{
2023-09-27 15:55:03 +08:00
Logger . Log ( $@"Score '{archive.Name}' failed to import: no corresponding beatmap with the hash '{notFound.Hash}' could be found." , LoggingTarget . Database ) ;
2023-09-19 16:41:00 +08:00
2023-09-27 16:02:47 +08:00
if ( ! parameters . Batch )
{
// In the case of a missing beatmap, let's attempt to resolve it and show a prompt to the user to download the required beatmap.
2023-09-27 23:06:47 +08:00
var req = new GetBeatmapRequest ( new BeatmapInfo { MD5Hash = notFound . Hash } ) ;
req . Success + = res = > PostNotification ? . Invoke ( new MissingBeatmapNotification ( res , archive , notFound . Hash ) ) ;
2023-09-27 16:02:47 +08:00
api . Queue ( req ) ;
}
2021-09-30 17:21:16 +08:00
return null ;
}
2023-09-27 15:55:03 +08:00
catch ( Exception e )
2023-09-26 14:00:56 +08:00
{
2023-09-27 15:55:03 +08:00
Logger . Log ( $@"Failed to parse headers of score '{archive.Name}': {e}." , LoggingTarget . Database ) ;
2023-09-26 14:00:56 +08:00
return null ;
}
2021-09-30 17:21:16 +08:00
}
}
public Score GetScore ( ScoreInfo score ) = > new LegacyDatabasedScore ( score , rulesets , beatmaps ( ) , Files . Store ) ;
2022-01-13 15:27:07 +08:00
protected override void Populate ( ScoreInfo model , ArchiveReader ? archive , Realm realm , CancellationToken cancellationToken = default )
2022-01-07 16:27:48 +08:00
{
2023-07-04 13:50:34 +08:00
Debug . Assert ( model . BeatmapInfo ! = null ) ;
2022-01-07 16:27:48 +08:00
// Ensure the beatmap is not detached.
2022-01-12 17:05:25 +08:00
if ( ! model . BeatmapInfo . IsManaged )
2023-07-06 12:37:42 +08:00
model . BeatmapInfo = realm . Find < BeatmapInfo > ( model . BeatmapInfo . ID ) ! ;
2022-01-07 16:27:48 +08:00
if ( ! model . Ruleset . IsManaged )
2023-07-06 12:37:42 +08:00
model . Ruleset = realm . Find < RulesetInfo > ( model . Ruleset . ShortName ) ! ;
2022-01-07 16:27:48 +08:00
2022-01-17 13:02:15 +08:00
// These properties are known to be non-null, but these final checks ensure a null hasn't come from somewhere (or the refetch has failed).
// Under no circumstance do we want these to be written to realm as null.
2022-12-23 04:27:59 +08:00
ArgumentNullException . ThrowIfNull ( model . BeatmapInfo ) ;
ArgumentNullException . ThrowIfNull ( model . Ruleset ) ;
2022-01-17 13:02:15 +08:00
2022-09-09 12:57:01 +08:00
PopulateMaximumStatistics ( model ) ;
2022-09-08 21:06:44 +08:00
2022-01-07 23:40:14 +08:00
if ( string . IsNullOrEmpty ( model . StatisticsJson ) )
model . StatisticsJson = JsonConvert . SerializeObject ( model . Statistics ) ;
2022-08-22 20:31:10 +08:00
if ( string . IsNullOrEmpty ( model . MaximumStatisticsJson ) )
model . MaximumStatisticsJson = JsonConvert . SerializeObject ( model . MaximumStatistics ) ;
2023-06-16 03:48:57 +08:00
// for pre-ScoreV2 lazer scores, apply a best-effort conversion of total score to ScoreV2.
// this requires: max combo, statistics, max statistics (where available), and mods to already be populated on the score.
if ( StandardisedScoreMigrationTools . ShouldMigrateToNewStandardised ( model ) )
model . TotalScore = StandardisedScoreMigrationTools . GetNewStandardised ( model ) ;
2023-06-26 21:19:01 +08:00
else if ( model . IsLegacyScore )
2023-06-29 16:21:24 +08:00
{
model . LegacyTotalScore = model . TotalScore ;
2023-12-21 14:08:10 +08:00
StandardisedScoreMigrationTools . UpdateFromLegacy ( model , beatmaps ( ) ) ;
2023-06-29 16:21:24 +08:00
}
2022-01-07 16:27:48 +08:00
}
2022-07-08 11:16:06 +08:00
2022-09-08 21:06:44 +08:00
/// <summary>
/// Populates the <see cref="ScoreInfo.MaximumStatistics"/> for a given <see cref="ScoreInfo"/>.
/// </summary>
/// <param name="score">The score to populate the statistics of.</param>
2022-09-09 12:57:01 +08:00
public void PopulateMaximumStatistics ( ScoreInfo score )
2022-09-08 21:06:44 +08:00
{
2023-07-05 15:07:12 +08:00
Debug . Assert ( score . BeatmapInfo ! = null ) ;
2022-09-08 21:06:44 +08:00
if ( score . MaximumStatistics . Select ( kvp = > kvp . Value ) . Sum ( ) > 0 )
return ;
2023-07-05 15:07:12 +08:00
var beatmap = score . BeatmapInfo ! . Detach ( ) ;
2022-09-08 21:06:44 +08:00
var ruleset = score . Ruleset . Detach ( ) ;
2022-09-09 12:57:01 +08:00
var rulesetInstance = ruleset . CreateInstance ( ) ;
2023-12-20 19:23:43 +08:00
var scoreProcessor = rulesetInstance . CreateScoreProcessor ( ) ;
2022-09-09 12:57:01 +08:00
Debug . Assert ( rulesetInstance ! = null ) ;
2022-09-08 21:06:44 +08:00
// Populate the maximum statistics.
2022-09-09 12:57:01 +08:00
HitResult maxBasicResult = rulesetInstance . GetHitResults ( )
. Select ( h = > h . result )
2023-12-21 13:58:23 +08:00
. Where ( h = > h . IsBasic ( ) ) . MaxBy ( scoreProcessor . GetBaseScoreForResult ) ;
2022-09-08 21:06:44 +08:00
foreach ( ( HitResult result , int count ) in score . Statistics )
{
switch ( result )
{
case HitResult . LargeTickHit :
case HitResult . LargeTickMiss :
score . MaximumStatistics [ HitResult . LargeTickHit ] = score . MaximumStatistics . GetValueOrDefault ( HitResult . LargeTickHit ) + count ;
break ;
case HitResult . SmallTickHit :
case HitResult . SmallTickMiss :
score . MaximumStatistics [ HitResult . SmallTickHit ] = score . MaximumStatistics . GetValueOrDefault ( HitResult . SmallTickHit ) + count ;
break ;
case HitResult . IgnoreHit :
case HitResult . IgnoreMiss :
case HitResult . SmallBonus :
case HitResult . LargeBonus :
break ;
default :
score . MaximumStatistics [ maxBasicResult ] = score . MaximumStatistics . GetValueOrDefault ( maxBasicResult ) + count ;
break ;
}
}
if ( ! score . IsLegacyScore )
return ;
#pragma warning disable CS0618
// In osu! and osu!mania, some judgements affect combo but aren't stored to scores.
// A special hit result is used to pad out the combo value to match, based on the max combo from the difficulty attributes.
2022-09-09 12:57:01 +08:00
var calculator = rulesetInstance . CreateDifficultyCalculator ( beatmaps ( ) . GetWorkingBeatmap ( beatmap ) ) ;
var attributes = calculator . Calculate ( score . Mods ) ;
2022-09-08 21:06:44 +08:00
int maxComboFromStatistics = score . MaximumStatistics . Where ( kvp = > kvp . Key . AffectsCombo ( ) ) . Select ( kvp = > kvp . Value ) . DefaultIfEmpty ( 0 ) . Sum ( ) ;
2022-09-09 12:57:01 +08:00
if ( attributes . MaxCombo > maxComboFromStatistics )
score . MaximumStatistics [ HitResult . LegacyComboIncrease ] = attributes . MaxCombo - maxComboFromStatistics ;
2022-09-08 21:06:44 +08:00
#pragma warning restore CS0618
}
2023-06-09 18:03:02 +08:00
// Very naive local caching to improve performance of large score imports (where the username is usually the same for most or all scores).
private readonly Dictionary < string , APIUser > usernameLookupCache = new Dictionary < string , APIUser > ( ) ;
2022-12-12 22:56:11 +08:00
protected override void PostImport ( ScoreInfo model , Realm realm , ImportParameters parameters )
2022-07-08 11:16:06 +08:00
{
2022-12-12 22:56:11 +08:00
base . PostImport ( model , realm , parameters ) ;
2022-07-08 11:16:06 +08:00
2023-06-09 18:03:02 +08:00
populateUserDetails ( model ) ;
2023-10-30 14:46:09 +08:00
Debug . Assert ( model . BeatmapInfo ! = null ) ;
// This needs to be run after user detail population to ensure we have a valid user id.
if ( api . IsLoggedIn & & api . LocalUser . Value . OnlineID = = model . UserID & & ( model . BeatmapInfo . LastPlayed = = null | | model . Date > model . BeatmapInfo . LastPlayed ) )
model . BeatmapInfo . LastPlayed = model . Date ;
2023-06-09 18:03:02 +08:00
}
/// <summary>
/// Legacy replays only store a username.
/// This will populate a user ID during import.
/// </summary>
private void populateUserDetails ( ScoreInfo model )
{
2023-10-10 15:28:01 +08:00
if ( model . RealmUser . OnlineID = = APIUser . SYSTEM_USER_ID )
return ;
2023-06-09 18:03:02 +08:00
string username = model . RealmUser . Username ;
if ( usernameLookupCache . TryGetValue ( username , out var existing ) )
{
model . User = existing ;
return ;
}
var userRequest = new GetUserRequest ( username ) ;
2022-07-12 02:46:42 +08:00
2022-07-08 11:16:06 +08:00
api . Perform ( userRequest ) ;
2022-07-12 02:46:42 +08:00
if ( userRequest . Response is APIUser user )
2023-06-09 18:03:02 +08:00
{
usernameLookupCache . TryAdd ( username , new APIUser
{
// Because this is a permanent cache, let's only store the pieces we're interested in,
// rather than the full API response. If we start to store more than these three fields
// in realm, this should be undone.
Id = user . Id ,
Username = user . Username ,
CountryCode = user . CountryCode ,
} ) ;
2022-07-16 11:31:01 +08:00
model . User = user ;
2023-06-09 18:03:02 +08:00
}
2022-07-08 11:16:06 +08:00
}
2021-09-30 17:21:16 +08:00
}
}