// 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.Utils; using osu.Game.Beatmaps; using osu.Game.Extensions; using osu.Game.Online.Spectator; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Replays; using osu.Game.Scoring; using osu.Framework.Localisation; using osu.Game.Localisation; namespace osu.Game.Rulesets.Scoring { public class ScoreProcessor : JudgementProcessor { 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 BindableDouble TotalScore = new BindableDouble { MinValue = 0 }; /// /// The current accuracy. /// public readonly BindableDouble Accuracy = 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; private readonly Ruleset ruleset; private readonly double accuracyPortion; private readonly double comboPortion; /// /// Scoring values for a perfect play. /// public ScoringValues MaximumScoringValues { get { if (!beatmapApplied) throw new InvalidOperationException($"Cannot access maximum scoring values before calling {nameof(ApplyBeatmap)}."); return maximumScoringValues; } } 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) { this.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 = rankFrom(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 ? currentScoringValues.BaseScore / currentMaximumScoringValues.BaseScore : 1; TotalScore.Value = ComputeScore(Mode.Value, currentScoringValues, maximumScoringValues); } /// /// Computes the total score of a given finalised . This should be used when a score is known to be complete. /// /// /// 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 double 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] public double ComputeScore(ScoringMode mode, ScoringValues current, ScoringValues maximum) { double accuracyRatio = maximum.BaseScore > 0 ? 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 double ComputeScore(ScoringMode mode, double accuracyRatio, double comboRatio, double bonusScore, int totalBasicHitObjects) { switch (mode) { default: case ScoringMode.Standardised: double accuracyScore = accuracyPortion * accuracyRatio; double comboScore = comboPortion * comboRatio; return (max_score * (accuracyScore + comboScore) + bonusScore) * scoreMultiplier; 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 scaledStandardised = ComputeScore(ScoringMode.Standardised, accuracyRatio, comboRatio, bonusScore, totalBasicHitObjects) / max_score; return Math.Pow(scaledStandardised * Math.Max(1, totalBasicHitObjects), 2) * ClassicScoreMultiplier; } } private ScoreRank rankFrom(double acc) { if (acc == 1) return ScoreRank.X; if (acc >= 0.95) return ScoreRank.S; if (acc >= 0.9) return ScoreRank.A; if (acc >= 0.8) return ScoreRank.B; if (acc >= 0.7) return ScoreRank.C; return ScoreRank.D; } /// /// 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; 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 = (long)Math.Round(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] internal 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 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 replay frame header 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] internal void ExtractScoringValues(FrameHeader header, out ScoringValues current, out ScoringValues maximum) { extractScoringValues(header.Statistics, out current, out maximum); current.MaxCombo = header.MaxCombo; } /// /// 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().OrderByDescending(kvp => Judgement.ToNumericResult(kvp.result)).First().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(); } } public enum ScoringMode { [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.StandardisedScoreDisplay))] Standardised, [LocalisableDescription(typeof(GameplaySettingsStrings), nameof(GameplaySettingsStrings.ClassicScoreDisplay))] Classic } }