diff --git a/osu.Game/BackgroundBeatmapProcessor.cs b/osu.Game/BackgroundBeatmapProcessor.cs index 14fdb2e1ef..405386660c 100644 --- a/osu.Game/BackgroundBeatmapProcessor.cs +++ b/osu.Game/BackgroundBeatmapProcessor.cs @@ -7,6 +7,7 @@ using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -14,6 +15,7 @@ using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Rulesets; +using osu.Game.Scoring; using osu.Game.Screens.Play; namespace osu.Game @@ -23,6 +25,9 @@ namespace osu.Game [Resolved] private RulesetStore rulesetStore { get; set; } = null!; + [Resolved] + private ScoreManager scoreManager { get; set; } = null!; + [Resolved] private RealmAccess realmAccess { get; set; } = null!; @@ -41,11 +46,12 @@ namespace osu.Game { base.LoadComplete(); - Task.Run(() => + Task.Run(async () => { Logger.Log("Beginning background beatmap processing.."); checkForOutdatedStarRatings(); processBeatmapSetsWithMissingMetrics(); + await processScoresWithMissingStatistics(); }).ContinueWith(t => { if (t.Exception?.InnerException is ObjectDisposedException) @@ -140,5 +146,52 @@ namespace osu.Game }); } } + + private async Task processScoresWithMissingStatistics() + { + HashSet scoreIds = new HashSet(); + + Logger.Log("Querying for scores to reprocess..."); + + realmAccess.Run(r => + { + foreach (var score in r.All()) + { + if (score.Statistics.Sum(kvp => kvp.Value) > 0 && score.MaximumStatistics.Sum(kvp => kvp.Value) == 0) + scoreIds.Add(score.ID); + } + }); + + Logger.Log($"Found {scoreIds.Count} scores which require reprocessing."); + + foreach (var id in scoreIds) + { + while (localUserPlayInfo?.IsPlaying.Value == true) + { + Logger.Log("Background processing sleeping due to active gameplay..."); + Thread.Sleep(TimeToSleepDuringGameplay); + } + + try + { + var score = scoreManager.Query(s => s.ID == id); + + await scoreManager.PopulateMaximumStatistics(score); + + // Can't use async overload because we're not on the update thread. + // ReSharper disable once MethodHasAsyncOverload + realmAccess.Write(r => + { + r.Find(id).MaximumStatisticsJson = JsonConvert.SerializeObject(score.MaximumStatistics); + }); + + Logger.Log($"Populated maximum statistics for score {id}"); + } + catch (Exception e) + { + Logger.Log(@$"Failed to populate maximum statistics for {id}: {e}"); + } + } + } } } diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index c95a281f09..c95dde8759 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -279,7 +279,7 @@ namespace osu.Game dependencies.Cache(difficultyCache = new BeatmapDifficultyCache()); // ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup() - dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, realm, API, LocalConfig)); + dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, realm, API, LocalConfig, difficultyCache)); dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, realm, API, Audio, Resources, Host, defaultBeatmap, difficultyCache, performOnlineLookups: true)); diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs index 45f827354e..488f5815f8 100644 --- a/osu.Game/Scoring/ScoreImporter.cs +++ b/osu.Game/Scoring/ScoreImporter.cs @@ -5,7 +5,9 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading; +using System.Threading.Tasks; using Newtonsoft.Json; +using osu.Framework.Extensions; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Beatmaps; @@ -16,6 +18,8 @@ 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 @@ -28,15 +32,17 @@ namespace osu.Game.Scoring private readonly RulesetStore rulesets; private readonly Func beatmaps; + private readonly BeatmapDifficultyCache? difficultyCache; private readonly IAPIProvider api; - public ScoreImporter(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm, IAPIProvider api) + public ScoreImporter(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm, IAPIProvider api, BeatmapDifficultyCache? difficultyCache = null) : base(storage, realm) { this.rulesets = rulesets; this.beatmaps = beatmaps; this.api = api; + this.difficultyCache = difficultyCache; } protected override ScoreInfo? CreateModel(ArchiveReader archive) @@ -71,6 +77,8 @@ namespace osu.Game.Scoring if (model.BeatmapInfo == null) throw new ArgumentNullException(nameof(model.BeatmapInfo)); if (model.Ruleset == null) throw new ArgumentNullException(nameof(model.Ruleset)); + PopulateMaximumStatistics(model).WaitSafely(); + if (string.IsNullOrEmpty(model.StatisticsJson)) model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics); @@ -78,6 +86,69 @@ namespace osu.Game.Scoring model.MaximumStatisticsJson = JsonConvert.SerializeObject(model.MaximumStatistics); } + /// + /// Populates the for a given . + /// + /// The score to populate the statistics of. + public async Task PopulateMaximumStatistics(ScoreInfo score) + { + if (score.MaximumStatistics.Select(kvp => kvp.Value).Sum() > 0) + return; + + var beatmap = score.BeatmapInfo.Detach(); + var ruleset = score.Ruleset.Detach(); + + // Populate the maximum statistics. + HitResult maxBasicResult = ruleset.CreateInstance().GetHitResults() + .Select(h => h.result) + .Where(h => h.IsBasic()) + .OrderByDescending(Judgement.ToNumericResult).First(); + + 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 + if (difficultyCache == null) + throw new InvalidOperationException($"Cannot populate legacy score statistics without a {nameof(BeatmapDifficultyCache)}."); + + // 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. + StarDifficulty? difficulty = await difficultyCache.GetDifficultyAsync(beatmap, ruleset, score.Mods); + if (difficulty == null) + throw new InvalidOperationException("Failed to populate maximum statistics due to missing difficulty attributes."); + + int maxComboFromStatistics = score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Select(kvp => kvp.Value).DefaultIfEmpty(0).Sum(); + if (difficulty.Value.MaxCombo > maxComboFromStatistics) + score.MaximumStatistics[HitResult.LegacyComboIncrease] = difficulty.Value.MaxCombo - maxComboFromStatistics; +#pragma warning restore CS0618 + } + protected override void PostImport(ScoreInfo model, Realm realm, bool batchImport) { base.PostImport(model, realm, batchImport); diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 782590114f..87bb834a75 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -28,12 +28,13 @@ namespace osu.Game.Scoring private readonly OsuConfigManager configManager; private readonly ScoreImporter scoreImporter; - public ScoreManager(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm, IAPIProvider api, OsuConfigManager configManager = null) + public ScoreManager(RulesetStore rulesets, Func beatmaps, Storage storage, RealmAccess realm, IAPIProvider api, + OsuConfigManager configManager = null, BeatmapDifficultyCache difficultyCache = null) : base(storage, realm) { this.configManager = configManager; - scoreImporter = new ScoreImporter(rulesets, beatmaps, storage, realm, api) + scoreImporter = new ScoreImporter(rulesets, beatmaps, storage, realm, api, difficultyCache) { PostNotification = obj => PostNotification?.Invoke(obj) }; @@ -178,6 +179,12 @@ namespace osu.Game.Scoring public Live Import(ScoreInfo item, ArchiveReader archive = null, bool batchImport = false, CancellationToken cancellationToken = default) => scoreImporter.ImportModel(item, archive, batchImport, cancellationToken); + /// + /// Populates the for a given . + /// + /// The score to populate the statistics of. + public Task PopulateMaximumStatistics(ScoreInfo score) => scoreImporter.PopulateMaximumStatistics(score); + #region Implementation of IPresentImports public Action>> PresentImport