// 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.Diagnostics.Contracts; using System.Linq; using osu.Framework.Bindables; using osu.Framework.Localisation; using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Localisation; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Replays; using osu.Game.Scoring; namespace osu.Game.Rulesets.Scoring { public partial class ScoreProcessor : JudgementProcessor { private const double accuracy_cutoff_x = 1; private const double accuracy_cutoff_s = 0.95; private const double accuracy_cutoff_a = 0.9; private const double accuracy_cutoff_b = 0.8; private const double accuracy_cutoff_c = 0.7; private const double accuracy_cutoff_d = 0; private const double max_score = 1000000; /// /// Invoked when this was reset from a replay frame. /// public event Action? OnResetFromReplayFrame; /// /// The current total score. /// public readonly BindableLong TotalScore = new BindableLong { MinValue = 0 }; /// /// The current accuracy. /// public readonly BindableDouble Accuracy = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; /// /// The minimum achievable accuracy for the whole beatmap at this stage of gameplay. /// Assumes that all objects that have not been judged yet will receive the minimum hit result. /// public readonly BindableDouble MinimumAccuracy = new BindableDouble { MinValue = 0, MaxValue = 1 }; /// /// The maximum achievable accuracy for the whole beatmap at this stage of gameplay. /// Assumes that all objects that have not been judged yet will receive the maximum hit result. /// public readonly BindableDouble MaximumAccuracy = new BindableDouble(1) { MinValue = 0, MaxValue = 1 }; /// /// The current combo. /// public readonly BindableInt Combo = new BindableInt(); /// /// The current selected mods /// public readonly Bindable> Mods = new Bindable>(Array.Empty()); /// /// The current rank. /// public readonly Bindable Rank = new Bindable(ScoreRank.X); /// /// The highest combo achieved by this score. /// public readonly BindableInt HighestCombo = new BindableInt(); /// /// The used to calculate scores. /// public readonly Bindable Mode = new Bindable(); /// /// The s collected during gameplay thus far. /// Intended for use with various statistics displays. /// public IReadOnlyList HitEvents => hitEvents; /// /// The default portion of awarded for hitting s accurately. Defaults to 30%. /// protected virtual double DefaultAccuracyPortion => 0.3; /// /// The default portion of awarded for achieving a high combo. Default to 70%. /// protected virtual double DefaultComboPortion => 0.7; /// /// An arbitrary multiplier to scale scores in the scoring mode. /// protected virtual double ClassicScoreMultiplier => 36; /// /// The ruleset this score processor is valid for. /// public readonly Ruleset Ruleset; private readonly double accuracyPortion; private readonly double comboPortion; public Dictionary MaximumStatistics { get { if (!beatmapApplied) throw new InvalidOperationException($"Cannot access maximum statistics before calling {nameof(ApplyBeatmap)}."); return new Dictionary(maximumResultCounts); } } private ScoringValues maximumScoringValues; /// /// Scoring values for the current play assuming all perfect hits. /// /// /// This is only used to determine the accuracy with respect to the current point in time for an ongoing play session. /// private ScoringValues currentMaximumScoringValues; /// /// Scoring values for the current play. /// private ScoringValues currentScoringValues; /// /// The maximum of a basic (non-tick and non-bonus) hitobject. /// Only populated via or . /// private HitResult? maxBasicResult; private bool beatmapApplied; private readonly Dictionary scoreResultCounts = new Dictionary(); private readonly Dictionary maximumResultCounts = new Dictionary(); private readonly List hitEvents = new List(); private HitObject? lastHitObject; private double scoreMultiplier = 1; public ScoreProcessor(Ruleset ruleset) { Ruleset = ruleset; accuracyPortion = DefaultAccuracyPortion; comboPortion = DefaultComboPortion; if (!Precision.AlmostEquals(1.0, accuracyPortion + comboPortion)) throw new InvalidOperationException($"{nameof(DefaultAccuracyPortion)} + {nameof(DefaultComboPortion)} must equal 1."); Combo.ValueChanged += combo => HighestCombo.Value = Math.Max(HighestCombo.Value, combo.NewValue); Accuracy.ValueChanged += accuracy => { Rank.Value = RankFromAccuracy(accuracy.NewValue); foreach (var mod in Mods.Value.OfType()) Rank.Value = mod.AdjustRank(Rank.Value, accuracy.NewValue); }; Mode.ValueChanged += _ => updateScore(); Mods.ValueChanged += mods => { scoreMultiplier = 1; foreach (var m in mods.NewValue) scoreMultiplier *= m.ScoreMultiplier; updateScore(); }; } public override void ApplyBeatmap(IBeatmap beatmap) { base.ApplyBeatmap(beatmap); beatmapApplied = true; } protected sealed override void ApplyResultInternal(JudgementResult result) { result.ComboAtJudgement = Combo.Value; result.HighestComboAtJudgement = HighestCombo.Value; if (result.FailedAtJudgement) return; scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) + 1; // Always update the maximum scoring values. applyResult(result.Judgement.MaxResult, ref currentMaximumScoringValues); currentMaximumScoringValues.MaxCombo += result.Judgement.MaxResult.IncreasesCombo() ? 1 : 0; if (!result.Type.IsScorable()) return; if (result.Type.IncreasesCombo()) Combo.Value++; else if (result.Type.BreaksCombo()) Combo.Value = 0; applyResult(result.Type, ref currentScoringValues); currentScoringValues.MaxCombo = HighestCombo.Value; hitEvents.Add(CreateHitEvent(result)); lastHitObject = result.HitObject; updateScore(); } private static void applyResult(HitResult result, ref ScoringValues scoringValues) { if (!result.IsScorable()) return; if (result.IsBonus()) scoringValues.BonusScore += result.IsHit() ? Judgement.ToNumericResult(result) : 0; else scoringValues.BaseScore += result.IsHit() ? Judgement.ToNumericResult(result) : 0; if (result.IsBasic()) scoringValues.CountBasicHitObjects++; } /// /// Creates the that describes a . /// /// The to describe. /// The . protected virtual HitEvent CreateHitEvent(JudgementResult result) => new HitEvent(result.TimeOffset, result.Type, result.HitObject, lastHitObject, null); protected sealed override void RevertResultInternal(JudgementResult result) { Combo.Value = result.ComboAtJudgement; HighestCombo.Value = result.HighestComboAtJudgement; if (result.FailedAtJudgement) return; scoreResultCounts[result.Type] = scoreResultCounts.GetValueOrDefault(result.Type) - 1; // Always update the maximum scoring values. revertResult(result.Judgement.MaxResult, ref currentMaximumScoringValues); currentMaximumScoringValues.MaxCombo -= result.Judgement.MaxResult.IncreasesCombo() ? 1 : 0; if (!result.Type.IsScorable()) return; revertResult(result.Type, ref currentScoringValues); currentScoringValues.MaxCombo = HighestCombo.Value; Debug.Assert(hitEvents.Count > 0); lastHitObject = hitEvents[^1].LastHitObject; hitEvents.RemoveAt(hitEvents.Count - 1); updateScore(); } private static void revertResult(HitResult result, ref ScoringValues scoringValues) { if (!result.IsScorable()) return; if (result.IsBonus()) scoringValues.BonusScore -= result.IsHit() ? Judgement.ToNumericResult(result) : 0; else scoringValues.BaseScore -= result.IsHit() ? Judgement.ToNumericResult(result) : 0; if (result.IsBasic()) scoringValues.CountBasicHitObjects--; } private void updateScore() { Accuracy.Value = currentMaximumScoringValues.BaseScore > 0 ? (double)currentScoringValues.BaseScore / currentMaximumScoringValues.BaseScore : 1; MinimumAccuracy.Value = maximumScoringValues.BaseScore > 0 ? (double)currentScoringValues.BaseScore / maximumScoringValues.BaseScore : 0; MaximumAccuracy.Value = maximumScoringValues.BaseScore > 0 ? (double)(currentScoringValues.BaseScore + (maximumScoringValues.BaseScore - currentMaximumScoringValues.BaseScore)) / maximumScoringValues.BaseScore : 1; TotalScore.Value = computeScore(Mode.Value, currentScoringValues, maximumScoringValues); } /// /// Computes the accuracy of a given . /// /// The to compute the total score of. /// The score's accuracy. [Pure] public double ComputeAccuracy(ScoreInfo scoreInfo) { if (!Ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) throw new ArgumentException($"Unexpected score ruleset. Expected \"{Ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); // We only extract scoring values from the score's statistics. This is because accuracy is always relative to the point of pass or fail rather than relative to the whole beatmap. extractScoringValues(scoreInfo.Statistics, out var current, out var maximum); return maximum.BaseScore > 0 ? (double)current.BaseScore / maximum.BaseScore : 1; } /// /// Computes the total score of a given . /// /// /// Does not require to have been called before use. /// /// The to represent the score as. /// The to compute the total score of. /// The total score in the given . [Pure] public long ComputeScore(ScoringMode mode, ScoreInfo scoreInfo) { if (!Ruleset.RulesetInfo.Equals(scoreInfo.Ruleset)) throw new ArgumentException($"Unexpected score ruleset. Expected \"{Ruleset.RulesetInfo.ShortName}\" but was \"{scoreInfo.Ruleset.ShortName}\"."); extractScoringValues(scoreInfo, out var current, out var maximum); return computeScore(mode, current, maximum); } /// /// Computes the total score from scoring values. /// /// The to represent the score as. /// The current scoring values. /// The maximum scoring values. /// The total score computed from the given scoring values. [Pure] private long computeScore(ScoringMode mode, ScoringValues current, ScoringValues maximum) { double accuracyRatio = maximum.BaseScore > 0 ? (double)current.BaseScore / maximum.BaseScore : 1; double comboRatio = maximum.MaxCombo > 0 ? (double)current.MaxCombo / maximum.MaxCombo : 1; return ComputeScore(mode, accuracyRatio, comboRatio, current.BonusScore, maximum.CountBasicHitObjects); } /// /// Computes the total score from individual scoring components. /// /// The to represent the score as. /// The accuracy percentage achieved by the player. /// The portion of the max combo achieved by the player. /// The total bonus score. /// The total number of basic (non-tick and non-bonus) hitobjects in the beatmap. /// The total score computed from the given scoring component ratios. [Pure] public long ComputeScore(ScoringMode mode, double accuracyRatio, double comboRatio, long bonusScore, int totalBasicHitObjects) { double accuracyScore = accuracyPortion * accuracyRatio; double comboScore = comboPortion * comboRatio; double rawScore = (max_score * (accuracyScore + comboScore) + bonusScore) * scoreMultiplier; switch (mode) { default: case ScoringMode.Standardised: return (long)Math.Round(rawScore); case ScoringMode.Classic: // This gives a similar feeling to osu!stable scoring (ScoreV1) while keeping classic scoring as only a constant multiple of standardised scoring. // The invariant is important to ensure that scores don't get re-ordered on leaderboards between the two scoring modes. double scaledRawScore = rawScore / max_score; return (long)Math.Round(Math.Pow(scaledRawScore * Math.Max(1, totalBasicHitObjects), 2) * ClassicScoreMultiplier); } } /// /// Resets this ScoreProcessor to a default state. /// /// Whether to store the current state of the for future use. protected override void Reset(bool storeResults) { base.Reset(storeResults); hitEvents.Clear(); lastHitObject = null; if (storeResults) { maximumScoringValues = currentScoringValues; maximumResultCounts.Clear(); maximumResultCounts.AddRange(scoreResultCounts); } scoreResultCounts.Clear(); currentScoringValues = default; currentMaximumScoringValues = default; TotalScore.Value = 0; Accuracy.Value = 1; Combo.Value = 0; Rank.Disabled = false; Rank.Value = ScoreRank.X; HighestCombo.Value = 0; } /// /// Retrieve a score populated with data for the current play this processor is responsible for. /// public virtual void PopulateScore(ScoreInfo score) { score.Combo = Combo.Value; score.MaxCombo = HighestCombo.Value; score.Accuracy = Accuracy.Value; score.Rank = Rank.Value; score.HitEvents = hitEvents; score.Statistics.Clear(); score.MaximumStatistics.Clear(); foreach (var result in HitResultExtensions.ALL_TYPES) score.Statistics[result] = scoreResultCounts.GetValueOrDefault(result); foreach (var result in HitResultExtensions.ALL_TYPES) score.MaximumStatistics[result] = maximumResultCounts.GetValueOrDefault(result); // Populate total score after everything else. score.TotalScore = ComputeScore(ScoringMode.Standardised, score); } /// /// Populates a failed score, marking it with the rank. /// public void FailScore(ScoreInfo score) { if (Rank.Value == ScoreRank.F) return; score.Passed = false; Rank.Value = ScoreRank.F; PopulateScore(score); } public override void ResetFromReplayFrame(ReplayFrame frame) { base.ResetFromReplayFrame(frame); if (frame.Header == null) return; extractScoringValues(frame.Header.Statistics, out var current, out var maximum); currentScoringValues.BaseScore = current.BaseScore; currentScoringValues.MaxCombo = frame.Header.MaxCombo; currentMaximumScoringValues.BaseScore = maximum.BaseScore; currentMaximumScoringValues.MaxCombo = maximum.MaxCombo; Combo.Value = frame.Header.Combo; HighestCombo.Value = frame.Header.MaxCombo; scoreResultCounts.Clear(); scoreResultCounts.AddRange(frame.Header.Statistics); updateScore(); OnResetFromReplayFrame?.Invoke(); } #region ScoringValue extraction /// /// Applies a best-effort extraction of hit statistics into . /// /// /// This method is useful in a variety of situations, with a few drawbacks that need to be considered: /// /// The maximum will always be 0. /// The current and maximum will always be the same value. /// /// Consumers are expected to more accurately fill in the above values through external means. /// /// Ensure to fill in the maximum for use in /// . /// /// /// The score to extract scoring values from. /// The "current" scoring values, representing the hit statistics as they appear. /// The "maximum" scoring values, representing the hit statistics as if the maximum hit result was attained each time. [Pure] private void extractScoringValues(ScoreInfo scoreInfo, out ScoringValues current, out ScoringValues maximum) { extractScoringValues(scoreInfo.Statistics, out current, out maximum); current.MaxCombo = scoreInfo.MaxCombo; if (scoreInfo.MaximumStatistics.Count > 0) extractScoringValues(scoreInfo.MaximumStatistics, out _, out maximum); } /// /// Applies a best-effort extraction of hit statistics into . /// /// /// This method is useful in a variety of situations, with a few drawbacks that need to be considered: /// /// The current will always be 0. /// The maximum will always be 0. /// The current and maximum will always be the same value. /// /// Consumers are expected to more accurately fill in the above values (especially the current ) via external means (e.g. ). /// /// The hit statistics to extract scoring values from. /// The "current" scoring values, representing the hit statistics as they appear. /// The "maximum" scoring values, representing the hit statistics as if the maximum hit result was attained each time. [Pure] private void extractScoringValues(IReadOnlyDictionary statistics, out ScoringValues current, out ScoringValues maximum) { current = default; maximum = default; foreach ((HitResult result, int count) in statistics) { if (!result.IsScorable()) continue; if (result.IsBonus()) current.BonusScore += count * Judgement.ToNumericResult(result); if (result.AffectsAccuracy()) { // The maximum result of this judgement if it wasn't a miss. // E.g. For a GOOD judgement, the max result is either GREAT/PERFECT depending on which one the ruleset uses (osu!: GREAT, osu!mania: PERFECT). HitResult maxResult; switch (result) { case HitResult.LargeTickHit: case HitResult.LargeTickMiss: maxResult = HitResult.LargeTickHit; break; case HitResult.SmallTickHit: case HitResult.SmallTickMiss: maxResult = HitResult.SmallTickHit; break; default: maxResult = maxBasicResult ??= Ruleset.GetHitResults().MaxBy(kvp => Judgement.ToNumericResult(kvp.result)).result; break; } current.BaseScore += count * Judgement.ToNumericResult(result); maximum.BaseScore += count * Judgement.ToNumericResult(maxResult); } if (result.AffectsCombo()) maximum.MaxCombo += count; if (result.IsBasic()) { current.CountBasicHitObjects += count; maximum.CountBasicHitObjects += count; } } } #endregion protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); hitEvents.Clear(); } #region Static helper methods /// /// Given an accuracy (0..1), return the correct . /// public static ScoreRank RankFromAccuracy(double accuracy) { if (accuracy == accuracy_cutoff_x) return ScoreRank.X; if (accuracy >= accuracy_cutoff_s) return ScoreRank.S; if (accuracy >= accuracy_cutoff_a) return ScoreRank.A; if (accuracy >= accuracy_cutoff_b) return ScoreRank.B; if (accuracy >= accuracy_cutoff_c) return ScoreRank.C; return ScoreRank.D; } /// /// Given a , return the cutoff accuracy (0..1). /// Accuracy must be greater than or equal to the cutoff to qualify for the provided rank. /// public static double AccuracyCutoffFromRank(ScoreRank rank) { switch (rank) { case ScoreRank.X: case ScoreRank.XH: return accuracy_cutoff_x; case ScoreRank.S: case ScoreRank.SH: return accuracy_cutoff_s; case ScoreRank.A: return accuracy_cutoff_a; case ScoreRank.B: return accuracy_cutoff_b; case ScoreRank.C: return accuracy_cutoff_c; case ScoreRank.D: return accuracy_cutoff_d; default: throw new ArgumentOutOfRangeException(nameof(rank), rank, null); } } #endregion /// /// Stores the required scoring data that fulfils the minimum requirements for a to calculate score. /// private struct ScoringValues { /// /// The sum of all "basic" scoring values. See: and . /// public long BaseScore; /// /// The sum of all "bonus" scoring values. See: and . /// public long BonusScore; /// /// The highest achieved combo. /// public int MaxCombo; /// /// The count of "basic" s. See: . /// public int CountBasicHitObjects; } } public enum ScoringMode { [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.StandardisedScoreDisplay))] Standardised, [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.ClassicScoreDisplay))] Classic } }