// 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.Linq; using JetBrains.Annotations; using Newtonsoft.Json; using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Models; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring.Legacy; using osu.Game.Users; using osu.Game.Utils; using Realms; namespace osu.Game.Scoring { /// /// A realm model containing metadata for a single score. /// [MapTo("Score")] public class ScoreInfo : RealmObject, IHasGuidPrimaryKey, IHasRealmFiles, ISoftDelete, IEquatable, IScoreInfo { [PrimaryKey] public Guid ID { get; set; } /// /// The this score was made against. /// /// /// /// This property may be if the score was set on a beatmap (or a version of the beatmap) that is not available locally /// e.g. due to online updates, or local modifications to the beatmap. /// The property will only link to a if its matches . /// /// /// Due to the above, whenever setting this, make sure to also set to allow relational consistency when a beatmap is potentially changed. /// /// public BeatmapInfo? BeatmapInfo { get; set; } /// /// The at the point in time when the score was set. /// public string BeatmapHash { get; set; } = string.Empty; public RulesetInfo Ruleset { get; set; } = null!; public IList Files { get; } = null!; public string Hash { get; set; } = string.Empty; public bool DeletePending { get; set; } public long TotalScore { get; set; } /// /// The version of processing applied to calculate total score as stored in the database. /// If this does not match , /// the total score has not yet been updated to reflect the current scoring values. /// /// See 's conversion logic. /// /// /// This may not match the version stored in the replay files. /// public int TotalScoreVersion { get; set; } = LegacyScoreEncoder.LATEST_VERSION; /// /// Used to preserve the total score for legacy scores. /// /// /// Not populated if is false. /// public long? LegacyTotalScore { get; set; } /// /// If background processing of this beatmap failed in some way, this flag will become true. /// Should be used to ensure we don't repeatedly attempt to reprocess the same scores each startup even though we already know they will fail. /// /// /// See https://github.com/ppy/osu/issues/24301 for one example of how this can occur (missing beatmap file on disk). /// public bool BackgroundReprocessingFailed { get; set; } public int MaxCombo { get; set; } public double Accuracy { get; set; } public bool HasReplay => !string.IsNullOrEmpty(Hash); public DateTimeOffset Date { get; set; } public double? PP { get; set; } [Indexed] public long OnlineID { get; set; } = -1; [MapTo("User")] public RealmUser RealmUser { get; set; } = null!; [MapTo("Mods")] public string ModsJson { get; set; } = string.Empty; [MapTo("Statistics")] public string StatisticsJson { get; set; } = string.Empty; [MapTo("MaximumStatistics")] public string MaximumStatisticsJson { get; set; } = string.Empty; public ScoreInfo(BeatmapInfo? beatmap = null, RulesetInfo? ruleset = null, RealmUser? realmUser = null) { Ruleset = ruleset ?? new RulesetInfo(); BeatmapInfo = beatmap ?? new BeatmapInfo(); BeatmapHash = BeatmapInfo.Hash; RealmUser = realmUser ?? new RealmUser(); ID = Guid.NewGuid(); } [UsedImplicitly] // Realm private ScoreInfo() { } // TODO: this is a bit temporary to account for the fact that this class is used to ferry API user data to certain UI components. // Eventually we should either persist enough information to realm to not require the API lookups, or perform the API lookups locally. private APIUser? user; [Ignored] public APIUser User { get => user ??= new APIUser { Id = RealmUser.OnlineID, Username = RealmUser.Username, CountryCode = RealmUser.CountryCode, }; set { user = value; RealmUser = new RealmUser { OnlineID = user.OnlineID, Username = user.Username, CountryCode = user.CountryCode, }; } } [Ignored] public ScoreRank Rank { get => (ScoreRank)RankInt; set => RankInt = (int)value; } [MapTo(nameof(Rank))] public int RankInt { get; set; } IRulesetInfo IScoreInfo.Ruleset => Ruleset; IBeatmapInfo? IScoreInfo.Beatmap => BeatmapInfo; IUser IScoreInfo.User => User; IEnumerable IHasNamedFiles.Files => Files; #region Properties required to make things work with existing usages public int UserID => RealmUser.OnlineID; public int RulesetID => Ruleset.OnlineID; [Ignored] public List HitEvents { get; set; } = new List(); public ScoreInfo DeepClone() { var clone = (ScoreInfo)this.Detach().MemberwiseClone(); clone.Statistics = new Dictionary(clone.Statistics); clone.MaximumStatistics = new Dictionary(clone.MaximumStatistics); // Ensure we have fresh mods to avoid any references (ie. after gameplay). clone.clearAllMods(); clone.ModsJson = ModsJson; clone.RealmUser = new RealmUser { OnlineID = RealmUser.OnlineID, Username = RealmUser.Username, CountryCode = RealmUser.CountryCode, }; return clone; } [Ignored] public bool Passed { get; set; } = true; public int Combo { get; set; } /// /// The position of this score, starting at 1. /// [Ignored] public int? Position { get; set; } // TODO: remove after all calls to `CreateScoreInfo` are gone. [Ignored] public LocalisableString DisplayAccuracy => Accuracy.FormatAccuracy(); /// /// Whether this represents a legacy (osu!stable) score. /// public bool IsLegacyScore { get; set; } private Dictionary? statistics; [Ignored] public Dictionary Statistics { get { if (statistics != null) return statistics; if (!string.IsNullOrEmpty(StatisticsJson)) statistics = JsonConvert.DeserializeObject>(StatisticsJson); return statistics ??= new Dictionary(); } set => statistics = value; } private Dictionary? maximumStatistics; [Ignored] public Dictionary MaximumStatistics { get { if (maximumStatistics != null) return maximumStatistics; if (!string.IsNullOrEmpty(MaximumStatisticsJson)) maximumStatistics = JsonConvert.DeserializeObject>(MaximumStatisticsJson); return maximumStatistics ??= new Dictionary(); } set => maximumStatistics = value; } private Mod[]? mods; [Ignored] public Mod[] Mods { get { if (mods != null) return mods; return APIMods.Select(m => m.ToMod(Ruleset.CreateInstance())).ToArray(); } set { clearAllMods(); mods = value; updateModsJson(); } } private APIMod[]? apiMods; // Used for API serialisation/deserialisation. [Ignored] public APIMod[] APIMods { get { if (apiMods != null) return apiMods; // prioritise reading from realm backing if (!string.IsNullOrEmpty(ModsJson)) apiMods = JsonConvert.DeserializeObject(ModsJson); // then check mods set via Mods property. if (mods != null) apiMods ??= mods.Select(m => new APIMod(m)).ToArray(); return apiMods ?? Array.Empty(); } set { clearAllMods(); apiMods = value; updateModsJson(); } } private void clearAllMods() { ModsJson = string.Empty; mods = null; apiMods = null; } private void updateModsJson() { ModsJson = APIMods.Length > 0 ? JsonConvert.SerializeObject(APIMods) : string.Empty; } public IEnumerable GetStatisticsForDisplay() { foreach (var r in Ruleset.CreateInstance().GetHitResults()) { int value = Statistics.GetValueOrDefault(r.result); switch (r.result) { case HitResult.SmallTickHit: { int total = value + Statistics.GetValueOrDefault(HitResult.SmallTickMiss); if (total > 0) yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); break; } case HitResult.LargeTickHit: { int total = value + Statistics.GetValueOrDefault(HitResult.LargeTickMiss); if (total > 0) yield return new HitResultDisplayStatistic(r.result, value, total, r.displayName); break; } case HitResult.LargeBonus: case HitResult.SmallBonus: if (MaximumStatistics.TryGetValue(r.result, out int count) && count > 0) yield return new HitResultDisplayStatistic(r.result, value, null, r.displayName); break; case HitResult.SmallTickMiss: case HitResult.LargeTickMiss: break; default: yield return new HitResultDisplayStatistic(r.result, value, null, r.displayName); break; } } } #endregion public bool Equals(ScoreInfo? other) => other?.ID == ID; public override string ToString() => this.GetDisplayTitle(); } }