// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; using System.Threading; using Newtonsoft.Json; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.IO.Archives; using osu.Game.Rulesets; using osu.Game.Scoring.Legacy; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Scoring; using Realms; namespace osu.Game.Scoring { public class ScoreImporter : RealmArchiveModelImporter { public override IEnumerable HandledExtensions => new[] { ".osr" }; protected override string[] HashableFileTypes => new[] { ".osr" }; private readonly RulesetStore rulesets; private readonly Func beatmaps; private readonly IAPIProvider api; public ScoreImporter(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm, IAPIProvider api) : base(storage, realm) { this.rulesets = rulesets; this.beatmaps = beatmaps; this.api = api; } protected override ScoreInfo? CreateModel(ArchiveReader archive, ImportParameters parameters) { string name = archive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase)); using (var stream = archive.GetStream(name)) { try { return new DatabasedLegacyScoreDecoder(rulesets, beatmaps()).Parse(stream).ScoreInfo; } catch (LegacyScoreDecoder.BeatmapNotFoundException notFound) { Logger.Log($@"Score '{archive.Name}' failed to import: no corresponding beatmap with the hash '{notFound.Hash}' could be found.", LoggingTarget.Database); 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. var req = new GetBeatmapRequest(new BeatmapInfo { MD5Hash = notFound.Hash }); req.Success += res => PostNotification?.Invoke(new MissingBeatmapNotification(res, archive, notFound.Hash)); api.Queue(req); } return null; } catch (Exception e) { Logger.Log($@"Failed to parse headers of score '{archive.Name}': {e}.", LoggingTarget.Database); return null; } } } public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store); protected override void Populate(ScoreInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) { Debug.Assert(model.BeatmapInfo != null); // Ensure the beatmap is not detached. if (!model.BeatmapInfo.IsManaged) model.BeatmapInfo = realm.Find(model.BeatmapInfo.ID)!; if (!model.Ruleset.IsManaged) model.Ruleset = realm.Find(model.Ruleset.ShortName)!; // 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. ArgumentNullException.ThrowIfNull(model.BeatmapInfo); ArgumentNullException.ThrowIfNull(model.Ruleset); PopulateMaximumStatistics(model); if (string.IsNullOrEmpty(model.StatisticsJson)) model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics); if (string.IsNullOrEmpty(model.MaximumStatisticsJson)) model.MaximumStatisticsJson = JsonConvert.SerializeObject(model.MaximumStatistics); // 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); else if (model.IsLegacyScore) { model.LegacyTotalScore = model.TotalScore; model.TotalScore = StandardisedScoreMigrationTools.ConvertFromLegacyTotalScore(model, beatmaps()); } } /// /// Populates the for a given . /// /// The score to populate the statistics of. public void PopulateMaximumStatistics(ScoreInfo score) { Debug.Assert(score.BeatmapInfo != null); if (score.MaximumStatistics.Select(kvp => kvp.Value).Sum() > 0) return; var beatmap = score.BeatmapInfo!.Detach(); var ruleset = score.Ruleset.Detach(); var rulesetInstance = ruleset.CreateInstance(); Debug.Assert(rulesetInstance != null); // Populate the maximum statistics. HitResult maxBasicResult = rulesetInstance.GetHitResults() .Select(h => h.result) .Where(h => h.IsBasic()).MaxBy(Judgement.ToNumericResult); 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. var calculator = rulesetInstance.CreateDifficultyCalculator(beatmaps().GetWorkingBeatmap(beatmap)); var attributes = calculator.Calculate(score.Mods); int maxComboFromStatistics = score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Select(kvp => kvp.Value).DefaultIfEmpty(0).Sum(); if (attributes.MaxCombo > maxComboFromStatistics) score.MaximumStatistics[HitResult.LegacyComboIncrease] = attributes.MaxCombo - maxComboFromStatistics; #pragma warning restore CS0618 } // 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 usernameLookupCache = new Dictionary(); protected override void PostImport(ScoreInfo model, Realm realm, ImportParameters parameters) { base.PostImport(model, realm, parameters); populateUserDetails(model); 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; } /// /// Legacy replays only store a username. /// This will populate a user ID during import. /// private void populateUserDetails(ScoreInfo model) { if (model.RealmUser.OnlineID == APIUser.SYSTEM_USER_ID) return; string username = model.RealmUser.Username; if (usernameLookupCache.TryGetValue(username, out var existing)) { model.User = existing; return; } var userRequest = new GetUserRequest(username); api.Perform(userRequest); if (userRequest.Response is APIUser user) { 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, }); model.User = user; } } } }