diff --git a/osu.Desktop/DiscordRichPresence.cs b/osu.Desktop/DiscordRichPresence.cs index fe3e08537e..b1e11d7a60 100644 --- a/osu.Desktop/DiscordRichPresence.cs +++ b/osu.Desktop/DiscordRichPresence.cs @@ -187,7 +187,7 @@ namespace osu.Desktop return edit.BeatmapInfo.ToString() ?? string.Empty; case UserActivity.WatchingReplay watching: - return watching.BeatmapInfo.ToString(); + return watching.BeatmapInfo?.ToString() ?? string.Empty; case UserActivity.InLobby lobby: return privacyMode.Value == DiscordRichPresenceMode.Limited ? string.Empty : lobby.Room.Name.Value; diff --git a/osu.Game.Rulesets.Catch/CatchRuleset.cs b/osu.Game.Rulesets.Catch/CatchRuleset.cs index 8a0b8250d5..8f1a1b8ef5 100644 --- a/osu.Game.Rulesets.Catch/CatchRuleset.cs +++ b/osu.Game.Rulesets.Catch/CatchRuleset.cs @@ -202,6 +202,8 @@ namespace osu.Game.Rulesets.Catch public int LegacyID => 2; + public ILegacyScoreSimulator CreateLegacyScoreSimulator() => new CatchLegacyScoreSimulator(); + public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new CatchReplayFrame(); public override HitObjectComposer CreateHitObjectComposer() => new CatchHitObjectComposer(this); diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs index 2d01153f98..5c64643fd4 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyAttributes.cs @@ -27,7 +27,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty // Todo: osu!catch should not output star rating in the 'aim' attribute. yield return (ATTRIB_ID_AIM, StarRating); yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate); - yield return (ATTRIB_ID_MAX_COMBO, MaxCombo); } public override void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) @@ -36,7 +35,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty StarRating = values[ATTRIB_ID_AIM]; ApproachRate = values[ATTRIB_ID_APPROACH_RATE]; - MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO]; } } } diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs index 42cfde268e..0b56405299 100644 --- a/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchDifficultyCalculator.cs @@ -25,9 +25,12 @@ namespace osu.Game.Rulesets.Catch.Difficulty public override int Version => 20220701; + private readonly IWorkingBeatmap workingBeatmap; + public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { + workingBeatmap = beatmap; } protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) @@ -38,13 +41,24 @@ namespace osu.Game.Rulesets.Catch.Difficulty // this is the same as osu!, so there's potential to share the implementation... maybe double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate; - return new CatchDifficultyAttributes + CatchDifficultyAttributes attributes = new CatchDifficultyAttributes { StarRating = Math.Sqrt(skills[0].DifficultyValue()) * star_scaling_factor, Mods = mods, ApproachRate = preempt > 1200.0 ? -(preempt - 1800.0) / 120.0 : -(preempt - 1200.0) / 150.0 + 5.0, MaxCombo = beatmap.HitObjects.Count(h => h is Fruit) + beatmap.HitObjects.OfType().SelectMany(j => j.NestedHitObjects).Count(h => !(h is TinyDroplet)), }; + + if (ComputeLegacyScoringValues) + { + CatchLegacyScoreSimulator sv1Simulator = new CatchLegacyScoreSimulator(); + sv1Simulator.Simulate(workingBeatmap, beatmap, mods); + attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore; + attributes.LegacyComboScore = sv1Simulator.ComboScore; + attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio; + } + + return attributes; } protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) diff --git a/osu.Game.Rulesets.Catch/Difficulty/CatchLegacyScoreSimulator.cs b/osu.Game.Rulesets.Catch/Difficulty/CatchLegacyScoreSimulator.cs new file mode 100644 index 0000000000..c79fd36d96 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Difficulty/CatchLegacyScoreSimulator.cs @@ -0,0 +1,142 @@ +// 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 osu.Game.Beatmaps; +using osu.Game.Rulesets.Catch.Objects; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Catch.Difficulty +{ + internal class CatchLegacyScoreSimulator : ILegacyScoreSimulator + { + public int AccuracyScore { get; private set; } + + public int ComboScore { get; private set; } + + public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore; + + private int legacyBonusScore; + private int modernBonusScore; + private int combo; + + private double scoreMultiplier; + + public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList mods) + { + IBeatmap baseBeatmap = workingBeatmap.Beatmap; + + int countNormal = 0; + int countSlider = 0; + int countSpinner = 0; + + foreach (HitObject obj in baseBeatmap.HitObjects) + { + switch (obj) + { + case IHasPath: + countSlider++; + break; + + case IHasDuration: + countSpinner++; + break; + + default: + countNormal++; + break; + } + } + + int objectCount = countNormal + countSlider + countSpinner; + + int drainLength = 0; + + if (baseBeatmap.HitObjects.Count > 0) + { + int breakLength = baseBeatmap.Breaks.Select(b => (int)Math.Round(b.EndTime) - (int)Math.Round(b.StartTime)).Sum(); + drainLength = ((int)Math.Round(baseBeatmap.HitObjects[^1].StartTime) - (int)Math.Round(baseBeatmap.HitObjects[0].StartTime) - breakLength) / 1000; + } + + int difficultyPeppyStars = (int)Math.Round( + (baseBeatmap.Difficulty.DrainRate + + baseBeatmap.Difficulty.OverallDifficulty + + baseBeatmap.Difficulty.CircleSize + + Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5); + + scoreMultiplier = difficultyPeppyStars * mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier); + + foreach (var obj in playableBeatmap.HitObjects) + simulateHit(obj); + } + + private void simulateHit(HitObject hitObject) + { + bool increaseCombo = true; + bool addScoreComboMultiplier = false; + + bool isBonus = false; + HitResult bonusResult = HitResult.None; + + int scoreIncrease = 0; + + switch (hitObject) + { + case TinyDroplet: + scoreIncrease = 10; + increaseCombo = false; + break; + + case Droplet: + scoreIncrease = 100; + break; + + case Fruit: + scoreIncrease = 300; + addScoreComboMultiplier = true; + increaseCombo = true; + break; + + case Banana: + scoreIncrease = 1100; + increaseCombo = false; + isBonus = true; + bonusResult = HitResult.LargeBonus; + break; + + case JuiceStream: + foreach (var nested in hitObject.NestedHitObjects) + simulateHit(nested); + return; + + case BananaShower: + foreach (var nested in hitObject.NestedHitObjects) + simulateHit(nested); + return; + } + + if (addScoreComboMultiplier) + { + // ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...) + ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier)); + } + + if (isBonus) + { + legacyBonusScore += scoreIncrease; + modernBonusScore += Judgement.ToNumericResult(bonusResult); + } + else + AccuracyScore += scoreIncrease; + + if (increaseCombo) + combo++; + } + } +} diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs index d259c2af8e..db60e757e1 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyAttributes.cs @@ -24,7 +24,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty foreach (var v in base.ToDatabaseAttributes()) yield return v; - yield return (ATTRIB_ID_MAX_COMBO, MaxCombo); yield return (ATTRIB_ID_DIFFICULTY, StarRating); yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow); } @@ -33,7 +32,6 @@ namespace osu.Game.Rulesets.Mania.Difficulty { base.FromDatabaseAttributes(values, onlineInfo); - MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO]; StarRating = values[ATTRIB_ID_DIFFICULTY]; GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW]; } diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs index bbb31ab98c..de9f0d91ae 100644 --- a/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaDifficultyCalculator.cs @@ -31,9 +31,13 @@ namespace osu.Game.Rulesets.Mania.Difficulty public override int Version => 20220902; + private readonly IWorkingBeatmap workingBeatmap; + public ManiaDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { + workingBeatmap = beatmap; + isForCurrentRuleset = beatmap.BeatmapInfo.Ruleset.MatchesOnlineID(ruleset); originalOverallDifficulty = beatmap.BeatmapInfo.Difficulty.OverallDifficulty; } @@ -46,15 +50,26 @@ namespace osu.Game.Rulesets.Mania.Difficulty HitWindows hitWindows = new ManiaHitWindows(); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); - return new ManiaDifficultyAttributes + ManiaDifficultyAttributes attributes = new ManiaDifficultyAttributes { StarRating = skills[0].DifficultyValue() * star_scaling_factor, Mods = mods, // In osu-stable mania, rate-adjustment mods don't affect the hit window. // This is done the way it is to introduce fractional differences in order to match osu-stable for the time being. GreatHitWindow = Math.Ceiling((int)(getHitWindow300(mods) * clockRate) / clockRate), - MaxCombo = beatmap.HitObjects.Sum(maxComboForObject) + MaxCombo = beatmap.HitObjects.Sum(maxComboForObject), }; + + if (ComputeLegacyScoringValues) + { + ManiaLegacyScoreSimulator sv1Simulator = new ManiaLegacyScoreSimulator(); + sv1Simulator.Simulate(workingBeatmap, beatmap, mods); + attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore; + attributes.LegacyComboScore = sv1Simulator.ComboScore; + attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio; + } + + return attributes; } private static int maxComboForObject(HitObject hitObject) diff --git a/osu.Game.Rulesets.Mania/Difficulty/ManiaLegacyScoreSimulator.cs b/osu.Game.Rulesets.Mania/Difficulty/ManiaLegacyScoreSimulator.cs new file mode 100644 index 0000000000..e544428979 --- /dev/null +++ b/osu.Game.Rulesets.Mania/Difficulty/ManiaLegacyScoreSimulator.cs @@ -0,0 +1,28 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Mania.Difficulty +{ + internal class ManiaLegacyScoreSimulator : ILegacyScoreSimulator + { + public int AccuracyScore => 0; + public int ComboScore { get; private set; } + public double BonusScoreRatio => 0; + + public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList mods) + { + double multiplier = mods.Where(m => m is not (ModHidden or ModHardRock or ModDoubleTime or ModFlashlight or ManiaModFadeIn)) + .Select(m => m.ScoreMultiplier) + .Aggregate(1.0, (c, n) => c * n); + + ComboScore = (int)(1000000 * multiplier); + } + } +} diff --git a/osu.Game.Rulesets.Mania/ManiaRuleset.cs b/osu.Game.Rulesets.Mania/ManiaRuleset.cs index e8fda3ec80..2e96c89516 100644 --- a/osu.Game.Rulesets.Mania/ManiaRuleset.cs +++ b/osu.Game.Rulesets.Mania/ManiaRuleset.cs @@ -302,6 +302,8 @@ namespace osu.Game.Rulesets.Mania public int LegacyID => 3; + public ILegacyScoreSimulator CreateLegacyScoreSimulator() => new ManiaLegacyScoreSimulator(); + public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new ManiaReplayFrame(); public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new ManiaRulesetConfigManager(settings, RulesetInfo); diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index 03540abddb..24d5635104 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -93,7 +93,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty yield return (ATTRIB_ID_SPEED, SpeedDifficulty); yield return (ATTRIB_ID_OVERALL_DIFFICULTY, OverallDifficulty); yield return (ATTRIB_ID_APPROACH_RATE, ApproachRate); - yield return (ATTRIB_ID_MAX_COMBO, MaxCombo); yield return (ATTRIB_ID_DIFFICULTY, StarRating); if (ShouldSerializeFlashlightRating()) @@ -111,7 +110,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty SpeedDifficulty = values[ATTRIB_ID_SPEED]; OverallDifficulty = values[ATTRIB_ID_OVERALL_DIFFICULTY]; ApproachRate = values[ATTRIB_ID_APPROACH_RATE]; - MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO]; StarRating = values[ATTRIB_ID_DIFFICULTY]; FlashlightDifficulty = values.GetValueOrDefault(ATTRIB_ID_FLASHLIGHT); SliderFactor = values[ATTRIB_ID_SLIDER_FACTOR]; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 1e83d6d820..b92092c674 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -26,9 +26,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty public override int Version => 20220902; + private readonly IWorkingBeatmap workingBeatmap; + public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { + workingBeatmap = beatmap; } protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) @@ -71,7 +74,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 ); - double starRating = basePerformance > 0.00001 ? Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) : 0; + double starRating = basePerformance > 0.00001 + ? Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4) + : 0; double preempt = IBeatmapDifficultyInfo.DifficultyRange(beatmap.Difficulty.ApproachRate, 1800, 1200, 450) / clockRate; double drainRate = beatmap.Difficulty.DrainRate; @@ -86,7 +91,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate; - return new OsuDifficultyAttributes + OsuDifficultyAttributes attributes = new OsuDifficultyAttributes { StarRating = starRating, Mods = mods, @@ -103,6 +108,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty SliderCount = sliderCount, SpinnerCount = spinnerCount, }; + + if (ComputeLegacyScoringValues) + { + OsuLegacyScoreSimulator sv1Simulator = new OsuLegacyScoreSimulator(); + sv1Simulator.Simulate(workingBeatmap, beatmap, mods); + attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore; + attributes.LegacyComboScore = sv1Simulator.ComboScore; + attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio; + } + + return attributes; } protected override IEnumerable CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreSimulator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreSimulator.cs new file mode 100644 index 0000000000..980d86e4ad --- /dev/null +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuLegacyScoreSimulator.cs @@ -0,0 +1,177 @@ +// 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 osu.Game.Beatmaps; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Osu.Objects; +using osu.Game.Rulesets.Scoring; + +namespace osu.Game.Rulesets.Osu.Difficulty +{ + internal class OsuLegacyScoreSimulator : ILegacyScoreSimulator + { + public int AccuracyScore { get; private set; } + + public int ComboScore { get; private set; } + + public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore; + + private int legacyBonusScore; + private int modernBonusScore; + private int combo; + + private double scoreMultiplier; + private IBeatmap playableBeatmap = null!; + + public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList mods) + { + this.playableBeatmap = playableBeatmap; + + IBeatmap baseBeatmap = workingBeatmap.Beatmap; + + int countNormal = 0; + int countSlider = 0; + int countSpinner = 0; + + foreach (HitObject obj in workingBeatmap.Beatmap.HitObjects) + { + switch (obj) + { + case IHasPath: + countSlider++; + break; + + case IHasDuration: + countSpinner++; + break; + + default: + countNormal++; + break; + } + } + + int objectCount = countNormal + countSlider + countSpinner; + + int drainLength = 0; + + if (baseBeatmap.HitObjects.Count > 0) + { + int breakLength = baseBeatmap.Breaks.Select(b => (int)Math.Round(b.EndTime) - (int)Math.Round(b.StartTime)).Sum(); + drainLength = ((int)Math.Round(baseBeatmap.HitObjects[^1].StartTime) - (int)Math.Round(baseBeatmap.HitObjects[0].StartTime) - breakLength) / 1000; + } + + int difficultyPeppyStars = (int)Math.Round( + (baseBeatmap.Difficulty.DrainRate + + baseBeatmap.Difficulty.OverallDifficulty + + baseBeatmap.Difficulty.CircleSize + + Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5); + + scoreMultiplier = difficultyPeppyStars * mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier); + + foreach (var obj in playableBeatmap.HitObjects) + simulateHit(obj); + } + + private void simulateHit(HitObject hitObject) + { + bool increaseCombo = true; + bool addScoreComboMultiplier = false; + + bool isBonus = false; + HitResult bonusResult = HitResult.None; + + int scoreIncrease = 0; + + switch (hitObject) + { + case SliderHeadCircle: + case SliderTailCircle: + case SliderRepeat: + scoreIncrease = 30; + break; + + case SliderTick: + scoreIncrease = 10; + break; + + case SpinnerBonusTick: + scoreIncrease = 1100; + increaseCombo = false; + isBonus = true; + bonusResult = HitResult.LargeBonus; + break; + + case SpinnerTick: + scoreIncrease = 100; + increaseCombo = false; + isBonus = true; + bonusResult = HitResult.SmallBonus; + break; + + case HitCircle: + scoreIncrease = 300; + addScoreComboMultiplier = true; + break; + + case Slider: + foreach (var nested in hitObject.NestedHitObjects) + simulateHit(nested); + + scoreIncrease = 300; + increaseCombo = false; + addScoreComboMultiplier = true; + break; + + case Spinner spinner: + // The spinner object applies a lenience because gameplay mechanics differ from osu-stable. + // We'll redo the calculations to match osu-stable here... + const double maximum_rotations_per_second = 477.0 / 60; + double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(playableBeatmap.Difficulty.OverallDifficulty, 3, 5, 7.5); + double secondsDuration = spinner.Duration / 1000; + + // The total amount of half spins possible for the entire spinner. + int totalHalfSpinsPossible = (int)(secondsDuration * maximum_rotations_per_second * 2); + // The amount of half spins that are required to successfully complete the spinner (i.e. get a 300). + int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimumRotationsPerSecond); + // To be able to receive bonus points, the spinner must be rotated another 1.5 times. + int halfSpinsRequiredBeforeBonus = halfSpinsRequiredForCompletion + 3; + + for (int i = 0; i <= totalHalfSpinsPossible; i++) + { + if (i > halfSpinsRequiredBeforeBonus && (i - halfSpinsRequiredBeforeBonus) % 2 == 0) + simulateHit(new SpinnerBonusTick()); + else if (i > 1 && i % 2 == 0) + simulateHit(new SpinnerTick()); + } + + scoreIncrease = 300; + addScoreComboMultiplier = true; + break; + } + + if (addScoreComboMultiplier) + { + // ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...) + ComboScore += (int)(Math.Max(0, combo - 1) * (scoreIncrease / 25 * scoreMultiplier)); + } + + if (isBonus) + { + legacyBonusScore += scoreIncrease; + modernBonusScore += Judgement.ToNumericResult(bonusResult); + } + else + AccuracyScore += scoreIncrease; + + if (increaseCombo) + combo++; + } + } +} diff --git a/osu.Game.Rulesets.Osu/OsuRuleset.cs b/osu.Game.Rulesets.Osu/OsuRuleset.cs index 4cff16b46f..b44d999d4f 100644 --- a/osu.Game.Rulesets.Osu/OsuRuleset.cs +++ b/osu.Game.Rulesets.Osu/OsuRuleset.cs @@ -253,6 +253,8 @@ namespace osu.Game.Rulesets.Osu public int LegacyID => 0; + public ILegacyScoreSimulator CreateLegacyScoreSimulator() => new OsuLegacyScoreSimulator(); + public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new OsuReplayFrame(); public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new OsuRulesetConfigManager(settings, RulesetInfo); diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs index bce855ae45..4133b96d42 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneDrumSampleTriggerSource.cs @@ -72,13 +72,13 @@ namespace osu.Game.Rulesets.Taiko.Tests }); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); seekTo(200); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); } [Test] @@ -100,13 +100,13 @@ namespace osu.Game.Rulesets.Taiko.Tests }); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); seekTo(200); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); } [Test] @@ -145,23 +145,23 @@ namespace osu.Game.Rulesets.Taiko.Tests }); AddAssert("most valid object is first hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(first)); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL); seekTo(120); AddAssert("most valid object is first hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(first)); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_NORMAL); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_NORMAL); seekTo(480); AddAssert("most valid object is second hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(second)); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); seekTo(700); AddAssert("most valid object is second hit", () => triggerSource.GetMostValidObject(), () => Is.EqualTo(second)); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); } [Test] @@ -174,8 +174,8 @@ namespace osu.Game.Rulesets.Taiko.Tests StartTime = 100, Samples = new List { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum"), - new HitSampleInfo(HitSampleInfo.HIT_FINISH, "drum") // implies strong + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM), + new HitSampleInfo(HitSampleInfo.HIT_FINISH, HitSampleInfo.BANK_DRUM) // implies strong } }; hit.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); @@ -184,13 +184,13 @@ namespace osu.Game.Rulesets.Taiko.Tests }); AddAssert("most valid object is nested strong hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM); seekTo(200); AddAssert("most valid object is hit", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM); } [Test] @@ -213,18 +213,18 @@ namespace osu.Game.Rulesets.Taiko.Tests }); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); seekTo(600); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); seekTo(1200); AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); } [Test] @@ -247,18 +247,18 @@ namespace osu.Game.Rulesets.Taiko.Tests }); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); seekTo(600); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); seekTo(1200); AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_SOFT); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_SOFT); } [Test] @@ -272,8 +272,8 @@ namespace osu.Game.Rulesets.Taiko.Tests EndTime = 1100, Samples = new List { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum"), - new HitSampleInfo(HitSampleInfo.HIT_FINISH, "drum") // implies strong + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM), + new HitSampleInfo(HitSampleInfo.HIT_FINISH, HitSampleInfo.BANK_DRUM) // implies strong } }; drumRoll.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); @@ -282,18 +282,18 @@ namespace osu.Game.Rulesets.Taiko.Tests }); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM); seekTo(600); AddAssert("most valid object is drum roll tick", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM); seekTo(1200); AddAssert("most valid object is drum roll", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, $"{HitSampleInfo.HIT_NORMAL},{HitSampleInfo.HIT_FINISH}", HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, $"{HitSampleInfo.HIT_CLAP},{HitSampleInfo.HIT_WHISTLE}", HitSampleInfo.BANK_DRUM); } [Test] @@ -319,18 +319,18 @@ namespace osu.Game.Rulesets.Taiko.Tests // This works fine in gameplay because they are judged whenever the user pressed, rather than being timed hits. // But for sample playback purposes they can be ignored as noise. AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); seekTo(600); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); seekTo(1200); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, SampleControlPoint.DEFAULT_BANK); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, SampleControlPoint.DEFAULT_BANK); } [Test] @@ -344,7 +344,7 @@ namespace osu.Game.Rulesets.Taiko.Tests EndTime = 1100, Samples = new List { - new HitSampleInfo(HitSampleInfo.HIT_NORMAL, "drum") + new HitSampleInfo(HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM) } }; swell.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty()); @@ -356,25 +356,26 @@ namespace osu.Game.Rulesets.Taiko.Tests // This works fine in gameplay because they are judged whenever the user pressed, rather than being timed hits. // But for sample playback purposes they can be ignored as noise. AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_DRUM); seekTo(600); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_DRUM); seekTo(1200); AddAssert("most valid object is swell", () => triggerSource.GetMostValidObject(), Is.InstanceOf); - checkSound(HitType.Centre, HitSampleInfo.HIT_NORMAL, "drum"); - checkSound(HitType.Rim, HitSampleInfo.HIT_CLAP, "drum"); + checkSamples(HitType.Centre, HitSampleInfo.HIT_NORMAL, HitSampleInfo.BANK_DRUM); + checkSamples(HitType.Rim, HitSampleInfo.HIT_CLAP, HitSampleInfo.BANK_DRUM); } - private void checkSound(HitType hitType, string expectedName, string expectedBank) + private void checkSamples(HitType hitType, string expectedSamplesCsv, string expectedBank) { AddStep($"hit {hitType}", () => triggerSource.Play(hitType)); - AddAssert($"last played sample is {expectedName}", () => triggerSource.LastPlayedSamples!.OfType().Single().Name, () => Is.EqualTo(expectedName)); - AddAssert($"last played sample has {expectedBank} bank", () => triggerSource.LastPlayedSamples!.OfType().Single().Bank, () => Is.EqualTo(expectedBank)); + AddAssert($"last played sample is {expectedSamplesCsv}", () => string.Join(',', triggerSource.LastPlayedSamples!.OfType().Select(s => s.Name)), + () => Is.EqualTo(expectedSamplesCsv)); + AddAssert($"last played sample has {expectedBank} bank", () => triggerSource.LastPlayedSamples!.OfType().First().Bank, () => Is.EqualTo(expectedBank)); } private void seekTo(double time) => AddStep($"seek to {time}", () => gameplayClock.Seek(time)); diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneFlyingHits.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneFlyingHits.cs index e0ff617b59..0e10f75378 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneFlyingHits.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneFlyingHits.cs @@ -38,7 +38,7 @@ namespace osu.Game.Rulesets.Taiko.Tests private void addFlyingHit(HitType hitType) { - var tick = new DrumRollTick { HitWindows = HitWindows.Empty, StartTime = DrawableRuleset.Playfield.Time.Current }; + var tick = new DrumRollTick(new DrumRoll()) { HitWindows = HitWindows.Empty, StartTime = DrawableRuleset.Playfield.Time.Current }; DrawableDrumRollTick h; DrawableRuleset.Playfield.Add(h = new DrawableDrumRollTick(tick) { JudgementType = hitType }); diff --git a/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs b/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs index 2429b71095..a548a14d88 100644 --- a/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs +++ b/osu.Game.Rulesets.Taiko.Tests/TestSceneSampleOutput.cs @@ -3,15 +3,16 @@ using System.Collections.Generic; using System.Linq; +using NUnit.Framework; using osu.Framework.Testing; -using osu.Game.Audio; using osu.Game.Beatmaps; using osu.Game.Rulesets.Taiko.Objects.Drawables; +using osu.Game.Rulesets.Taiko.UI; namespace osu.Game.Rulesets.Taiko.Tests { /// - /// Taiko has some interesting rules for legacy mappings. + /// Taiko doesn't output any samples. They are all handled externally by . /// [HeadlessTest] public partial class TestSceneSampleOutput : TestSceneTaikoPlayer @@ -26,10 +27,10 @@ namespace osu.Game.Rulesets.Taiko.Tests string.Empty, string.Empty, string.Empty, - HitSampleInfo.HIT_FINISH, - HitSampleInfo.HIT_WHISTLE, - HitSampleInfo.HIT_WHISTLE, - HitSampleInfo.HIT_WHISTLE, + string.Empty, + string.Empty, + string.Empty, + string.Empty, }; var actualSampleNames = new List(); @@ -46,7 +47,7 @@ namespace osu.Game.Rulesets.Taiko.Tests AddUntilStep("all samples collected", () => actualSampleNames.Count == expectedSampleNames.Length); - AddAssert("samples are correct", () => actualSampleNames.SequenceEqual(expectedSampleNames)); + AddAssert("samples are correct", () => actualSampleNames, () => Is.EqualTo(expectedSampleNames)); } protected override IBeatmap CreateBeatmap(RulesetInfo ruleset) => new TaikoBeatmapConversionTest().GetBeatmap("sample-to-type-conversions"); diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs index 72452e27b3..1664c941f8 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyAttributes.cs @@ -48,7 +48,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty foreach (var v in base.ToDatabaseAttributes()) yield return v; - yield return (ATTRIB_ID_MAX_COMBO, MaxCombo); yield return (ATTRIB_ID_DIFFICULTY, StarRating); yield return (ATTRIB_ID_GREAT_HIT_WINDOW, GreatHitWindow); } @@ -57,7 +56,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty { base.FromDatabaseAttributes(values, onlineInfo); - MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO]; StarRating = values[ATTRIB_ID_DIFFICULTY]; GreatHitWindow = values[ATTRIB_ID_GREAT_HIT_WINDOW]; } diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs index 8e988c4154..25adba5ab6 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoDifficultyCalculator.cs @@ -25,9 +25,12 @@ namespace osu.Game.Rulesets.Taiko.Difficulty public override int Version => 20220902; + private readonly IWorkingBeatmap workingBeatmap; + public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) : base(ruleset, beatmap) { + workingBeatmap = beatmap; } protected override Skill[] CreateSkills(IBeatmap beatmap, Mod[] mods, double clockRate) @@ -84,7 +87,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty HitWindows hitWindows = new TaikoHitWindows(); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); - return new TaikoDifficultyAttributes + TaikoDifficultyAttributes attributes = new TaikoDifficultyAttributes { StarRating = starRating, Mods = mods, @@ -95,6 +98,17 @@ namespace osu.Game.Rulesets.Taiko.Difficulty GreatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate, MaxCombo = beatmap.HitObjects.Count(h => h is Hit), }; + + if (ComputeLegacyScoringValues) + { + TaikoLegacyScoreSimulator sv1Simulator = new TaikoLegacyScoreSimulator(); + sv1Simulator.Simulate(workingBeatmap, beatmap, mods); + attributes.LegacyAccuracyScore = sv1Simulator.AccuracyScore; + attributes.LegacyComboScore = sv1Simulator.ComboScore; + attributes.LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio; + } + + return attributes; } /// diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoLegacyScoreSimulator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoLegacyScoreSimulator.cs new file mode 100644 index 0000000000..e77327d622 --- /dev/null +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoLegacyScoreSimulator.cs @@ -0,0 +1,202 @@ +// 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 osu.Game.Beatmaps; +using osu.Game.Rulesets.Judgements; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Objects; +using osu.Game.Rulesets.Objects.Types; +using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko.Objects; + +namespace osu.Game.Rulesets.Taiko.Difficulty +{ + internal class TaikoLegacyScoreSimulator : ILegacyScoreSimulator + { + public int AccuracyScore { get; private set; } + + public int ComboScore { get; private set; } + + public double BonusScoreRatio => legacyBonusScore == 0 ? 0 : (double)modernBonusScore / legacyBonusScore; + + private int legacyBonusScore; + private int modernBonusScore; + private int combo; + + private double modMultiplier; + private int difficultyPeppyStars; + private IBeatmap playableBeatmap = null!; + private IReadOnlyList mods = null!; + + public void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList mods) + { + this.playableBeatmap = playableBeatmap; + this.mods = mods; + + IBeatmap baseBeatmap = workingBeatmap.Beatmap; + + int countNormal = 0; + int countSlider = 0; + int countSpinner = 0; + + foreach (HitObject obj in baseBeatmap.HitObjects) + { + switch (obj) + { + case IHasPath: + countSlider++; + break; + + case IHasDuration: + countSpinner++; + break; + + default: + countNormal++; + break; + } + } + + int objectCount = countNormal + countSlider + countSpinner; + + int drainLength = 0; + + if (baseBeatmap.HitObjects.Count > 0) + { + int breakLength = baseBeatmap.Breaks.Select(b => (int)Math.Round(b.EndTime) - (int)Math.Round(b.StartTime)).Sum(); + drainLength = ((int)Math.Round(baseBeatmap.HitObjects[^1].StartTime) - (int)Math.Round(baseBeatmap.HitObjects[0].StartTime) - breakLength) / 1000; + } + + difficultyPeppyStars = (int)Math.Round( + (baseBeatmap.Difficulty.DrainRate + + baseBeatmap.Difficulty.OverallDifficulty + + baseBeatmap.Difficulty.CircleSize + + Math.Clamp((float)objectCount / drainLength * 8, 0, 16)) / 38 * 5); + + modMultiplier = mods.Aggregate(1.0, (current, mod) => current * mod.ScoreMultiplier); + + foreach (var obj in playableBeatmap.HitObjects) + simulateHit(obj); + } + + private void simulateHit(HitObject hitObject) + { + bool increaseCombo = true; + bool addScoreComboMultiplier = false; + + bool isBonus = false; + HitResult bonusResult = HitResult.None; + + int scoreIncrease = 0; + + switch (hitObject) + { + case SwellTick: + scoreIncrease = 300; + increaseCombo = false; + break; + + case DrumRollTick: + scoreIncrease = 300; + increaseCombo = false; + isBonus = true; + bonusResult = HitResult.SmallBonus; + break; + + case Swell swell: + // The taiko swell generally does not match the osu-stable implementation in any way. + // We'll redo the calculations to match osu-stable here... + double minimumRotationsPerSecond = IBeatmapDifficultyInfo.DifficultyRange(playableBeatmap.Difficulty.OverallDifficulty, 3, 5, 7.5); + double secondsDuration = swell.Duration / 1000; + + // The amount of half spins that are required to successfully complete the spinner (i.e. get a 300). + int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimumRotationsPerSecond); + + halfSpinsRequiredForCompletion = (int)Math.Max(1, halfSpinsRequiredForCompletion * 1.65f); + + if (mods.Any(m => m is ModDoubleTime)) + halfSpinsRequiredForCompletion = Math.Max(1, (int)(halfSpinsRequiredForCompletion * 0.75f)); + if (mods.Any(m => m is ModHalfTime)) + halfSpinsRequiredForCompletion = Math.Max(1, (int)(halfSpinsRequiredForCompletion * 1.5f)); + + for (int i = 0; i <= halfSpinsRequiredForCompletion; i++) + simulateHit(new SwellTick()); + + scoreIncrease = 300; + addScoreComboMultiplier = true; + increaseCombo = false; + isBonus = true; + bonusResult = HitResult.LargeBonus; + break; + + case Hit: + scoreIncrease = 300; + addScoreComboMultiplier = true; + break; + + case DrumRoll: + foreach (var nested in hitObject.NestedHitObjects) + simulateHit(nested); + return; + } + + if (hitObject is DrumRollTick tick) + { + if (playableBeatmap.ControlPointInfo.EffectPointAt(tick.Parent.StartTime).KiaiMode) + scoreIncrease = (int)(scoreIncrease * 1.2f); + + if (tick.IsStrong) + scoreIncrease += scoreIncrease / 5; + } + + // The score increase directly contributed to by the combo-multiplied portion. + int comboScoreIncrease = 0; + + if (addScoreComboMultiplier) + { + int oldScoreIncrease = scoreIncrease; + + // ReSharper disable once PossibleLossOfFraction (intentional to match osu-stable...) + scoreIncrease += (int)(scoreIncrease / 35 * 2 * (difficultyPeppyStars + 1) * modMultiplier) * (Math.Min(100, combo) / 10); + + if (hitObject is Swell) + { + if (playableBeatmap.ControlPointInfo.EffectPointAt(hitObject.GetEndTime()).KiaiMode) + scoreIncrease = (int)(scoreIncrease * 1.2f); + } + else + { + if (playableBeatmap.ControlPointInfo.EffectPointAt(hitObject.StartTime).KiaiMode) + scoreIncrease = (int)(scoreIncrease * 1.2f); + } + + comboScoreIncrease = scoreIncrease - oldScoreIncrease; + } + + if (hitObject is Swell || (hitObject is TaikoStrongableHitObject strongable && strongable.IsStrong)) + { + scoreIncrease *= 2; + comboScoreIncrease *= 2; + } + + scoreIncrease -= comboScoreIncrease; + + if (addScoreComboMultiplier) + ComboScore += comboScoreIncrease; + + if (isBonus) + { + legacyBonusScore += scoreIncrease; + modernBonusScore += Judgement.ToNumericResult(bonusResult); + } + else + AccuracyScore += scoreIncrease; + + if (increaseCombo) + combo++; + } + } +} diff --git a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs index a193bacde5..ac4462c18b 100644 --- a/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Taiko/Difficulty/TaikoPerformanceCalculator.cs @@ -42,7 +42,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss; // TODO: The detection of rulesets is temporary until the leftover old skills have been reworked. - bool isConvert = score.BeatmapInfo.Ruleset.OnlineID != 1; + bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1; double multiplier = 1.13; diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs index 62c8457c58..5b79151225 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableHit.cs @@ -4,14 +4,12 @@ #nullable disable using System; -using System.Collections.Generic; using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Input.Events; -using osu.Game.Audio; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko.Skinning.Default; @@ -93,40 +91,6 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables ? new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.CentreHit), _ => new CentreHitCirclePiece(), confineMode: ConfineMode.ScaleToFit) : new SkinnableDrawable(new TaikoSkinComponentLookup(TaikoSkinComponents.RimHit), _ => new RimHitCirclePiece(), confineMode: ConfineMode.ScaleToFit); - public override IEnumerable GetSamples() - { - // normal and claps are always handled by the drum (see DrumSampleMapping). - // in addition, whistles are excluded as they are an alternative rim marker. - - var samples = HitObject.Samples.Where(s => - s.Name != HitSampleInfo.HIT_NORMAL - && s.Name != HitSampleInfo.HIT_CLAP - && s.Name != HitSampleInfo.HIT_WHISTLE); - - if (HitObject.Type == HitType.Rim && HitObject.IsStrong) - { - // strong + rim always maps to whistle. - // TODO: this should really be in the legacy decoder, but can't be because legacy encoding parity would be broken. - // when we add a taiko editor, this is probably not going to play nice. - - var corrected = samples.ToList(); - - for (int i = 0; i < corrected.Count; i++) - { - var s = corrected[i]; - - if (s.Name != HitSampleInfo.HIT_FINISH) - continue; - - corrected[i] = s.With(HitSampleInfo.HIT_WHISTLE); - } - - return corrected; - } - - return samples; - } - protected override void CheckForResult(bool userTriggered, double timeOffset) { Debug.Assert(HitObject.HitWindows != null); diff --git a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs index 1b5d641612..3f4694d71d 100644 --- a/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs +++ b/osu.Game.Rulesets.Taiko/Objects/Drawables/DrawableTaikoHitObject.cs @@ -119,8 +119,8 @@ namespace osu.Game.Rulesets.Taiko.Objects.Drawables public override bool RemoveWhenNotAlive => false; } - // Most osu!taiko hitsounds are managed by the drum (see DrumSampleTriggerSource). - public override IEnumerable GetSamples() => Enumerable.Empty(); + // osu!taiko hitsounds are managed by the drum (see DrumSampleTriggerSource). + public sealed override IEnumerable GetSamples() => Enumerable.Empty(); } public abstract partial class DrawableTaikoHitObject : DrawableTaikoHitObject diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs index 79d17b4a1f..083b8cc547 100644 --- a/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs +++ b/osu.Game.Rulesets.Taiko/Objects/DrumRoll.cs @@ -90,7 +90,7 @@ namespace osu.Game.Rulesets.Taiko.Objects { cancellationToken.ThrowIfCancellationRequested(); - AddNested(new DrumRollTick + AddNested(new DrumRollTick(this) { FirstTick = first, TickSpacing = tickSpacing, diff --git a/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs b/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs index eb333d9ec5..dc082ffd21 100644 --- a/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs +++ b/osu.Game.Rulesets.Taiko/Objects/DrumRollTick.cs @@ -9,6 +9,8 @@ namespace osu.Game.Rulesets.Taiko.Objects { public class DrumRollTick : TaikoStrongableHitObject { + public readonly DrumRoll Parent; + /// /// Whether this is the first (initial) tick of the slider. /// @@ -25,6 +27,11 @@ namespace osu.Game.Rulesets.Taiko.Objects /// public double HitWindow => TickSpacing / 2; + public DrumRollTick(DrumRoll parent) + { + Parent = parent; + } + public override Judgement CreateJudgement() => new TaikoDrumRollTickJudgement(); protected override HitWindows CreateHitWindows() => HitWindows.Empty; diff --git a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs index 37eb95b86f..5516e025cd 100644 --- a/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs +++ b/osu.Game.Rulesets.Taiko/Skinning/Legacy/LegacyCirclePiece.cs @@ -1,6 +1,7 @@ // 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 osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -8,6 +9,7 @@ using osu.Framework.Graphics.Animations; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Primitives; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Taiko.Objects; @@ -26,11 +28,12 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy private Bindable currentCombo { get; } = new BindableInt(); private int animationFrame; - private double beatLength; // required for editor blueprints (not sure why these circle pieces are zero size). public override Quad ScreenSpaceDrawQuad => backgroundLayer.ScreenSpaceDrawQuad; + private TimingControlPoint timingPoint = TimingControlPoint.DEFAULT; + public LegacyCirclePiece() { RelativeSizeAxes = Axes.Both; @@ -39,11 +42,8 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy [Resolved(canBeNull: true)] private GameplayState? gameplayState { get; set; } - [Resolved(canBeNull: true)] - private IBeatSyncProvider? beatSyncProvider { get; set; } - [BackgroundDependencyLoader] - private void load(ISkinSource skin, DrawableHitObject drawableHitObject) + private void load(ISkinSource skin, DrawableHitObject drawableHitObject, IBeatSyncProvider? beatSyncProvider) { Drawable? getDrawableFor(string lookup) { @@ -64,6 +64,11 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy if (foregroundLayer != null) AddInternal(foregroundLayer); + drawableHitObject.StartTimeBindable.BindValueChanged(startTime => + { + timingPoint = beatSyncProvider?.ControlPoints?.TimingPointAt(startTime.NewValue) ?? TimingControlPoint.DEFAULT; + }, true); + // Animations in taiko skins are used in a custom way (>150 combo and animating in time with beat). // For now just stop at first frame for sanity. foreach (var c in InternalChildren) @@ -115,14 +120,8 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy return; } - if (beatSyncProvider?.ControlPoints != null) - { - beatLength = beatSyncProvider.ControlPoints.TimingPointAt(Time.Current).BeatLength; - - animationFrame = Time.Current % ((beatLength * 2) / multiplier) >= beatLength / multiplier ? 0 : 1; - - animatableForegroundLayer.GotoFrame(animationFrame); - } + animationFrame = Math.Abs(Time.Current - timingPoint.Time) % ((timingPoint.BeatLength * 2) / multiplier) >= timingPoint.BeatLength / multiplier ? 0 : 1; + animatableForegroundLayer.GotoFrame(animationFrame); } private Color4 accentColour; diff --git a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs index d6824109b3..aa31b1924f 100644 --- a/osu.Game.Rulesets.Taiko/TaikoRuleset.cs +++ b/osu.Game.Rulesets.Taiko/TaikoRuleset.cs @@ -197,6 +197,8 @@ namespace osu.Game.Rulesets.Taiko public int LegacyID => 1; + public ILegacyScoreSimulator CreateLegacyScoreSimulator() => new TaikoLegacyScoreSimulator(); + public override IConvertibleReplayFrame CreateConvertibleReplayFrame() => new TaikoReplayFrame(); public override IRulesetConfigManager CreateConfig(SettingsStore? settings) => new TaikoRulesetConfigManager(settings, RulesetInfo); diff --git a/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs b/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs index 92f2b74568..6098db4f7a 100644 --- a/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs +++ b/osu.Game.Rulesets.Taiko/UI/DrumSampleTriggerSource.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Linq; using osu.Game.Audio; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.UI; @@ -18,12 +17,25 @@ namespace osu.Game.Rulesets.Taiko.UI public void Play(HitType hitType) { - var hitSample = GetMostValidObject()?.Samples?.FirstOrDefault(o => o.Name == HitSampleInfo.HIT_NORMAL); + TaikoHitObject? hitObject = GetMostValidObject() as TaikoHitObject; - if (hitSample == null) + if (hitObject == null) return; - PlaySamples(new ISampleInfo[] { new HitSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL, hitSample.Bank, volume: hitSample.Volume) }); + var baseSample = hitObject.CreateHitSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_CLAP : HitSampleInfo.HIT_NORMAL); + + if ((hitObject as TaikoStrongableHitObject)?.IsStrong == true || hitObject is StrongNestedHitObject) + { + PlaySamples(new ISampleInfo[] + { + baseSample, + hitObject.CreateHitSampleInfo(hitType == HitType.Rim ? HitSampleInfo.HIT_WHISTLE : HitSampleInfo.HIT_FINISH) + }); + } + else + { + PlaySamples(new ISampleInfo[] { baseSample }); + } } public override void Play() => throw new InvalidOperationException(@"Use override with HitType parameter instead"); diff --git a/osu.Game.Tests/Beatmaps/WorkingBeatmapManagerTest.cs b/osu.Game.Tests/Beatmaps/WorkingBeatmapManagerTest.cs index 89b8c8927d..237fe758b5 100644 --- a/osu.Game.Tests/Beatmaps/WorkingBeatmapManagerTest.cs +++ b/osu.Game.Tests/Beatmaps/WorkingBeatmapManagerTest.cs @@ -64,7 +64,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestCachedRetrievalWithFiles() => AddStep("run test", () => { - var beatmap = Realm.Run(r => r.Find(importedSet.Beatmaps.First().ID).Detach()); + var beatmap = Realm.Run(r => r.Find(importedSet.Beatmaps.First().ID)!.Detach()); Assert.That(beatmap.BeatmapSet?.Files, Has.Count.GreaterThan(0)); @@ -90,7 +90,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestForcedRefetchRetrievalWithFiles() => AddStep("run test", () => { - var beatmap = Realm.Run(r => r.Find(importedSet.Beatmaps.First().ID).Detach()); + var beatmap = Realm.Run(r => r.Find(importedSet.Beatmaps.First().ID)!.Detach()); Assert.That(beatmap.BeatmapSet?.Files, Has.Count.GreaterThan(0)); @@ -102,7 +102,7 @@ namespace osu.Game.Tests.Beatmaps [Test] public void TestSavePreservesCollections() => AddStep("run test", () => { - var beatmap = Realm.Run(r => r.Find(importedSet.Beatmaps.First().ID).Detach()); + var beatmap = Realm.Run(r => r.Find(importedSet.Beatmaps.First().ID)!.Detach()); var working = beatmaps.GetWorkingBeatmap(beatmap); diff --git a/osu.Game.Tests/Database/BackgroundBeatmapProcessorTests.cs b/osu.Game.Tests/Database/BackgroundBeatmapProcessorTests.cs index ddb60606ec..c876316be4 100644 --- a/osu.Game.Tests/Database/BackgroundBeatmapProcessorTests.cs +++ b/osu.Game.Tests/Database/BackgroundBeatmapProcessorTests.cs @@ -42,7 +42,7 @@ namespace osu.Game.Tests.Database { return Realm.Run(r => { - var beatmapSetInfo = r.Find(importedSet.ID); + var beatmapSetInfo = r.Find(importedSet.ID)!; return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0); }); }); @@ -51,7 +51,7 @@ namespace osu.Game.Tests.Database { Realm.Write(r => { - var beatmapSetInfo = r.Find(importedSet.ID); + var beatmapSetInfo = r.Find(importedSet.ID)!; foreach (var b in beatmapSetInfo.Beatmaps) b.StarRating = -1; }); @@ -66,7 +66,7 @@ namespace osu.Game.Tests.Database { return Realm.Run(r => { - var beatmapSetInfo = r.Find(importedSet.ID); + var beatmapSetInfo = r.Find(importedSet.ID)!; return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0); }); }); @@ -79,7 +79,7 @@ namespace osu.Game.Tests.Database { return Realm.Run(r => { - var beatmapSetInfo = r.Find(importedSet.ID); + var beatmapSetInfo = r.Find(importedSet.ID)!; return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0); }); }); @@ -90,7 +90,7 @@ namespace osu.Game.Tests.Database { Realm.Write(r => { - var beatmapSetInfo = r.Find(importedSet.ID); + var beatmapSetInfo = r.Find(importedSet.ID)!; foreach (var b in beatmapSetInfo.Beatmaps) b.StarRating = -1; }); @@ -107,7 +107,7 @@ namespace osu.Game.Tests.Database { return Realm.Run(r => { - var beatmapSetInfo = r.Find(importedSet.ID); + var beatmapSetInfo = r.Find(importedSet.ID)!; return beatmapSetInfo.Beatmaps.All(b => b.StarRating == -1); }); }); @@ -118,7 +118,7 @@ namespace osu.Game.Tests.Database { return Realm.Run(r => { - var beatmapSetInfo = r.Find(importedSet.ID); + var beatmapSetInfo = r.Find(importedSet.ID)!; return beatmapSetInfo.Beatmaps.All(b => b.StarRating > 0); }); }); diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index 446eb72b04..0eac70f9c8 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -18,6 +18,7 @@ using osu.Game.Extensions; using osu.Game.Models; using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; +using osu.Game.Scoring; using osu.Game.Tests.Resources; using Realms; using SharpCompress.Archives; @@ -416,6 +417,108 @@ namespace osu.Game.Tests.Database }); } + [Test] + public void TestImport_Modify_Revert() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var store = new RealmRulesetStore(realm, storage); + + var imported = await LoadOszIntoStore(importer, realm.Realm); + + await createScoreForBeatmap(realm.Realm, imported.Beatmaps.First()); + + var score = realm.Run(r => r.All().Single()); + + string originalHash = imported.Beatmaps.First().Hash; + const string modified_hash = "new_hash"; + + Assert.That(imported.Beatmaps.First().Scores.Single(), Is.EqualTo(score)); + + Assert.That(score.BeatmapHash, Is.EqualTo(originalHash)); + Assert.That(score.BeatmapInfo, Is.EqualTo(imported.Beatmaps.First())); + + // imitate making local changes via editor + // ReSharper disable once MethodHasAsyncOverload + realm.Write(r => + { + BeatmapInfo beatmap = imported.Beatmaps.First(); + beatmap.Hash = modified_hash; + beatmap.ResetOnlineInfo(); + beatmap.UpdateLocalScores(r); + }); + + Assert.That(!imported.Beatmaps.First().Scores.Any()); + + Assert.That(score.BeatmapInfo, Is.Null); + Assert.That(score.BeatmapHash, Is.EqualTo(originalHash)); + + // imitate reverting the local changes made above + // ReSharper disable once MethodHasAsyncOverload + realm.Write(r => + { + BeatmapInfo beatmap = imported.Beatmaps.First(); + beatmap.Hash = originalHash; + beatmap.ResetOnlineInfo(); + beatmap.UpdateLocalScores(r); + }); + + Assert.That(imported.Beatmaps.First().Scores.Single(), Is.EqualTo(score)); + + Assert.That(score.BeatmapHash, Is.EqualTo(originalHash)); + Assert.That(score.BeatmapInfo, Is.EqualTo(imported.Beatmaps.First())); + }); + } + + [Test] + public void TestImport_ThenModifyMapWithScore_ThenImport() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var store = new RealmRulesetStore(realm, storage); + + string? temp = TestResources.GetTestBeatmapForImport(); + + var imported = await LoadOszIntoStore(importer, realm.Realm); + + await createScoreForBeatmap(realm.Realm, imported.Beatmaps.First()); + + Assert.That(imported.Beatmaps.First().Scores.Any()); + + // imitate making local changes via editor + // ReSharper disable once MethodHasAsyncOverload + realm.Write(r => + { + BeatmapInfo beatmap = imported.Beatmaps.First(); + beatmap.Hash = "new_hash"; + beatmap.ResetOnlineInfo(); + beatmap.UpdateLocalScores(r); + }); + + Assert.That(!imported.Beatmaps.First().Scores.Any()); + + var importedSecondTime = await importer.Import(new ImportTask(temp)); + + EnsureLoaded(realm.Realm); + + // check the newly "imported" beatmap is not the original. + Assert.NotNull(importedSecondTime); + Debug.Assert(importedSecondTime != null); + Assert.That(imported.ID != importedSecondTime.ID); + + var importedFirstTimeBeatmap = imported.Beatmaps.First(); + var importedSecondTimeBeatmap = importedSecondTime.PerformRead(s => s.Beatmaps.First()); + + Assert.That(importedFirstTimeBeatmap.ID != importedSecondTimeBeatmap.ID); + Assert.That(importedFirstTimeBeatmap.Hash != importedSecondTimeBeatmap.Hash); + Assert.That(!importedFirstTimeBeatmap.Scores.Any()); + Assert.That(importedSecondTimeBeatmap.Scores.Count() == 1); + Assert.That(importedSecondTimeBeatmap.Scores.Single().BeatmapInfo, Is.EqualTo(importedSecondTimeBeatmap)); + }); + } + [Test] public void TestImportThenImportWithChangedFile() { @@ -1074,18 +1177,16 @@ namespace osu.Game.Tests.Database Assert.IsTrue(realm.All().First(_ => true).DeletePending); } - private static Task createScoreForBeatmap(Realm realm, BeatmapInfo beatmap) - { - // TODO: reimplement when we have score support in realm. - // return ImportScoreTest.LoadScoreIntoOsu(osu, new ScoreInfo - // { - // OnlineID = 2, - // Beatmap = beatmap, - // BeatmapInfoID = beatmap.ID - // }, new ImportScoreTest.TestArchiveReader()); - - return Task.CompletedTask; - } + private static Task createScoreForBeatmap(Realm realm, BeatmapInfo beatmap) => + realm.WriteAsync(() => + { + realm.Add(new ScoreInfo + { + OnlineID = 2, + BeatmapInfo = beatmap, + BeatmapHash = beatmap.Hash + }); + }); private static void checkBeatmapSetCount(Realm realm, int expected, bool includeDeletePending = false) { diff --git a/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs b/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs index b94cff2a9a..d30b3c089e 100644 --- a/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterUpdateTests.cs @@ -323,7 +323,7 @@ namespace osu.Game.Tests.Database var beatmapInfo = s.Beatmaps.First(b => b.File?.Filename != removedFilename); scoreTargetBeatmapHash = beatmapInfo.Hash; - s.Realm.Add(new ScoreInfo(beatmapInfo, s.Realm.All().First(), new RealmUser())); + s.Realm!.Add(new ScoreInfo(beatmapInfo, s.Realm.All().First(), new RealmUser())); }); realm.Run(r => r.Refresh()); @@ -347,6 +347,73 @@ namespace osu.Game.Tests.Database }); } + [Test] + public void TestDanglingScoreTransferred() + { + RunTestWithRealmAsync(async (realm, storage) => + { + var importer = new BeatmapImporter(storage, realm); + using var rulesets = new RealmRulesetStore(realm, storage); + + using var __ = getBeatmapArchive(out string pathOriginal); + using var _ = getBeatmapArchive(out string pathOnlineCopy); + + var importBeforeUpdate = await importer.Import(new ImportTask(pathOriginal)); + + Assert.That(importBeforeUpdate, Is.Not.Null); + Debug.Assert(importBeforeUpdate != null); + + string scoreTargetBeatmapHash = string.Empty; + + // set a score on the beatmap + importBeforeUpdate.PerformWrite(s => + { + var beatmapInfo = s.Beatmaps.First(); + + scoreTargetBeatmapHash = beatmapInfo.Hash; + + s.Realm!.Add(new ScoreInfo(beatmapInfo, s.Realm.All().First(), new RealmUser())); + }); + + // locally modify beatmap + const string new_beatmap_hash = "new_hash"; + importBeforeUpdate.PerformWrite(s => + { + var beatmapInfo = s.Beatmaps.First(b => b.Hash == scoreTargetBeatmapHash); + + beatmapInfo.Hash = new_beatmap_hash; + beatmapInfo.ResetOnlineInfo(); + beatmapInfo.UpdateLocalScores(s.Realm!); + }); + + realm.Run(r => r.Refresh()); + + // making changes to a beatmap doesn't remove the score from realm, but should disassociate the beatmap. + checkCount(realm, 1); + Assert.That(realm.Run(r => r.All().First().BeatmapInfo), Is.Null); + + // reimport the original beatmap before local modifications + var importAfterUpdate = await importer.ImportAsUpdate(new ProgressNotification(), new ImportTask(pathOnlineCopy), importBeforeUpdate.Value); + + Assert.That(importAfterUpdate, Is.Not.Null); + Debug.Assert(importAfterUpdate != null); + + realm.Run(r => r.Refresh()); + + // both original and locally modified versions present + checkCount(realm, count_beatmaps + 1); + checkCount(realm, count_beatmaps + 1); + checkCount(realm, 2); + + // score is preserved + checkCount(realm, 1); + + // score is transferred to new beatmap + Assert.That(importBeforeUpdate.Value.Beatmaps.First(b => b.Hash == new_beatmap_hash).Scores, Has.Count.EqualTo(0)); + Assert.That(importAfterUpdate.Value.Beatmaps.First(b => b.Hash == scoreTargetBeatmapHash).Scores, Has.Count.EqualTo(1)); + }); + } + [Test] public void TestScoreLostOnModification() { @@ -368,7 +435,7 @@ namespace osu.Game.Tests.Database { var beatmapInfo = s.Beatmaps.Last(); scoreTargetFilename = beatmapInfo.File?.Filename; - s.Realm.Add(new ScoreInfo(beatmapInfo, s.Realm.All().First(), new RealmUser())); + s.Realm!.Add(new ScoreInfo(beatmapInfo, s.Realm.All().First(), new RealmUser())); }); realm.Run(r => r.Refresh()); @@ -461,7 +528,7 @@ namespace osu.Game.Tests.Database importBeforeUpdate.PerformWrite(s => { - var beatmapCollection = s.Realm.Add(new BeatmapCollection("test collection")); + var beatmapCollection = s.Realm!.Add(new BeatmapCollection("test collection")); beatmapsToAddToCollection = s.Beatmaps.Count - (allOriginalBeatmapsInCollection ? 0 : 1); for (int i = 0; i < beatmapsToAddToCollection; i++) @@ -476,7 +543,7 @@ namespace osu.Game.Tests.Database importAfterUpdate.PerformRead(updated => { - updated.Realm.Refresh(); + updated.Realm!.Refresh(); string[] hashes = updated.Realm.All().Single().BeatmapMD5Hashes.ToArray(); @@ -526,7 +593,7 @@ namespace osu.Game.Tests.Database importBeforeUpdate.PerformWrite(s => { - var beatmapCollection = s.Realm.Add(new BeatmapCollection("test collection")); + var beatmapCollection = s.Realm!.Add(new BeatmapCollection("test collection")); originalHash = s.Beatmaps.Single(b => b.DifficultyName == "Hard").MD5Hash; beatmapCollection.BeatmapMD5Hashes.Add(originalHash); @@ -540,7 +607,7 @@ namespace osu.Game.Tests.Database importAfterUpdate.PerformRead(updated => { - updated.Realm.Refresh(); + updated.Realm!.Refresh(); string[] hashes = updated.Realm.All().Single().BeatmapMD5Hashes.ToArray(); string updatedHash = updated.Beatmaps.Single(b => b.DifficultyName == "Hard").MD5Hash; diff --git a/osu.Game.Tests/Database/GeneralUsageTests.cs b/osu.Game.Tests/Database/GeneralUsageTests.cs index fd0b391d0d..b8073a65bc 100644 --- a/osu.Game.Tests/Database/GeneralUsageTests.cs +++ b/osu.Game.Tests/Database/GeneralUsageTests.cs @@ -128,7 +128,7 @@ namespace osu.Game.Tests.Database realm.RegisterCustomSubscription(r => { - var subscription = r.All().QueryAsyncWithNotifications((_, _, _) => + var subscription = r.All().QueryAsyncWithNotifications((_, _) => { realm.Run(_ => { diff --git a/osu.Game.Tests/Database/RealmLiveTests.cs b/osu.Game.Tests/Database/RealmLiveTests.cs index d853e75db0..cea30acf3f 100644 --- a/osu.Game.Tests/Database/RealmLiveTests.cs +++ b/osu.Game.Tests/Database/RealmLiveTests.cs @@ -355,7 +355,7 @@ namespace osu.Game.Tests.Database return null; }); - void gotChange(IRealmCollection sender, ChangeSet changes, Exception error) + void gotChange(IRealmCollection sender, ChangeSet? changes) { changesTriggered++; } diff --git a/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs b/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs index 4ee302bbd0..45842a952a 100644 --- a/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs +++ b/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs @@ -1,7 +1,6 @@ // 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 System.Threading; @@ -54,7 +53,7 @@ namespace osu.Game.Tests.Database registration.Dispose(); }); - void onChanged(IRealmCollection sender, ChangeSet? changes, Exception error) + void onChanged(IRealmCollection sender, ChangeSet? changes) { lastChanges = changes; @@ -92,7 +91,7 @@ namespace osu.Game.Tests.Database registration.Dispose(); }); - void onChanged(IRealmCollection sender, ChangeSet? changes, Exception error) => lastChanges = changes; + void onChanged(IRealmCollection sender, ChangeSet? changes) => lastChanges = changes; } [Test] @@ -185,7 +184,7 @@ namespace osu.Game.Tests.Database } }); - void onChanged(IRealmCollection sender, ChangeSet? changes, Exception error) + void onChanged(IRealmCollection sender, ChangeSet? changes) { if (changes == null) resolvedItems = sender; diff --git a/osu.Game.Tests/Database/RulesetStoreTests.cs b/osu.Game.Tests/Database/RulesetStoreTests.cs index a5662fa121..8b4c6e2411 100644 --- a/osu.Game.Tests/Database/RulesetStoreTests.cs +++ b/osu.Game.Tests/Database/RulesetStoreTests.cs @@ -76,12 +76,12 @@ namespace osu.Game.Tests.Database Available = true, })); - Assert.That(realm.Run(r => r.Find(rulesetShortName).Available), Is.True); + Assert.That(realm.Run(r => r.Find(rulesetShortName)!.Available), Is.True); // Availability is updated on construction of a RealmRulesetStore var _ = new RealmRulesetStore(realm, storage); - Assert.That(realm.Run(r => r.Find(rulesetShortName).Available), Is.False); + Assert.That(realm.Run(r => r.Find(rulesetShortName)!.Available), Is.False); }); } @@ -101,18 +101,18 @@ namespace osu.Game.Tests.Database Available = true, })); - Assert.That(realm.Run(r => r.Find(rulesetShortName).Available), Is.True); + Assert.That(realm.Run(r => r.Find(rulesetShortName)!.Available), Is.True); // Availability is updated on construction of a RealmRulesetStore var _ = new RealmRulesetStore(realm, storage); - Assert.That(realm.Run(r => r.Find(rulesetShortName).Available), Is.False); + Assert.That(realm.Run(r => r.Find(rulesetShortName)!.Available), Is.False); // Simulate the ruleset getting updated LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION; var __ = new RealmRulesetStore(realm, storage); - Assert.That(realm.Run(r => r.Find(rulesetShortName).Available), Is.True); + Assert.That(realm.Run(r => r.Find(rulesetShortName)!.Available), Is.True); }); } diff --git a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs index f4467867db..e2774cef00 100644 --- a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs +++ b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs @@ -104,7 +104,7 @@ namespace osu.Game.Tests.Database realm.Run(innerRealm => { - var binding = innerRealm.ResolveReference(tsr); + var binding = innerRealm.ResolveReference(tsr)!; innerRealm.Write(() => binding.KeyCombination = new KeyCombination(InputKey.BackSpace)); }); diff --git a/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs b/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs index 04fc4cafbd..10dbede2e0 100644 --- a/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs +++ b/osu.Game.Tests/Gameplay/TestSceneDrawableHitObject.cs @@ -3,6 +3,7 @@ #nullable disable +using System; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; @@ -80,7 +81,9 @@ namespace osu.Game.Tests.Gameplay { TestLifetimeEntry entry = null; AddStep("Create entry", () => entry = new TestLifetimeEntry(new HitObject()) { LifetimeStart = 1 }); + assertJudged(() => entry, false); AddStep("ApplyDefaults", () => entry.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty())); + assertJudged(() => entry, false); AddAssert("Lifetime is updated", () => entry.LifetimeStart == -TestLifetimeEntry.INITIAL_LIFETIME_OFFSET); TestDrawableHitObject dho = null; @@ -91,6 +94,7 @@ namespace osu.Game.Tests.Gameplay }); AddStep("ApplyDefaults", () => entry.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty())); AddAssert("Lifetime is correct", () => dho.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY && entry.LifetimeStart == TestDrawableHitObject.LIFETIME_ON_APPLY); + assertJudged(() => entry, false); } [Test] @@ -138,6 +142,29 @@ namespace osu.Game.Tests.Gameplay AddAssert("DHO state is correct", () => dho.State.Value == ArmedState.Miss); } + [Test] + public void TestJudgedStateThroughLifetime() + { + TestDrawableHitObject dho = null; + HitObjectLifetimeEntry lifetimeEntry = null; + + AddStep("Create lifetime entry", () => lifetimeEntry = new HitObjectLifetimeEntry(new HitObject { StartTime = Time.Current })); + + assertJudged(() => lifetimeEntry, false); + + AddStep("Create DHO and apply entry", () => + { + Child = dho = new TestDrawableHitObject(); + dho.Apply(lifetimeEntry); + }); + + assertJudged(() => lifetimeEntry, false); + + AddStep("Apply result", () => dho.MissForcefully()); + + assertJudged(() => lifetimeEntry, true); + } + [Test] public void TestResultSetBeforeLoadComplete() { @@ -154,15 +181,20 @@ namespace osu.Game.Tests.Gameplay } }; }); + assertJudged(() => lifetimeEntry, true); AddStep("Create DHO and apply entry", () => { dho = new TestDrawableHitObject(); dho.Apply(lifetimeEntry); Child = dho; }); + assertJudged(() => lifetimeEntry, true); AddAssert("DHO state is correct", () => dho.State.Value, () => Is.EqualTo(ArmedState.Hit)); } + private void assertJudged(Func entry, bool val) => + AddAssert(val ? "Is judged" : "Not judged", () => entry().Judged, () => Is.EqualTo(val)); + private partial class TestDrawableHitObject : DrawableHitObject { public const double INITIAL_LIFETIME_OFFSET = 100; diff --git a/osu.Game.Tests/Models/DisplayStringTest.cs b/osu.Game.Tests/Models/DisplayStringTest.cs index d585a0eb9f..b5303e1dd6 100644 --- a/osu.Game.Tests/Models/DisplayStringTest.cs +++ b/osu.Game.Tests/Models/DisplayStringTest.cs @@ -87,10 +87,10 @@ namespace osu.Game.Tests.Models var mock = new Mock(); mock.Setup(m => m.User).Returns(new APIUser { Username = "user" }); // TODO: temporary. - mock.Setup(m => m.Beatmap.Metadata.Artist).Returns("artist"); - mock.Setup(m => m.Beatmap.Metadata.Title).Returns("title"); - mock.Setup(m => m.Beatmap.Metadata.Author.Username).Returns("author"); - mock.Setup(m => m.Beatmap.DifficultyName).Returns("difficulty"); + mock.Setup(m => m.Beatmap!.Metadata.Artist).Returns("artist"); + mock.Setup(m => m.Beatmap!.Metadata.Title).Returns("title"); + mock.Setup(m => m.Beatmap!.Metadata.Author.Username).Returns("author"); + mock.Setup(m => m.Beatmap!.DifficultyName).Returns("difficulty"); Assert.That(mock.Object.GetDisplayString(), Is.EqualTo("user playing artist - title (author) [difficulty]")); } diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs index 80c4e4bce9..b0b9d48cbe 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLocalScoreImport.cs @@ -131,7 +131,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("results screen score has matching", () => (Player.GetChildScreen() as ResultsScreen)?.Score.Mods.First(), () => Is.EqualTo(playerMods.First())); AddUntilStep("score in database", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID) != null)); - AddUntilStep("databased score has correct mods", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID)).Mods.First(), () => Is.EqualTo(playerMods.First())); + AddUntilStep("databased score has correct mods", () => Realm.Run(r => r.Find(Player.Score.ScoreInfo.ID))!.Mods.First(), () => Is.EqualTo(playerMods.First())); } [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs index d16f51f36e..fea7456472 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePoolingRuleset.cs @@ -170,7 +170,16 @@ namespace osu.Game.Tests.Visual.Gameplay ManualClock clock = null; var beatmap = new Beatmap(); - beatmap.HitObjects.Add(new TestHitObjectWithNested { Duration = 40 }); + beatmap.HitObjects.Add(new TestHitObjectWithNested + { + Duration = 40, + NestedObjects = new HitObject[] + { + new PooledNestedHitObject { StartTime = 10 }, + new PooledNestedHitObject { StartTime = 20 }, + new PooledNestedHitObject { StartTime = 30 } + } + }); createTest(beatmap, 10, () => new FramedClock(clock = new ManualClock())); @@ -209,6 +218,49 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("object judged", () => playfield.JudgedObjects.Count == 1); } + [Test] + public void TestPooledObjectWithNonPooledNesteds() + { + ManualClock clock = null; + TestHitObjectWithNested hitObjectWithNested; + + var beatmap = new Beatmap(); + beatmap.HitObjects.Add(hitObjectWithNested = new TestHitObjectWithNested + { + Duration = 40, + NestedObjects = new HitObject[] + { + new PooledNestedHitObject { StartTime = 10 }, + new NonPooledNestedHitObject { StartTime = 20 }, + new NonPooledNestedHitObject { StartTime = 30 } + } + }); + + createTest(beatmap, 10, () => new FramedClock(clock = new ManualClock())); + + AddAssert("hitobject entry has all nesteds", () => playfield.HitObjectContainer.Entries.Single().NestedEntries, () => Has.Count.EqualTo(3)); + + AddStep("skip to middle of object", () => clock.CurrentTime = (hitObjectWithNested.StartTime + hitObjectWithNested.GetEndTime()) / 2); + AddAssert("2 objects judged", () => playfield.JudgedObjects.Count, () => Is.EqualTo(2)); + AddAssert("entry not all judged", () => playfield.HitObjectContainer.Entries.Single().AllJudged, () => Is.False); + + AddStep("skip to before end of object", () => clock.CurrentTime = hitObjectWithNested.GetEndTime() - 1); + AddAssert("3 objects judged", () => playfield.JudgedObjects.Count, () => Is.EqualTo(3)); + AddAssert("entry not all judged", () => playfield.HitObjectContainer.Entries.Single().AllJudged, () => Is.False); + + AddStep("removing object doesn't crash", () => playfield.Remove(hitObjectWithNested)); + AddStep("clear judged", () => playfield.JudgedObjects.Clear()); + + AddStep("add object back", () => playfield.Add(hitObjectWithNested)); + AddAssert("entry not all judged", () => playfield.HitObjectContainer.Entries.Single().AllJudged, () => Is.False); + + AddStep("skip to long past object", () => clock.CurrentTime = 100_000); + // the parent entry should still be linked to nested entries of pooled objects that are managed externally + // but not contain synthetic entries that were created for the non-pooled objects. + AddAssert("entry still has non-synthetic nested entries", () => playfield.HitObjectContainer.Entries.Single().NestedEntries, () => Has.Count.EqualTo(1)); + AddAssert("entry all judged", () => playfield.HitObjectContainer.Entries.Single().AllJudged, () => Is.True); + } + private void createTest(IBeatmap beatmap, int poolSize, Func createClock = null) { AddStep("create test", () => @@ -289,7 +341,7 @@ namespace osu.Game.Tests.Visual.Gameplay RegisterPool(poolSize); RegisterPool(poolSize); RegisterPool(poolSize); - RegisterPool(poolSize); + RegisterPool(poolSize); } protected override HitObjectLifetimeEntry CreateLifetimeEntry(HitObject hitObject) => new TestHitObjectLifetimeEntry(hitObject); @@ -422,16 +474,22 @@ namespace osu.Game.Tests.Visual.Gameplay private class TestHitObjectWithNested : TestHitObject { + public IEnumerable NestedObjects { get; init; } = Array.Empty(); + protected override void CreateNestedHitObjects(CancellationToken cancellationToken) { base.CreateNestedHitObjects(cancellationToken); - for (int i = 0; i < 3; ++i) - AddNested(new NestedHitObject { StartTime = (float)Duration * (i + 1) / 4 }); + foreach (var ho in NestedObjects) + AddNested(ho); } } - private class NestedHitObject : ConvertHitObject + private class PooledNestedHitObject : ConvertHitObject + { + } + + private class NonPooledNestedHitObject : ConvertHitObject { } @@ -482,6 +540,9 @@ namespace osu.Game.Tests.Visual.Gameplay nestedContainer.Clear(false); } + protected override DrawableHitObject CreateNestedHitObject(HitObject hitObject) + => hitObject is NonPooledNestedHitObject nonPooled ? new DrawableNestedHitObject(nonPooled) : null; + protected override void CheckForResult(bool userTriggered, double timeOffset) { base.CheckForResult(userTriggered, timeOffset); @@ -490,25 +551,30 @@ namespace osu.Game.Tests.Visual.Gameplay } } - private partial class DrawableNestedHitObject : DrawableHitObject + private partial class DrawableNestedHitObject : DrawableHitObject { public DrawableNestedHitObject() - : this(null) { } - public DrawableNestedHitObject(NestedHitObject hitObject) + public DrawableNestedHitObject(PooledNestedHitObject hitObject) + : base(hitObject) + { + } + + public DrawableNestedHitObject(NonPooledNestedHitObject hitObject) : base(hitObject) { - Size = new Vector2(15); - Colour = Colour4.White; - RelativePositionAxes = Axes.Both; - Origin = Anchor.Centre; } [BackgroundDependencyLoader] private void load() { + Size = new Vector2(15); + Colour = Colour4.White; + RelativePositionAxes = Axes.Both; + Origin = Anchor.Centre; + AddInternal(new Circle { RelativeSizeAxes = Axes.Both, diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index 2da29ccc95..95ae4c5e80 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -107,6 +107,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestBeatmapDownloadingStates() { + AddStep("set to unknown", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Unknown())); AddStep("set to no map", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.NotDownloaded())); AddStep("set to downloading map", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0))); @@ -382,6 +383,8 @@ namespace osu.Game.Tests.Visual.Multiplayer }); AddUntilStep("wait for list to load", () => participantsList?.IsLoaded == true); + + AddStep("set beatmap available", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.LocallyAvailable())); } private void checkProgressBarVisibility(bool visible) => diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs index 3004cb8a0c..0f17b08b7b 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs @@ -33,7 +33,7 @@ namespace osu.Game.Tests.Visual.Ranking AddStep("show excess mods score", () => { var score = TestResources.CreateTestScoreInfo(); - score.Mods = score.BeatmapInfo.Ruleset.CreateInstance().CreateAllMods().ToArray(); + score.Mods = score.BeatmapInfo!.Ruleset.CreateInstance().CreateAllMods().ToArray(); showPanel(CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)), score); }); } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs index c05774400f..d71c72f4ec 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs @@ -48,7 +48,7 @@ namespace osu.Game.Tests.Visual.Ranking var author = new RealmUser { Username = "mapper_name" }; var score = TestResources.CreateTestScoreInfo(createTestBeatmap(author)); - score.Mods = score.BeatmapInfo.Ruleset.CreateInstance().CreateAllMods().ToArray(); + score.Mods = score.BeatmapInfo!.Ruleset.CreateInstance().CreateAllMods().ToArray(); showPanel(score); }); diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index 42068ff117..c5b61c1a90 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -405,7 +405,7 @@ namespace osu.Game.Tests.Visual.Ranking public UnrankedSoloResultsScreen(ScoreInfo score) : base(score, true) { - Score.BeatmapInfo.OnlineID = 0; + Score.BeatmapInfo!.OnlineID = 0; Score.BeatmapInfo.Status = BeatmapOnlineStatus.Pending; } diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index af3a6e178c..0bff40f258 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -163,7 +163,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.Key(Key.Enter); }); - AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); + waitForDismissed(); AddAssert("ensure selection changed", () => selected != Beatmap.Value); } @@ -186,7 +186,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.Key(Key.Down); }); - AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); + waitForDismissed(); AddAssert("ensure selection didn't change", () => selected == Beatmap.Value); } @@ -215,7 +215,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.Key(Key.Enter); }); - AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); + waitForDismissed(); AddAssert("ensure selection changed", () => selected != Beatmap.Value); } @@ -244,7 +244,7 @@ namespace osu.Game.Tests.Visual.SongSelect InputManager.ReleaseButton(MouseButton.Left); }); - AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); + waitForDismissed(); AddAssert("ensure selection didn't change", () => selected == Beatmap.Value); } @@ -257,7 +257,7 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); - AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); + waitForDismissed(); AddStep("return", () => songSelect!.MakeCurrent()); AddUntilStep("wait for current", () => songSelect!.IsCurrentScreen()); @@ -275,7 +275,7 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); - AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); + waitForDismissed(); AddStep("change convert setting", () => config.SetValue(OsuSetting.ShowConvertedBeatmaps, true)); @@ -292,7 +292,7 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); AddStep("push child screen", () => Stack.Push(new TestSceneOsuScreenStack.TestScreen("test child"))); - AddUntilStep("wait for not current", () => !songSelect!.IsCurrentScreen()); + waitForDismissed(); AddStep("update beatmap", () => { @@ -1011,7 +1011,7 @@ namespace osu.Game.Tests.Visual.SongSelect }); }); - AddUntilStep("wait for results screen presented", () => !songSelect!.IsCurrentScreen()); + waitForDismissed(); AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(getPresentBeatmap())); AddAssert("check ruleset is correct for score", () => Ruleset.Value.OnlineID == 0); @@ -1040,7 +1040,7 @@ namespace osu.Game.Tests.Visual.SongSelect songSelect!.PresentScore(TestResources.CreateTestScoreInfo(getPresentBeatmap())); }); - AddUntilStep("wait for results screen presented", () => !songSelect!.IsCurrentScreen()); + waitForDismissed(); AddAssert("check beatmap is correct for score", () => Beatmap.Value.BeatmapInfo.MatchesOnlineID(getPresentBeatmap())); AddAssert("check ruleset is correct for score", () => Ruleset.Value.OnlineID == 0); @@ -1161,6 +1161,8 @@ namespace osu.Game.Tests.Visual.SongSelect rulesets.Dispose(); } + private void waitForDismissed() => AddUntilStep("wait for not current", () => !songSelect.AsNonNull().IsCurrentScreen()); + private partial class TestSongSelect : PlaySongSelect { public Action? StartRequested; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs index 2d54a4e566..bf6d8e524f 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModPresetColumn.cs @@ -73,7 +73,7 @@ namespace osu.Game.Tests.Visual.UserInterface var testPresets = createTestPresets(); foreach (var preset in testPresets) - preset.Ruleset = realm.Find(preset.Ruleset.ShortName); + preset.Ruleset = realm.Find(preset.Ruleset.ShortName)!; realm.Add(testPresets); }); @@ -103,7 +103,7 @@ namespace osu.Game.Tests.Visual.UserInterface new ManiaModNightcore(), new ManiaModHardRock() }, - Ruleset = r.Find("mania") + Ruleset = r.Find("mania")! }))); AddUntilStep("2 panels visible", () => this.ChildrenOfType().Count() == 2); @@ -115,7 +115,7 @@ namespace osu.Game.Tests.Visual.UserInterface new OsuModHidden(), new OsuModHardRock() }, - Ruleset = r.Find("osu") + Ruleset = r.Find("osu")! }))); AddUntilStep("2 panels visible", () => this.ChildrenOfType().Count() == 2); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs index dcb1f730a2..4cb6899ebc 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneModSelectOverlay.cs @@ -60,7 +60,7 @@ namespace osu.Game.Tests.Visual.UserInterface { Name = "AR0", Description = "Too... many... circles...", - Ruleset = r.Find(OsuRuleset.SHORT_NAME), + Ruleset = r.Find(OsuRuleset.SHORT_NAME)!, Mods = new[] { new OsuModDifficultyAdjust @@ -73,7 +73,7 @@ namespace osu.Game.Tests.Visual.UserInterface { Name = "Half Time 0.5x", Description = "Very slow", - Ruleset = r.Find(OsuRuleset.SHORT_NAME), + Ruleset = r.Find(OsuRuleset.SHORT_NAME)!, Mods = new[] { new OsuModHalfTime diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index 7d0571dde0..29f0f4f356 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -9,7 +9,6 @@ using System.Linq; using System.Threading.Tasks; using Newtonsoft.Json; using osu.Framework.Allocation; -using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Textures; using osu.Framework.Input; @@ -94,7 +93,7 @@ namespace osu.Game.Tournament Task.Run(readBracket); } - private void readBracket() + private async Task readBracket() { try { @@ -102,7 +101,7 @@ namespace osu.Game.Tournament { using (Stream stream = storage.GetStream(BRACKET_FILENAME, FileAccess.Read, FileMode.Open)) using (var sr = new StreamReader(stream)) - ladder = JsonConvert.DeserializeObject(sr.ReadToEnd(), new JsonPointConverter()); + ladder = JsonConvert.DeserializeObject(await sr.ReadToEndAsync().ConfigureAwait(false), new JsonPointConverter()); } ladder ??= new LadderInfo(); @@ -166,8 +165,8 @@ namespace osu.Game.Tournament } addedInfo |= addPlayers(); - addedInfo |= addRoundBeatmaps(); - addedInfo |= addSeedingBeatmaps(); + addedInfo |= await addRoundBeatmaps().ConfigureAwait(false); + addedInfo |= await addSeedingBeatmaps().ConfigureAwait(false); if (addedInfo) saveChanges(); @@ -233,7 +232,7 @@ namespace osu.Game.Tournament /// /// Add missing beatmap info based on beatmap IDs /// - private bool addRoundBeatmaps() + private async Task addRoundBeatmaps() { var beatmapsRequiringPopulation = ladder.Rounds .SelectMany(r => r.Beatmaps) @@ -246,7 +245,7 @@ namespace osu.Game.Tournament { var b = beatmapsRequiringPopulation[i]; - b.Beatmap = new TournamentBeatmap(beatmapCache.GetBeatmapAsync(b.ID).GetResultSafely() ?? new APIBeatmap()); + b.Beatmap = new TournamentBeatmap(await beatmapCache.GetBeatmapAsync(b.ID).ConfigureAwait(false) ?? new APIBeatmap()); updateLoadProgressMessage($"Populating round beatmaps ({i} / {beatmapsRequiringPopulation.Count})"); } @@ -257,7 +256,7 @@ namespace osu.Game.Tournament /// /// Add missing beatmap info based on beatmap IDs /// - private bool addSeedingBeatmaps() + private async Task addSeedingBeatmaps() { var beatmapsRequiringPopulation = ladder.Teams .SelectMany(r => r.SeedingResults) @@ -271,7 +270,7 @@ namespace osu.Game.Tournament { var b = beatmapsRequiringPopulation[i]; - b.Beatmap = new TournamentBeatmap(beatmapCache.GetBeatmapAsync(b.ID).GetResultSafely() ?? new APIBeatmap()); + b.Beatmap = new TournamentBeatmap(await beatmapCache.GetBeatmapAsync(b.ID).ConfigureAwait(false) ?? new APIBeatmap()); updateLoadProgressMessage($"Populating seeding beatmaps ({i} / {beatmapsRequiringPopulation.Count})"); } diff --git a/osu.Game/BackgroundBeatmapProcessor.cs b/osu.Game/BackgroundBeatmapProcessor.cs index b8c89d8822..9f62dae1f5 100644 --- a/osu.Game/BackgroundBeatmapProcessor.cs +++ b/osu.Game/BackgroundBeatmapProcessor.cs @@ -14,8 +14,11 @@ using osu.Framework.Graphics; using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Database; +using osu.Game.Overlays; +using osu.Game.Overlays.Notifications; using osu.Game.Rulesets; using osu.Game.Scoring; +using osu.Game.Scoring.Legacy; using osu.Game.Screens.Play; namespace osu.Game @@ -25,6 +28,9 @@ namespace osu.Game [Resolved] private RulesetStore rulesetStore { get; set; } = null!; + [Resolved] + private BeatmapManager beatmapManager { get; set; } = null!; + [Resolved] private ScoreManager scoreManager { get; set; } = null!; @@ -40,19 +46,23 @@ namespace osu.Game [Resolved] private ILocalUserPlayInfo? localUserPlayInfo { get; set; } + [Resolved] + private INotificationOverlay? notificationOverlay { get; set; } + protected virtual int TimeToSleepDuringGameplay => 30000; protected override void LoadComplete() { base.LoadComplete(); - Task.Run(() => + Task.Factory.StartNew(() => { Logger.Log("Beginning background beatmap processing.."); checkForOutdatedStarRatings(); processBeatmapSetsWithMissingMetrics(); processScoresWithMissingStatistics(); - }).ContinueWith(t => + convertLegacyTotalScoreToStandardised(); + }, TaskCreationOptions.LongRunning).ContinueWith(t => { if (t.Exception?.InnerException is ObjectDisposedException) { @@ -92,7 +102,7 @@ namespace osu.Game } } - r.Find(ruleset.ShortName).LastAppliedDifficultyVersion = currentVersion; + r.Find(ruleset.ShortName)!.LastAppliedDifficultyVersion = currentVersion; }); Logger.Log($"Finished resetting {countReset} beatmap sets for {ruleset.Name}"); @@ -121,11 +131,7 @@ namespace osu.Game foreach (var id in beatmapSetIds) { - while (localUserPlayInfo?.IsPlaying.Value == true) - { - Logger.Log("Background processing sleeping due to active gameplay..."); - Thread.Sleep(TimeToSleepDuringGameplay); - } + sleepIfRequired(); realmAccess.Run(r => { @@ -157,8 +163,12 @@ namespace osu.Game { foreach (var score in r.All()) { - if (score.Statistics.Sum(kvp => kvp.Value) > 0 && score.MaximumStatistics.Sum(kvp => kvp.Value) == 0) + if (score.BeatmapInfo != null + && score.Statistics.Sum(kvp => kvp.Value) > 0 + && score.MaximumStatistics.Sum(kvp => kvp.Value) == 0) + { scoreIds.Add(score.ID); + } } }); @@ -166,11 +176,7 @@ namespace osu.Game foreach (var id in scoreIds) { - while (localUserPlayInfo?.IsPlaying.Value == true) - { - Logger.Log("Background processing sleeping due to active gameplay..."); - Thread.Sleep(TimeToSleepDuringGameplay); - } + sleepIfRequired(); try { @@ -182,16 +188,105 @@ namespace osu.Game // ReSharper disable once MethodHasAsyncOverload realmAccess.Write(r => { - r.Find(id).MaximumStatisticsJson = JsonConvert.SerializeObject(score.MaximumStatistics); + r.Find(id)!.MaximumStatisticsJson = JsonConvert.SerializeObject(score.MaximumStatistics); }); Logger.Log($"Populated maximum statistics for score {id}"); } + catch (ObjectDisposedException) + { + throw; + } catch (Exception e) { Logger.Log(@$"Failed to populate maximum statistics for {id}: {e}"); } } } + + private void convertLegacyTotalScoreToStandardised() + { + Logger.Log("Querying for scores that need total score conversion..."); + + HashSet scoreIds = realmAccess.Run(r => new HashSet(r.All() + .Where(s => s.BeatmapInfo != null && s.TotalScoreVersion == 30000002) + .AsEnumerable().Select(s => s.ID))); + + Logger.Log($"Found {scoreIds.Count} scores which require total score conversion."); + + if (scoreIds.Count == 0) + return; + + ProgressNotification notification = new ProgressNotification { State = ProgressNotificationState.Active }; + + notificationOverlay?.Post(notification); + + int processedCount = 0; + int failedCount = 0; + + foreach (var id in scoreIds) + { + if (notification.State == ProgressNotificationState.Cancelled) + break; + + notification.Text = $"Upgrading scores to new scoring algorithm ({processedCount} of {scoreIds.Count})"; + notification.Progress = (float)processedCount / scoreIds.Count; + + sleepIfRequired(); + + try + { + var score = scoreManager.Query(s => s.ID == id); + long newTotalScore = StandardisedScoreMigrationTools.ConvertFromLegacyTotalScore(score, beatmapManager); + + // Can't use async overload because we're not on the update thread. + // ReSharper disable once MethodHasAsyncOverload + realmAccess.Write(r => + { + ScoreInfo s = r.Find(id)!; + s.TotalScore = newTotalScore; + s.TotalScoreVersion = LegacyScoreEncoder.LATEST_VERSION; + }); + + Logger.Log($"Converted total score for score {id}"); + ++processedCount; + } + catch (ObjectDisposedException) + { + throw; + } + catch (Exception e) + { + Logger.Log($"Failed to convert total score for {id}: {e}"); + ++failedCount; + } + } + + if (processedCount == scoreIds.Count) + { + notification.CompletionText = $"{processedCount} score(s) have been upgraded to the new scoring algorithm"; + notification.Progress = 1; + notification.State = ProgressNotificationState.Completed; + } + else + { + notification.Text = $"{processedCount} of {scoreIds.Count} score(s) have been upgraded to the new scoring algorithm."; + + // We may have arrived here due to user cancellation or completion with failures. + if (failedCount > 0) + notification.Text += $" Check logs for issues with {failedCount} failed upgrades."; + + notification.State = ProgressNotificationState.Cancelled; + } + } + + private void sleepIfRequired() + { + while (localUserPlayInfo?.IsPlaying.Value == true) + { + Logger.Log("Background processing sleeping due to active gameplay..."); + Thread.Sleep(TimeToSleepDuringGameplay); + } + } } } diff --git a/osu.Game/Beatmaps/BeatmapImporter.cs b/osu.Game/Beatmaps/BeatmapImporter.cs index 7d367ef77d..eadf7a3666 100644 --- a/osu.Game/Beatmaps/BeatmapImporter.cs +++ b/osu.Game/Beatmaps/BeatmapImporter.cs @@ -68,7 +68,7 @@ namespace osu.Game.Beatmaps Logger.Log($"Beatmap \"{updated}\" update completed successfully", LoggingTarget.Database); - original = realm.Find(original.ID); + original = realm!.Find(original.ID)!; // Generally the import process will do this for us if the OnlineIDs match, // but that isn't a guarantee (ie. if the .osu file doesn't have OnlineIDs populated). @@ -204,6 +204,14 @@ namespace osu.Game.Beatmaps protected override void PostImport(BeatmapSetInfo model, Realm realm, ImportParameters parameters) { base.PostImport(model, realm, parameters); + + // Scores are stored separately from beatmaps, and persist even when a beatmap is modified or deleted. + // Let's reattach any matching scores that exist in the database, based on hash. + foreach (BeatmapInfo beatmap in model.Beatmaps) + { + beatmap.UpdateLocalScores(realm); + } + ProcessBeatmap?.Invoke(model, parameters.Batch ? MetadataLookupScope.LocalCacheFirst : MetadataLookupScope.OnlineFirst); } diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 5019d64276..c1aeec1f71 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -234,6 +234,22 @@ namespace osu.Game.Beatmaps } } + /// + /// Local scores are retained separate from a beatmap's lifetime, matched via . + /// Therefore we need to detach / reattach scores when a beatmap is edited or imported. + /// + /// A realm instance in an active write transaction. + public void UpdateLocalScores(Realm realm) + { + // first disassociate any scores which are already attached and no longer valid. + foreach (var score in Scores) + score.BeatmapInfo = null; + + // then attach any scores which match the new hash. + foreach (var score in realm.All().Where(s => s.BeatmapHash == Hash)) + score.BeatmapInfo = this; + } + IBeatmapMetadataInfo IBeatmapInfo.Metadata => Metadata; IBeatmapSetInfo? IBeatmapInfo.BeatmapSet => BeatmapSet; IRulesetInfo IBeatmapInfo.Ruleset => Ruleset; diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 73811b2e62..54c243e842 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -208,7 +208,7 @@ namespace osu.Game.Beatmaps using (var transaction = r.BeginWrite()) { if (!beatmapInfo.IsManaged) - beatmapInfo = r.Find(beatmapInfo.ID); + beatmapInfo = r.Find(beatmapInfo.ID)!; beatmapInfo.Hidden = true; transaction.Commit(); @@ -227,7 +227,7 @@ namespace osu.Game.Beatmaps using (var transaction = r.BeginWrite()) { if (!beatmapInfo.IsManaged) - beatmapInfo = r.Find(beatmapInfo.ID); + beatmapInfo = r.Find(beatmapInfo.ID)!; beatmapInfo.Hidden = false; transaction.Commit(); @@ -330,7 +330,7 @@ namespace osu.Game.Beatmaps Realm.Write(r => { if (!beatmapInfo.IsManaged) - beatmapInfo = r.Find(beatmapInfo.ID); + beatmapInfo = r.Find(beatmapInfo.ID)!; Debug.Assert(beatmapInfo.BeatmapSet != null); Debug.Assert(beatmapInfo.File != null); @@ -460,13 +460,16 @@ namespace osu.Game.Beatmaps Realm.Write(r => { - var liveBeatmapSet = r.Find(setInfo.ID); + var liveBeatmapSet = r.Find(setInfo.ID)!; setInfo.CopyChangesToRealm(liveBeatmapSet); if (transferCollections) beatmapInfo.TransferCollectionReferences(r, oldMd5Hash); + liveBeatmapSet.Beatmaps.Single(b => b.ID == beatmapInfo.ID) + .UpdateLocalScores(r); + // do not look up metadata. // this is a locally-modified set now, so looking up metadata is busy work at best and harmful at worst. ProcessBeatmap?.Invoke(liveBeatmapSet, MetadataLookupScope.None); diff --git a/osu.Game/Beatmaps/BeatmapUpdater.cs b/osu.Game/Beatmaps/BeatmapUpdater.cs index 046adb8327..56bfdc5001 100644 --- a/osu.Game/Beatmaps/BeatmapUpdater.cs +++ b/osu.Game/Beatmaps/BeatmapUpdater.cs @@ -52,7 +52,7 @@ namespace osu.Game.Beatmaps /// /// The managed beatmap set to update. A transaction will be opened to apply changes. /// The preferred scope to use for metadata lookup. - public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) => beatmapSet.Realm.Write(r => + public void Process(BeatmapSetInfo beatmapSet, MetadataLookupScope lookupScope = MetadataLookupScope.LocalCacheFirst) => beatmapSet.Realm!.Write(_ => { // Before we use below, we want to invalidate. workingBeatmapCache.Invalidate(beatmapSet); diff --git a/osu.Game/Collections/CollectionDropdown.cs b/osu.Game/Collections/CollectionDropdown.cs index e95565a5c8..e435992381 100644 --- a/osu.Game/Collections/CollectionDropdown.cs +++ b/osu.Game/Collections/CollectionDropdown.cs @@ -59,7 +59,7 @@ namespace osu.Game.Collections Current.BindValueChanged(selectionChanged); } - private void collectionsChanged(IRealmCollection collections, ChangeSet? changes, Exception error) + private void collectionsChanged(IRealmCollection collections, ChangeSet? changes) { var selectedItem = SelectedItem?.Value?.Collection; diff --git a/osu.Game/Collections/DrawableCollectionList.cs b/osu.Game/Collections/DrawableCollectionList.cs index 0fdf196c4a..6fe38a3229 100644 --- a/osu.Game/Collections/DrawableCollectionList.cs +++ b/osu.Game/Collections/DrawableCollectionList.cs @@ -41,7 +41,7 @@ namespace osu.Game.Collections realmSubscription = realm.RegisterForNotifications(r => r.All().OrderBy(c => c.Name), collectionsChanged); } - private void collectionsChanged(IRealmCollection collections, ChangeSet? changes, Exception error) + private void collectionsChanged(IRealmCollection collections, ChangeSet? changes) { Items.Clear(); Items.AddRange(collections.AsEnumerable().Select(c => c.ToLive(realm))); diff --git a/osu.Game/Collections/DrawableCollectionListItem.cs b/osu.Game/Collections/DrawableCollectionListItem.cs index 0ab0ff520d..4131148f3f 100644 --- a/osu.Game/Collections/DrawableCollectionListItem.cs +++ b/osu.Game/Collections/DrawableCollectionListItem.cs @@ -197,7 +197,7 @@ namespace osu.Game.Collections return true; } - private void deleteCollection() => collection.PerformWrite(c => c.Realm.Remove(c)); + private void deleteCollection() => collection.PerformWrite(c => c.Realm!.Remove(c)); } } } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index ba555a7926..edcbb94368 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -129,6 +129,7 @@ namespace osu.Game.Configuration SetDefault(OsuSetting.ShowHealthDisplayWhenCantFail, true); SetDefault(OsuSetting.FadePlayfieldWhenHealthLow, true); SetDefault(OsuSetting.KeyOverlay, false); + SetDefault(OsuSetting.ReplaySettingsOverlay, true); SetDefault(OsuSetting.GameplayLeaderboard, true); SetDefault(OsuSetting.AlwaysPlayFirstComboBreak, true); @@ -382,6 +383,7 @@ namespace osu.Game.Configuration SafeAreaConsiderations, ComboColourNormalisationAmount, ProfileCoverExpanded, - EditorLimitedDistanceSnap + EditorLimitedDistanceSnap, + ReplaySettingsOverlay } } diff --git a/osu.Game/Database/EmptyRealmSet.cs b/osu.Game/Database/EmptyRealmSet.cs index 7db946d79f..02dfa50fe5 100644 --- a/osu.Game/Database/EmptyRealmSet.cs +++ b/osu.Game/Database/EmptyRealmSet.cs @@ -19,8 +19,8 @@ namespace osu.Game.Database IEnumerator IEnumerable.GetEnumerator() => emptySet.GetEnumerator(); public int Count => emptySet.Count; public T this[int index] => emptySet[index]; - public int IndexOf(object item) => emptySet.IndexOf((T)item); - public bool Contains(object item) => emptySet.Contains((T)item); + public int IndexOf(object? item) => item == null ? -1 : emptySet.IndexOf((T)item); + public bool Contains(object? item) => item != null && emptySet.Contains((T)item); public event NotifyCollectionChangedEventHandler? CollectionChanged { diff --git a/osu.Game/Database/ModelManager.cs b/osu.Game/Database/ModelManager.cs index 7d1dc5239a..47feb8a8f9 100644 --- a/osu.Game/Database/ModelManager.cs +++ b/osu.Game/Database/ModelManager.cs @@ -34,13 +34,13 @@ namespace osu.Game.Database } public void DeleteFile(TModel item, RealmNamedFileUsage file) => - performFileOperation(item, managed => DeleteFile(managed, managed.Files.First(f => f.Filename == file.Filename), managed.Realm)); + performFileOperation(item, managed => DeleteFile(managed, managed.Files.First(f => f.Filename == file.Filename), managed.Realm!)); public void ReplaceFile(TModel item, RealmNamedFileUsage file, Stream contents) => - performFileOperation(item, managed => ReplaceFile(file, contents, managed.Realm)); + performFileOperation(item, managed => ReplaceFile(file, contents, managed.Realm!)); public void AddFile(TModel item, Stream contents, string filename) => - performFileOperation(item, managed => AddFile(managed, contents, filename, managed.Realm)); + performFileOperation(item, managed => AddFile(managed, contents, filename, managed.Realm!)); private void performFileOperation(TModel item, Action operation) { @@ -178,13 +178,14 @@ namespace osu.Game.Database // (ie. if an async import finished very recently). return Realm.Write(realm => { - if (!item.IsManaged) - item = realm.Find(item.ID); + TModel? processableItem = item; + if (!processableItem.IsManaged) + processableItem = realm.Find(item.ID); - if (item?.DeletePending != false) + if (processableItem?.DeletePending != false) return false; - item.DeletePending = true; + processableItem.DeletePending = true; return true; }); } @@ -195,13 +196,14 @@ namespace osu.Game.Database // (ie. if an async import finished very recently). Realm.Write(realm => { - if (!item.IsManaged) - item = realm.Find(item.ID); + TModel? processableItem = item; + if (!processableItem.IsManaged) + processableItem = realm.Find(item.ID); - if (item?.DeletePending != true) + if (processableItem?.DeletePending != true) return; - item.DeletePending = false; + processableItem.DeletePending = false; }); } diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index da4caa42ba..f9f11c49ff 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -78,8 +78,9 @@ namespace osu.Game.Database /// 28 2023-06-08 Added IsLegacyScore to ScoreInfo, parsed from replay files. /// 29 2023-06-12 Run migration of old lazer scores to be best-effort in the new scoring number space. No actual realm changes. /// 30 2023-06-16 Run migration of old lazer scores again. This time with more correct rounding considerations. + /// 31 2023-06-26 Add Version and LegacyTotalScore to ScoreInfo, set Version to 30000002 and copy TotalScore into LegacyTotalScore for legacy scores. /// - private const int schema_version = 30; + private const int schema_version = 31; /// /// Lock object which is held during sections, blocking realm retrieval during blocking periods. @@ -534,7 +535,7 @@ namespace osu.Game.Database lock (notificationsResetMap) { // Store an action which is used when blocking to ensure consumers don't use results of a stale changeset firing. - notificationsResetMap.Add(action, () => callback(new EmptyRealmSet(), null, null)); + notificationsResetMap.Add(action, () => callback(new EmptyRealmSet(), null)); } return RegisterCustomSubscription(action); @@ -754,10 +755,10 @@ namespace osu.Game.Database for (int i = 0; i < itemCount; i++) { - dynamic? oldItem = oldItems.ElementAt(i); - dynamic? newItem = newItems.ElementAt(i); + dynamic oldItem = oldItems.ElementAt(i); + dynamic newItem = newItems.ElementAt(i); - long? nullableOnlineID = oldItem?.OnlineID; + long? nullableOnlineID = oldItem.OnlineID; newItem.OnlineID = (int)(nullableOnlineID ?? -1); } } @@ -794,7 +795,7 @@ namespace osu.Game.Database for (int i = 0; i < metadataCount; i++) { - dynamic? oldItem = oldMetadata.ElementAt(i); + dynamic oldItem = oldMetadata.ElementAt(i); var newItem = newMetadata.ElementAt(i); string username = oldItem.Author; @@ -817,7 +818,7 @@ namespace osu.Game.Database for (int i = 0; i < newSettings.Count; i++) { - dynamic? oldItem = oldSettings.ElementAt(i); + dynamic oldItem = oldSettings.ElementAt(i); var newItem = newSettings.ElementAt(i); long rulesetId = oldItem.RulesetID; @@ -842,7 +843,7 @@ namespace osu.Game.Database for (int i = 0; i < newKeyBindings.Count; i++) { - dynamic? oldItem = oldKeyBindings.ElementAt(i); + dynamic oldItem = oldKeyBindings.ElementAt(i); var newItem = newKeyBindings.ElementAt(i); if (oldItem.RulesetID == null) @@ -896,7 +897,7 @@ namespace osu.Game.Database var scores = migration.NewRealm.All(); foreach (var score in scores) - score.BeatmapHash = score.BeatmapInfo.Hash; + score.BeatmapHash = score.BeatmapInfo?.Hash ?? string.Empty; break; } @@ -966,6 +967,25 @@ namespace osu.Game.Database break; } + + case 31: + { + foreach (var score in migration.NewRealm.All()) + { + if (score.IsLegacyScore && score.Ruleset.IsLegacyRuleset()) + { + // Scores with this version will trigger the score upgrade process in BackgroundBeatmapProcessor. + score.TotalScoreVersion = 30000002; + + // Transfer known legacy scores to a permanent storage field for preservation. + score.LegacyTotalScore = score.TotalScore; + } + else + score.TotalScoreVersion = LegacyScoreEncoder.LATEST_VERSION; + } + + break; + } } Logger.Log($"Migration completed in {stopwatch.ElapsedMilliseconds}ms"); diff --git a/osu.Game/Database/RealmLive.cs b/osu.Game/Database/RealmLive.cs index 9c871a3929..509fabec59 100644 --- a/osu.Game/Database/RealmLive.cs +++ b/osu.Game/Database/RealmLive.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics; using osu.Framework.Development; +using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Statistics; using Realms; @@ -104,7 +105,7 @@ namespace osu.Game.Database PerformRead(t => { - using (var transaction = t.Realm.BeginWrite()) + using (var transaction = t.Realm!.BeginWrite()) { perform(t); transaction.Commit(); @@ -133,7 +134,7 @@ namespace osu.Game.Database { Debug.Assert(ThreadSafety.IsUpdateThread); - if (dataIsFromUpdateThread && !data.Realm.IsClosed) + if (dataIsFromUpdateThread && !data.Realm.AsNonNull().IsClosed) { RealmLiveStatistics.USAGE_UPDATE_IMMEDIATE.Value++; return; @@ -154,7 +155,7 @@ namespace osu.Game.Database // To ensure that behaviour matches what we'd expect (the object *is* available), force // a refresh to bring in any off-thread changes immediately. realm.Refresh(); - found = realm.Find(ID); + found = realm.Find(ID)!; } return found; diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index 5a6c2e3232..72529ed9ff 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -43,7 +43,7 @@ namespace osu.Game.Database .ForMember(s => s.BeatmapSet, cc => cc.Ignore()) .AfterMap((s, d) => { - d.Ruleset = d.Realm.Find(s.Ruleset.ShortName); + d.Ruleset = d.Realm!.Find(s.Ruleset.ShortName)!; copyChangesToRealm(s.Difficulty, d.Difficulty); copyChangesToRealm(s.Metadata, d.Metadata); }); @@ -57,7 +57,7 @@ namespace osu.Game.Database // Importantly, search all of realm for the beatmap (not just the set's beatmaps). // It may have gotten detached, and if that's the case let's use this opportunity to fix // things up. - var existingBeatmap = d.Realm.Find(beatmap.ID); + var existingBeatmap = d.Realm!.Find(beatmap.ID); if (existingBeatmap != null) { @@ -77,7 +77,7 @@ namespace osu.Game.Database { ID = beatmap.ID, BeatmapSet = d, - Ruleset = d.Realm.Find(beatmap.Ruleset.ShortName) + Ruleset = d.Realm.Find(beatmap.Ruleset.ShortName)! }; d.Beatmaps.Add(newBeatmap); @@ -282,12 +282,10 @@ namespace osu.Game.Database /// /// A subscription token. It must be kept alive for as long as you want to receive change notifications. /// To stop receiving notifications, call . - /// - /// May be null in the case the provided collection is not managed. /// /// /// - public static IDisposable? QueryAsyncWithNotifications(this IRealmCollection collection, NotificationCallbackDelegate callback) + public static IDisposable QueryAsyncWithNotifications(this IRealmCollection collection, NotificationCallbackDelegate callback) where T : RealmObjectBase { if (!RealmAccess.CurrentThreadSubscriptionsAllowed) diff --git a/osu.Game/Database/StandardisedScoreMigrationTools.cs b/osu.Game/Database/StandardisedScoreMigrationTools.cs index 582a656efa..60530c31cb 100644 --- a/osu.Game/Database/StandardisedScoreMigrationTools.cs +++ b/osu.Game/Database/StandardisedScoreMigrationTools.cs @@ -3,8 +3,11 @@ using System; using System.Collections.Generic; +using System.Diagnostics; using System.Linq; using osu.Game.Beatmaps; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Scoring; @@ -185,6 +188,100 @@ namespace osu.Game.Database return (long)Math.Round((1000000 * (accuracyPortion * accuracyScore + (1 - accuracyPortion) * comboScore) + bonusScore) * modMultiplier); } + /// + /// Converts from to the new standardised scoring of . + /// + /// The score to convert the total score of. + /// A used for lookups. + /// The standardised total score. + public static long ConvertFromLegacyTotalScore(ScoreInfo score, BeatmapManager beatmaps) + { + if (!score.IsLegacyScore) + return score.TotalScore; + + WorkingBeatmap beatmap = beatmaps.GetWorkingBeatmap(score.BeatmapInfo); + Ruleset ruleset = score.Ruleset.CreateInstance(); + + if (ruleset is not ILegacyRuleset legacyRuleset) + return score.TotalScore; + + var playableBeatmap = beatmap.GetPlayableBeatmap(ruleset.RulesetInfo, score.Mods); + + if (playableBeatmap.HitObjects.Count == 0) + throw new InvalidOperationException("Beatmap contains no hit objects!"); + + ILegacyScoreSimulator sv1Simulator = legacyRuleset.CreateLegacyScoreSimulator(); + + sv1Simulator.Simulate(beatmap, playableBeatmap, score.Mods); + + return ConvertFromLegacyTotalScore(score, new DifficultyAttributes + { + LegacyAccuracyScore = sv1Simulator.AccuracyScore, + LegacyComboScore = sv1Simulator.ComboScore, + LegacyBonusScoreRatio = sv1Simulator.BonusScoreRatio + }); + } + + /// + /// Converts from to the new standardised scoring of . + /// + /// The score to convert the total score of. + /// Difficulty attributes providing the legacy scoring values + /// (, , and ) + /// for the beatmap which the score was set on. + /// The standardised total score. + public static long ConvertFromLegacyTotalScore(ScoreInfo score, DifficultyAttributes attributes) + { + if (!score.IsLegacyScore) + return score.TotalScore; + + Debug.Assert(score.LegacyTotalScore != null); + + int maximumLegacyAccuracyScore = attributes.LegacyAccuracyScore; + int maximumLegacyComboScore = attributes.LegacyComboScore; + double maximumLegacyBonusRatio = attributes.LegacyBonusScoreRatio; + double modMultiplier = score.Mods.Select(m => m.ScoreMultiplier).Aggregate(1.0, (c, n) => c * n); + + // The part of total score that doesn't include bonus. + int maximumLegacyBaseScore = maximumLegacyAccuracyScore + maximumLegacyComboScore; + + // The combo proportion is calculated as a proportion of maximumLegacyBaseScore. + double comboProportion = Math.Min(1, (double)score.LegacyTotalScore / maximumLegacyBaseScore); + + // The bonus proportion makes up the rest of the score that exceeds maximumLegacyBaseScore. + double bonusProportion = Math.Max(0, ((long)score.LegacyTotalScore - maximumLegacyBaseScore) * maximumLegacyBonusRatio); + + switch (score.Ruleset.OnlineID) + { + case 0: + return (long)Math.Round(( + 700000 * comboProportion + + 300000 * Math.Pow(score.Accuracy, 10) + + bonusProportion) * modMultiplier); + + case 1: + return (long)Math.Round(( + 250000 * comboProportion + + 750000 * Math.Pow(score.Accuracy, 3.6) + + bonusProportion) * modMultiplier); + + case 2: + return (long)Math.Round(( + 600000 * comboProportion + + 400000 * score.Accuracy + + bonusProportion) * modMultiplier); + + case 3: + return (long)Math.Round(( + 990000 * comboProportion + + 10000 * Math.Pow(score.Accuracy, 2 + 2 * score.Accuracy) + + bonusProportion) * modMultiplier); + + default: + return score.TotalScore; + } + } + private class FakeHit : HitObject { private readonly Judgement judgement; diff --git a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs index fab0be6cf0..be025e3aa2 100644 --- a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs +++ b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs @@ -51,7 +51,7 @@ namespace osu.Game.Input.Bindings protected override void LoadComplete() { - realmSubscription = realm.RegisterForNotifications(queryRealmKeyBindings, (sender, _, _) => + realmSubscription = realm.RegisterForNotifications(queryRealmKeyBindings, (sender, _) => { // The first fire of this is a bit redundant as this is being called in base.LoadComplete, // but this is safest in case the subscription is restored after a context recycle. diff --git a/osu.Game/Input/Bindings/GlobalActionContainer.cs b/osu.Game/Input/Bindings/GlobalActionContainer.cs index 64268c73d0..01c454e3f9 100644 --- a/osu.Game/Input/Bindings/GlobalActionContainer.cs +++ b/osu.Game/Input/Bindings/GlobalActionContainer.cs @@ -129,6 +129,7 @@ namespace osu.Game.Input.Bindings new KeyBinding(InputKey.MouseMiddle, GlobalAction.TogglePauseReplay), new KeyBinding(InputKey.Left, GlobalAction.SeekReplayBackward), new KeyBinding(InputKey.Right, GlobalAction.SeekReplayForward), + new KeyBinding(new[] { InputKey.Control, InputKey.H }, GlobalAction.ToggleReplaySettings), }; public IEnumerable SongSelectKeyBindings => new[] @@ -374,5 +375,8 @@ namespace osu.Game.Input.Bindings [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ExportReplay))] ExportReplay, + + [LocalisableDescription(typeof(GlobalActionKeyBindingStrings), nameof(GlobalActionKeyBindingStrings.ToggleReplaySettings))] + ToggleReplaySettings, } } diff --git a/osu.Game/Localisation/GameplaySettingsStrings.cs b/osu.Game/Localisation/GameplaySettingsStrings.cs index 40f39d927d..f52f6abb89 100644 --- a/osu.Game/Localisation/GameplaySettingsStrings.cs +++ b/osu.Game/Localisation/GameplaySettingsStrings.cs @@ -65,10 +65,15 @@ namespace osu.Game.Localisation public static LocalisableString HUDVisibilityMode => new TranslatableString(getKey(@"hud_visibility_mode"), @"HUD overlay visibility mode"); /// - /// "Show health display even when you can't fail" + /// "Show health display even when you can't fail" /// public static LocalisableString ShowHealthDisplayWhenCantFail => new TranslatableString(getKey(@"show_health_display_when_cant_fail"), @"Show health display even when you can't fail"); + /// + /// "Show replay settings overlay" + /// + public static LocalisableString ShowReplaySettingsOverlay => new TranslatableString(getKey(@"show_replay_settings_overlay"), @"Show replay settings overlay"); + /// /// "Fade playfield to red when health is low" /// @@ -134,6 +139,6 @@ namespace osu.Game.Localisation /// public static LocalisableString ClassicScoreDisplay => new TranslatableString(getKey(@"classic_score_display"), @"Classic"); - private static string getKey(string key) => $"{prefix}:{key}"; + private static string getKey(string key) => $@"{prefix}:{key}"; } } diff --git a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs index 9e53b23180..f93d86225c 100644 --- a/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs +++ b/osu.Game/Localisation/GlobalActionKeyBindingStrings.cs @@ -324,6 +324,11 @@ namespace osu.Game.Localisation /// public static LocalisableString ToggleChatFocus => new TranslatableString(getKey(@"toggle_chat_focus"), @"Toggle chat focus"); + /// + /// "Toggle replay settings" + /// + public static LocalisableString ToggleReplaySettings => new TranslatableString(getKey(@"toggle_replay_settings"), @"Toggle replay settings"); + /// /// "Save replay" /// diff --git a/osu.Game/Localisation/LoginPanelStrings.cs b/osu.Game/Localisation/LoginPanelStrings.cs index 19b0ca3b52..925c2b9146 100644 --- a/osu.Game/Localisation/LoginPanelStrings.cs +++ b/osu.Game/Localisation/LoginPanelStrings.cs @@ -24,6 +24,11 @@ namespace osu.Game.Localisation /// public static LocalisableString SignedIn => new TranslatableString(getKey(@"signed_in"), @"Signed in"); + /// + /// "Sign out" + /// + public static LocalisableString SignOut => new TranslatableString(getKey(@"sign_out"), @"Sign out"); + /// /// "Account" /// diff --git a/osu.Game/Online/BeatmapDownloadTracker.cs b/osu.Game/Online/BeatmapDownloadTracker.cs index 144c4445a3..3db602c353 100644 --- a/osu.Game/Online/BeatmapDownloadTracker.cs +++ b/osu.Game/Online/BeatmapDownloadTracker.cs @@ -40,7 +40,7 @@ namespace osu.Game.Online // Used to interact with manager classes that don't support interface types. Will eventually be replaced. var beatmapSetInfo = new BeatmapSetInfo { OnlineID = TrackedItem.OnlineID }; - realmSubscription = realm.RegisterForNotifications(r => r.All().Where(s => s.OnlineID == TrackedItem.OnlineID && !s.DeletePending), (items, _, _) => + realmSubscription = realm.RegisterForNotifications(r => r.All().Where(s => s.OnlineID == TrackedItem.OnlineID && !s.DeletePending), (items, _) => { if (items.Any()) Schedule(() => UpdateState(DownloadState.LocallyAvailable)); diff --git a/osu.Game/Online/Chat/MessageNotifier.cs b/osu.Game/Online/Chat/MessageNotifier.cs index ae249d1b7f..65aac723da 100644 --- a/osu.Game/Online/Chat/MessageNotifier.cs +++ b/osu.Game/Online/Chat/MessageNotifier.cs @@ -171,6 +171,8 @@ namespace osu.Game.Online.Chat public abstract partial class HighlightMessageNotification : SimpleNotification { + public override string PopInSampleName => "UI/notification-mention"; + protected HighlightMessageNotification(Message message, Channel channel) { this.message = message; diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs index d70a2797c4..f769b4c805 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoomUser.cs @@ -29,7 +29,7 @@ namespace osu.Game.Online.Multiplayer /// The availability state of the current beatmap. /// [Key(2)] - public BeatmapAvailability BeatmapAvailability { get; set; } = BeatmapAvailability.LocallyAvailable(); + public BeatmapAvailability BeatmapAvailability { get; set; } = BeatmapAvailability.Unknown(); /// /// Any mods applicable only to the local user. diff --git a/osu.Game/Online/Rooms/BeatmapAvailability.cs b/osu.Game/Online/Rooms/BeatmapAvailability.cs index f2b981c075..a907ee0d3b 100644 --- a/osu.Game/Online/Rooms/BeatmapAvailability.cs +++ b/osu.Game/Online/Rooms/BeatmapAvailability.cs @@ -34,6 +34,7 @@ namespace osu.Game.Online.Rooms DownloadProgress = downloadProgress; } + public static BeatmapAvailability Unknown() => new BeatmapAvailability(DownloadState.Unknown); public static BeatmapAvailability NotDownloaded() => new BeatmapAvailability(DownloadState.NotDownloaded); public static BeatmapAvailability Downloading(float progress) => new BeatmapAvailability(DownloadState.Downloading, progress); public static BeatmapAvailability Importing() => new BeatmapAvailability(DownloadState.Importing); diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs index cce633d46a..ceb8e53778 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs @@ -60,6 +60,15 @@ namespace osu.Game.Online.Rooms if (item.NewValue == null) return; + // Initially set to unknown until we have attained a good state. + // This has the wanted side effect of forcing a state change when the current playlist + // item changes at the server but our local availability doesn't necessarily change + // (ie. we have both the previous and next item LocallyAvailable). + // + // Note that even without this, the server will trigger a state change and things will work. + // This is just for safety. + availability.Value = BeatmapAvailability.Unknown(); + downloadTracker?.RemoveAndDisposeImmediately(); selectedBeatmap = null; @@ -98,7 +107,7 @@ namespace osu.Game.Online.Rooms // handles changes to hash that didn't occur from the import process (ie. a user editing the beatmap in the editor, somehow). realmSubscription?.Dispose(); - realmSubscription = realm.RegisterForNotifications(_ => filteredBeatmaps(), (_, changes, _) => + realmSubscription = realm.RegisterForNotifications(_ => filteredBeatmaps(), (_, changes) => { if (changes == null) return; @@ -115,6 +124,9 @@ namespace osu.Game.Online.Rooms switch (downloadTracker.State.Value) { case DownloadState.Unknown: + availability.Value = BeatmapAvailability.Unknown(); + break; + case DownloadState.NotDownloaded: availability.Value = BeatmapAvailability.NotDownloaded(); break; diff --git a/osu.Game/Online/ScoreDownloadTracker.cs b/osu.Game/Online/ScoreDownloadTracker.cs index 4ddcb40368..de42292372 100644 --- a/osu.Game/Online/ScoreDownloadTracker.cs +++ b/osu.Game/Online/ScoreDownloadTracker.cs @@ -48,7 +48,7 @@ namespace osu.Game.Online realmSubscription = realm.RegisterForNotifications(r => r.All().Where(s => ((s.OnlineID > 0 && s.OnlineID == TrackedItem.OnlineID) || (!string.IsNullOrEmpty(s.Hash) && s.Hash == TrackedItem.Hash)) - && !s.DeletePending), (items, _, _) => + && !s.DeletePending), (items, _) => { if (items.Any()) Schedule(() => UpdateState(DownloadState.LocallyAvailable)); diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index 89da8b9d32..14e137caf1 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -185,7 +185,7 @@ namespace osu.Game.Online.Spectator IsPlaying = true; // transfer state at point of beginning play - currentState.BeatmapID = score.ScoreInfo.BeatmapInfo.OnlineID; + currentState.BeatmapID = score.ScoreInfo.BeatmapInfo!.OnlineID; currentState.RulesetID = score.ScoreInfo.RulesetID; currentState.Mods = score.ScoreInfo.Mods.Select(m => new APIMod(m)).ToArray(); currentState.State = SpectatedUserState.Playing; diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs index 425f40258e..615a3e39af 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoreTable.cs @@ -163,7 +163,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores }, username, #pragma warning disable 618 - new StatisticText(score.MaxCombo, score.BeatmapInfo.MaxCombo, @"0\x"), + new StatisticText(score.MaxCombo, score.BeatmapInfo!.MaxCombo, @"0\x"), #pragma warning restore 618 }; diff --git a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs index e030b1e34f..c92b79cb4d 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/TopScoreStatisticsSection.cs @@ -123,7 +123,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores accuracyColumn.Text = value.DisplayAccuracy; maxComboColumn.Text = value.MaxCombo.ToLocalisableString(@"0\x"); - ppColumn.Alpha = value.BeatmapInfo.Status.GrantsPerformancePoints() ? 1 : 0; + ppColumn.Alpha = value.BeatmapInfo!.Status.GrantsPerformancePoints() ? 1 : 0; if (value.PP is double pp) ppColumn.Text = pp.ToLocalisableString(@"N0"); diff --git a/osu.Game/Overlays/Dialog/PopupDialog.cs b/osu.Game/Overlays/Dialog/PopupDialog.cs index 9969677826..e1e5604e4c 100644 --- a/osu.Game/Overlays/Dialog/PopupDialog.cs +++ b/osu.Game/Overlays/Dialog/PopupDialog.cs @@ -225,7 +225,12 @@ namespace osu.Game.Overlays.Dialog /// /// Programmatically clicks the first button of the provided type. /// - public void PerformAction() where T : PopupDialogButton => Buttons.OfType().First().TriggerClick(); + public void PerformAction() where T : PopupDialogButton + { + // Buttons are regularly added in BDL or LoadComplete, so let's schedule to ensure + // they are ready to be pressed. + Schedule(() => Buttons.OfType().First().TriggerClick()); + } protected override bool OnKeyDown(KeyDownEvent e) { diff --git a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs index 75bc8fd3a8..385695f669 100644 --- a/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs +++ b/osu.Game/Overlays/FirstRunSetup/ScreenBeatmaps.cs @@ -123,7 +123,7 @@ namespace osu.Game.Overlays.FirstRunSetup beatmapSubscription?.Dispose(); } - private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes, Exception error) => Schedule(() => + private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes) => Schedule(() => { currentlyLoadedBeatmaps.Text = FirstRunSetupBeatmapScreenStrings.CurrentlyLoadedBeatmaps(sender.Count); diff --git a/osu.Game/Overlays/INotificationOverlay.cs b/osu.Game/Overlays/INotificationOverlay.cs index 6a1b66bbd2..c5ff10c619 100644 --- a/osu.Game/Overlays/INotificationOverlay.cs +++ b/osu.Game/Overlays/INotificationOverlay.cs @@ -44,6 +44,6 @@ namespace osu.Game.Overlays /// /// All ongoing operations (ie. any not in a completed state). /// - public IEnumerable OngoingOperations => AllNotifications.OfType().Where(p => p.State != ProgressNotificationState.Completed); + public IEnumerable OngoingOperations => AllNotifications.OfType().Where(p => p.State != ProgressNotificationState.Completed && p.State != ProgressNotificationState.Cancelled); } } diff --git a/osu.Game/Overlays/Login/UserAction.cs b/osu.Game/Overlays/Login/UserAction.cs index d4d639f2fb..813968a053 100644 --- a/osu.Game/Overlays/Login/UserAction.cs +++ b/osu.Game/Overlays/Login/UserAction.cs @@ -18,7 +18,7 @@ namespace osu.Game.Overlays.Login [LocalisableDescription(typeof(LoginPanelStrings), nameof(LoginPanelStrings.AppearOffline))] AppearOffline, - [LocalisableDescription(typeof(UserVerificationStrings), nameof(UserVerificationStrings.BoxInfoLogoutLink))] + [LocalisableDescription(typeof(LoginPanelStrings), nameof(LoginPanelStrings.SignOut))] SignOut, } } diff --git a/osu.Game/Overlays/Mods/AddPresetPopover.cs b/osu.Game/Overlays/Mods/AddPresetPopover.cs index d9e350e560..ef855f6166 100644 --- a/osu.Game/Overlays/Mods/AddPresetPopover.cs +++ b/osu.Game/Overlays/Mods/AddPresetPopover.cs @@ -102,7 +102,7 @@ namespace osu.Game.Overlays.Mods Name = nameTextBox.Current.Value, Description = descriptionTextBox.Current.Value, Mods = selectedMods.Value.ToArray(), - Ruleset = r.Find(ruleset.Value.ShortName) + Ruleset = r.Find(ruleset.Value.ShortName)! })); this.HidePopover(); diff --git a/osu.Game/Overlays/Mods/ModPresetColumn.cs b/osu.Game/Overlays/Mods/ModPresetColumn.cs index bf5e576277..3b12eec195 100644 --- a/osu.Game/Overlays/Mods/ModPresetColumn.cs +++ b/osu.Game/Overlays/Mods/ModPresetColumn.cs @@ -61,7 +61,7 @@ namespace osu.Game.Overlays.Mods private Task? latestLoadTask; internal bool ItemsLoaded => latestLoadTask?.IsCompleted == true; - private void asyncLoadPanels(IRealmCollection presets, ChangeSet changes, Exception error) + private void asyncLoadPanels(IRealmCollection presets, ChangeSet? changes) { cancellationTokenSource?.Cancel(); diff --git a/osu.Game/Overlays/Music/PlaylistOverlay.cs b/osu.Game/Overlays/Music/PlaylistOverlay.cs index 43b9024303..7784643163 100644 --- a/osu.Game/Overlays/Music/PlaylistOverlay.cs +++ b/osu.Game/Overlays/Music/PlaylistOverlay.cs @@ -109,7 +109,7 @@ namespace osu.Game.Overlays.Music beatmap.BindValueChanged(working => list.SelectedSet.Value = working.NewValue.BeatmapSetInfo.ToLive(realm), true); } - private void beatmapsChanged(IRealmCollection sender, ChangeSet changes, Exception error) + private void beatmapsChanged(IRealmCollection sender, ChangeSet changes) { if (changes == null) { diff --git a/osu.Game/Overlays/NotificationOverlay.cs b/osu.Game/Overlays/NotificationOverlay.cs index 21027b0931..d21ad625c1 100644 --- a/osu.Game/Overlays/NotificationOverlay.cs +++ b/osu.Game/Overlays/NotificationOverlay.cs @@ -169,7 +169,7 @@ namespace osu.Game.Overlays Logger.Log($"⚠️ {notification.Text}"); - notification.Closed += notificationClosed; + notification.Closed += () => notificationClosed(notification); if (notification is IHasCompletionTarget hasCompletionTarget) hasCompletionTarget.CompletionTarget = Post; @@ -229,17 +229,20 @@ namespace osu.Game.Overlays mainContent.FadeEdgeEffectTo(0, WaveContainer.DISAPPEAR_DURATION, Easing.In); } - private void notificationClosed() => Schedule(() => + private void notificationClosed(Notification notification) => Schedule(() => { updateCounts(); // this debounce is currently shared between popin/popout sounds, which means one could potentially not play when the user is expecting it. // popout is constant across all notification types, and should therefore be handled using playback concurrency instead, but seems broken at the moment. - playDebouncedSample("UI/overlay-pop-out"); + playDebouncedSample(notification.PopOutSampleName); }); private void playDebouncedSample(string sampleName) { + if (string.IsNullOrEmpty(sampleName)) + return; + if (lastSamplePlayback == null || Time.Current - lastSamplePlayback > OsuGameBase.SAMPLE_DEBOUNCE_TIME) { audio.Samples.Get(sampleName)?.Play(); diff --git a/osu.Game/Overlays/Notifications/Notification.cs b/osu.Game/Overlays/Notifications/Notification.cs index 77d3317b1f..8cdc373417 100644 --- a/osu.Game/Overlays/Notifications/Notification.cs +++ b/osu.Game/Overlays/Notifications/Notification.cs @@ -50,7 +50,8 @@ namespace osu.Game.Overlays.Notifications /// public virtual bool DisplayOnTop => true; - public virtual string PopInSampleName => "UI/notification-pop-in"; + public virtual string PopInSampleName => "UI/notification-default"; + public virtual string PopOutSampleName => "UI/overlay-pop-out"; protected NotificationLight Light; diff --git a/osu.Game/Overlays/Notifications/ProgressCompletionNotification.cs b/osu.Game/Overlays/Notifications/ProgressCompletionNotification.cs index 46972d4b5e..93286d9d36 100644 --- a/osu.Game/Overlays/Notifications/ProgressCompletionNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressCompletionNotification.cs @@ -10,6 +10,8 @@ namespace osu.Game.Overlays.Notifications { public partial class ProgressCompletionNotification : SimpleNotification { + public override string PopInSampleName => "UI/notification-done"; + public ProgressCompletionNotification() { Icon = FontAwesome.Solid.Check; diff --git a/osu.Game/Overlays/Notifications/ProgressNotification.cs b/osu.Game/Overlays/Notifications/ProgressNotification.cs index e6662e2179..53ac490297 100644 --- a/osu.Game/Overlays/Notifications/ProgressNotification.cs +++ b/osu.Game/Overlays/Notifications/ProgressNotification.cs @@ -4,6 +4,8 @@ using System; using System.Threading; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Audio.Sample; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -27,6 +29,8 @@ namespace osu.Game.Overlays.Notifications protected override bool AllowFlingDismiss => false; + public override string PopOutSampleName => State is ProgressNotificationState.Cancelled ? base.PopOutSampleName : ""; + /// /// The function to post completion notifications back to. /// @@ -122,6 +126,7 @@ namespace osu.Game.Overlays.Notifications cancellationTokenSource.Cancel(); IconContent.FadeColour(ColourInfo.GradientVertical(Color4.Gray, Color4.Gray.Lighten(0.5f)), colour_fade_duration); + cancelSample?.Play(); loadingSpinner.Hide(); var icon = new SpriteIcon @@ -190,6 +195,8 @@ namespace osu.Game.Overlays.Notifications private LoadingSpinner loadingSpinner = null!; + private Sample? cancelSample; + private readonly TextFlowContainer textDrawable; public ProgressNotification() @@ -217,7 +224,7 @@ namespace osu.Game.Overlays.Notifications } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load(OsuColour colours, AudioManager audioManager) { colourQueued = colours.YellowDark; colourActive = colours.Blue; @@ -236,6 +243,8 @@ namespace osu.Game.Overlays.Notifications Size = new Vector2(loading_spinner_size), } }); + + cancelSample = audioManager.Samples.Get(@"UI/notification-cancel"); } public override void Close(bool runFlingAnimation) diff --git a/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs b/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs index 758eea93d4..81e3b40ffc 100644 --- a/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs +++ b/osu.Game/Overlays/Notifications/SimpleErrorNotification.cs @@ -7,7 +7,7 @@ namespace osu.Game.Overlays.Notifications { public partial class SimpleErrorNotification : SimpleNotification { - public override string PopInSampleName => "UI/error-notification-pop-in"; + public override string PopInSampleName => "UI/notification-error"; public SimpleErrorNotification() { diff --git a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs index e7c83159cd..3e67b2f103 100644 --- a/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Gameplay/HUDSettings.cs @@ -25,10 +25,9 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay }, new SettingsCheckbox { - ClassicDefault = false, - LabelText = GameplaySettingsStrings.ShowHealthDisplayWhenCantFail, - Current = config.GetBindable(OsuSetting.ShowHealthDisplayWhenCantFail), - Keywords = new[] { "hp", "bar" } + LabelText = GameplaySettingsStrings.ShowReplaySettingsOverlay, + Current = config.GetBindable(OsuSetting.ReplaySettingsOverlay), + Keywords = new[] { "hide" }, }, new SettingsCheckbox { @@ -41,6 +40,13 @@ namespace osu.Game.Overlays.Settings.Sections.Gameplay LabelText = GameplaySettingsStrings.AlwaysShowGameplayLeaderboard, Current = config.GetBindable(OsuSetting.GameplayLeaderboard), }, + new SettingsCheckbox + { + ClassicDefault = false, + LabelText = GameplaySettingsStrings.ShowHealthDisplayWhenCantFail, + Current = config.GetBindable(OsuSetting.ShowHealthDisplayWhenCantFail), + Keywords = new[] { "hp", "bar" } + }, }; } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs index b04e514ec2..725925c8cf 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs @@ -440,7 +440,7 @@ namespace osu.Game.Overlays.Settings.Sections.Input } private void updateStoreFromButton(KeyButton button) => - realm.WriteAsync(r => r.Find(button.KeyBinding.ID).KeyCombinationString = button.KeyBinding.KeyCombinationString); + realm.WriteAsync(r => r.Find(button.KeyBinding.ID)!.KeyCombinationString = button.KeyBinding.KeyCombinationString); private void updateIsDefaultValue() { diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 5382eac675..e997e70157 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -92,7 +92,7 @@ namespace osu.Game.Overlays.Settings.Sections }); } - private void skinsChanged(IRealmCollection sender, ChangeSet changes, Exception error) + private void skinsChanged(IRealmCollection sender, ChangeSet changes) { // This can only mean that realm is recycling, else we would see the protected skins. // Because we are using `Live<>` in this class, we don't need to worry about this scenario too much. diff --git a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs index bd45482235..5a01faa417 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyAttributes.cs @@ -3,10 +3,10 @@ using System; using System.Collections.Generic; -using System.Linq; using Newtonsoft.Json; using osu.Game.Beatmaps; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Scoring; namespace osu.Game.Rulesets.Difficulty { @@ -27,6 +27,9 @@ namespace osu.Game.Rulesets.Difficulty protected const int ATTRIB_ID_FLASHLIGHT = 17; protected const int ATTRIB_ID_SLIDER_FACTOR = 19; protected const int ATTRIB_ID_SPEED_NOTE_COUNT = 21; + protected const int ATTRIB_ID_LEGACY_ACCURACY_SCORE = 23; + protected const int ATTRIB_ID_LEGACY_COMBO_SCORE = 25; + protected const int ATTRIB_ID_LEGACY_BONUS_SCORE_RATIO = 27; /// /// The mods which were applied to the beatmap. @@ -45,6 +48,22 @@ namespace osu.Game.Rulesets.Difficulty [JsonProperty("max_combo", Order = -2)] public int MaxCombo { get; set; } + /// + /// The accuracy portion of the legacy (ScoreV1) total score. + /// + public int LegacyAccuracyScore { get; set; } + + /// + /// The combo-multiplied portion of the legacy (ScoreV1) total score. + /// + public int LegacyComboScore { get; set; } + + /// + /// A ratio of new_bonus_score / old_bonus_score for converting the bonus score of legacy scores to the new scoring. + /// This is made up of all judgements that would be or . + /// + public double LegacyBonusScoreRatio { get; set; } + /// /// Creates new . /// @@ -69,7 +88,13 @@ namespace osu.Game.Rulesets.Difficulty /// /// See: osu_difficulty_attribs table. /// - public virtual IEnumerable<(int attributeId, object value)> ToDatabaseAttributes() => Enumerable.Empty<(int, object)>(); + public virtual IEnumerable<(int attributeId, object value)> ToDatabaseAttributes() + { + yield return (ATTRIB_ID_MAX_COMBO, MaxCombo); + yield return (ATTRIB_ID_LEGACY_ACCURACY_SCORE, LegacyAccuracyScore); + yield return (ATTRIB_ID_LEGACY_COMBO_SCORE, LegacyComboScore); + yield return (ATTRIB_ID_LEGACY_BONUS_SCORE_RATIO, LegacyBonusScoreRatio); + } /// /// Reads osu-web database attribute mappings into this object. @@ -78,6 +103,10 @@ namespace osu.Game.Rulesets.Difficulty /// The where more information about the beatmap may be extracted from (such as AR/CS/OD/etc). public virtual void FromDatabaseAttributes(IReadOnlyDictionary values, IBeatmapOnlineInfo onlineInfo) { + MaxCombo = (int)values[ATTRIB_ID_MAX_COMBO]; + LegacyAccuracyScore = (int)values[ATTRIB_ID_LEGACY_ACCURACY_SCORE]; + LegacyComboScore = (int)values[ATTRIB_ID_LEGACY_COMBO_SCORE]; + LegacyBonusScoreRatio = (int)values[ATTRIB_ID_LEGACY_BONUS_SCORE_RATIO]; } } } diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 00c90bd317..d005bbfc7a 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -23,6 +23,13 @@ namespace osu.Game.Rulesets.Difficulty { public abstract class DifficultyCalculator { + /// + /// Whether legacy scoring values (ScoreV1) should be computed to populate the difficulty attributes + /// , , + /// and . + /// + public bool ComputeLegacyScoringValues; + /// /// The beatmap for which difficulty will be calculated. /// diff --git a/osu.Game/Rulesets/ILegacyRuleset.cs b/osu.Game/Rulesets/ILegacyRuleset.cs index f4b03baccd..24aa672219 100644 --- a/osu.Game/Rulesets/ILegacyRuleset.cs +++ b/osu.Game/Rulesets/ILegacyRuleset.cs @@ -1,6 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Game.Rulesets.Scoring; + namespace osu.Game.Rulesets { public interface ILegacyRuleset @@ -11,5 +13,7 @@ namespace osu.Game.Rulesets /// Identifies the server-side ID of a legacy ruleset. /// int LegacyID { get; } + + ILegacyScoreSimulator CreateLegacyScoreSimulator(); } } diff --git a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs index 07c0d1f8a1..e4d8eb2335 100644 --- a/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs +++ b/osu.Game/Rulesets/Objects/Drawables/DrawableHitObject.cs @@ -98,9 +98,9 @@ namespace osu.Game.Rulesets.Objects.Drawables public virtual bool DisplayResult => true; /// - /// Whether this and all of its nested s have been judged. + /// The scoring result of this . /// - public bool AllJudged => Judged && NestedHitObjects.All(h => h.AllJudged); + public JudgementResult Result => Entry?.Result; /// /// Whether this has been hit. This occurs if is hit. @@ -112,12 +112,12 @@ namespace osu.Game.Rulesets.Objects.Drawables /// Whether this has been judged. /// Note: This does NOT include nested hitobjects. /// - public bool Judged => Result?.HasResult ?? true; + public bool Judged => Entry?.Judged ?? false; /// - /// The scoring result of this . + /// Whether this and all of its nested s have been judged. /// - public JudgementResult Result => Entry?.Result; + public bool AllJudged => Entry?.AllJudged ?? false; /// /// The relative X position of this hit object for sample playback balance adjustment. @@ -218,6 +218,8 @@ namespace osu.Game.Rulesets.Objects.Drawables protected sealed override void OnApply(HitObjectLifetimeEntry entry) { + Debug.Assert(Entry != null); + // LifetimeStart is already computed using HitObjectLifetimeEntry's InitialLifetimeOffset. // We override this with DHO's InitialLifetimeOffset for a non-pooled DHO. if (entry is SyntheticHitObjectEntry) @@ -247,6 +249,12 @@ namespace osu.Game.Rulesets.Objects.Drawables drawableNested.ParentHitObject = this; nestedHitObjects.Add(drawableNested); + + // assume that synthetic entries are not pooled and therefore need to be managed from within the DHO. + // this is important for the correctness of value of flags such as `AllJudged`. + if (drawableNested.Entry is SyntheticHitObjectEntry syntheticNestedEntry) + Entry.NestedEntries.Add(syntheticNestedEntry); + AddNestedHitObject(drawableNested); } @@ -290,6 +298,8 @@ namespace osu.Game.Rulesets.Objects.Drawables protected sealed override void OnFree(HitObjectLifetimeEntry entry) { + Debug.Assert(Entry != null); + StartTimeBindable.UnbindFrom(HitObject.StartTimeBindable); if (HitObject is IHasComboInformation combo) @@ -318,6 +328,8 @@ namespace osu.Game.Rulesets.Objects.Drawables } nestedHitObjects.Clear(); + // clean up synthetic entries manually added in `Apply()`. + Entry.NestedEntries.RemoveAll(nestedEntry => nestedEntry is SyntheticHitObjectEntry); ClearNestedHitObjects(); HitObject.DefaultsApplied -= onDefaultsApplied; diff --git a/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs b/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs index b517f6b9e6..4450f026b4 100644 --- a/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs +++ b/osu.Game/Rulesets/Objects/HitObjectLifetimeEntry.cs @@ -2,6 +2,8 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; +using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics.Performance; using osu.Game.Rulesets.Judgements; @@ -19,12 +21,28 @@ namespace osu.Game.Rulesets.Objects /// public readonly HitObject HitObject; + /// + /// The list of for the 's nested objects (if any). + /// + public List NestedEntries { get; internal set; } = new List(); + /// /// The result that was judged with. /// This is set by the accompanying , and reused when required for rewinding. /// internal JudgementResult? Result; + /// + /// Whether has been judged. + /// Note: This does NOT include nested hitobjects. + /// + public bool Judged => Result?.HasResult ?? false; + + /// + /// Whether and all of its nested objects have been judged. + /// + public bool AllJudged => Judged && NestedEntries.All(h => h.AllJudged); + private readonly IBindable startTimeBindable = new BindableDouble(); internal event Action? RevertResult; diff --git a/osu.Game/Rulesets/Objects/Pooling/HitObjectEntryManager.cs b/osu.Game/Rulesets/Objects/Pooling/HitObjectEntryManager.cs index 6c39ea44da..fabf4fc444 100644 --- a/osu.Game/Rulesets/Objects/Pooling/HitObjectEntryManager.cs +++ b/osu.Game/Rulesets/Objects/Pooling/HitObjectEntryManager.cs @@ -43,11 +43,6 @@ namespace osu.Game.Rulesets.Objects.Pooling /// private readonly Dictionary parentMap = new Dictionary(); - /// - /// Stores the list of child entries for each hit object managed by this . - /// - private readonly Dictionary> childrenMap = new Dictionary>(); - public void Add(HitObjectLifetimeEntry entry, HitObject? parent) { HitObject hitObject = entry.HitObject; @@ -57,22 +52,24 @@ namespace osu.Game.Rulesets.Objects.Pooling // Add the entry. entryMap[hitObject] = entry; - childrenMap[hitObject] = new List(); // If the entry has a parent, set it and add the entry to the parent's children. if (parent != null) { parentMap[entry] = parent; - if (childrenMap.TryGetValue(parent, out var parentChildEntries)) - parentChildEntries.Add(entry); + if (entryMap.TryGetValue(parent, out var parentEntry)) + parentEntry.NestedEntries.Add(entry); } hitObject.DefaultsApplied += onDefaultsApplied; OnEntryAdded?.Invoke(entry, parent); } - public void Remove(HitObjectLifetimeEntry entry) + public bool Remove(HitObjectLifetimeEntry entry) { + if (entry is SyntheticHitObjectEntry) + return false; + HitObject hitObject = entry.HitObject; if (!entryMap.ContainsKey(hitObject)) @@ -81,18 +78,16 @@ namespace osu.Game.Rulesets.Objects.Pooling entryMap.Remove(hitObject); // If the entry has a parent, unset it and remove the entry from the parents' children. - if (parentMap.Remove(entry, out var parent) && childrenMap.TryGetValue(parent, out var parentChildEntries)) - parentChildEntries.Remove(entry); + if (parentMap.Remove(entry, out var parent) && entryMap.TryGetValue(parent, out var parentEntry)) + parentEntry.NestedEntries.Remove(entry); // Remove all the entries' children. - if (childrenMap.Remove(hitObject, out var childEntries)) - { - foreach (var childEntry in childEntries) - Remove(childEntry); - } + foreach (var childEntry in entry.NestedEntries) + Remove(childEntry); hitObject.DefaultsApplied -= onDefaultsApplied; OnEntryRemoved?.Invoke(entry, parent); + return true; } public bool TryGet(HitObject hitObject, [MaybeNullWhen(false)] out HitObjectLifetimeEntry entry) @@ -105,16 +100,16 @@ namespace osu.Game.Rulesets.Objects.Pooling /// private void onDefaultsApplied(HitObject hitObject) { - if (!childrenMap.Remove(hitObject, out var childEntries)) + if (!entryMap.TryGetValue(hitObject, out var entry)) return; - // Remove all the entries' children. At this point the parents' (this entries') children list has been removed from the map, so this does not cause upwards traversal. - foreach (var entry in childEntries) - Remove(entry); + // Replace the entire list rather than clearing to prevent circular traversal later. + var previousEntries = entry.NestedEntries; + entry.NestedEntries = new List(); - // The removed children list needs to be added back to the map for the entry to potentially receive children. - childEntries.Clear(); - childrenMap[hitObject] = childEntries; + // Remove all the entries' children. At this point the parents' (this entries') children list has been reconstructed, so this does not cause upwards traversal. + foreach (var nested in previousEntries) + Remove(nested); } } } diff --git a/osu.Game/Rulesets/Scoring/ILegacyScoreSimulator.cs b/osu.Game/Rulesets/Scoring/ILegacyScoreSimulator.cs new file mode 100644 index 0000000000..7240f0d73e --- /dev/null +++ b/osu.Game/Rulesets/Scoring/ILegacyScoreSimulator.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Beatmaps; +using osu.Game.Rulesets.Mods; + +namespace osu.Game.Rulesets.Scoring +{ + /// + /// Generates attributes which are required to calculate old-style Score V1 scores. + /// + public interface ILegacyScoreSimulator + { + /// + /// The accuracy portion of the legacy (ScoreV1) total score. + /// + int AccuracyScore { get; } + + /// + /// The combo-multiplied portion of the legacy (ScoreV1) total score. + /// + int ComboScore { get; } + + /// + /// A ratio of new_bonus_score / old_bonus_score for converting the bonus score of legacy scores to the new scoring. + /// This is made up of all judgements that would be or . + /// + double BonusScoreRatio { get; } + + /// + /// Performs the simulation, computing the maximum , , + /// and achievable for the given beatmap. + /// + /// The working beatmap. + /// A playable version of the beatmap for the ruleset. + /// The applied mods. + void Simulate(IWorkingBeatmap workingBeatmap, IBeatmap playableBeatmap, IReadOnlyList mods); + } +} diff --git a/osu.Game/Scoring/IScoreInfo.cs b/osu.Game/Scoring/IScoreInfo.cs index 3644d099d9..d17558f800 100644 --- a/osu.Game/Scoring/IScoreInfo.cs +++ b/osu.Game/Scoring/IScoreInfo.cs @@ -28,7 +28,7 @@ namespace osu.Game.Scoring double? PP { get; } - IBeatmapInfo Beatmap { get; } + IBeatmapInfo? Beatmap { get; } IRulesetInfo Ruleset { get; } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index f71da6c7e0..6868c89d26 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -28,10 +28,11 @@ namespace osu.Game.Scoring.Legacy /// /// /// 30000001: Appends to the end of scores. - /// 30000002: Score stored to replay calculated using the Score V2 algorithm. + /// 30000002: Score stored to replay calculated using the Score V2 algorithm. Legacy scores on this version are candidate to Score V1 -> V2 conversion. + /// 30000003: First version after converting legacy total score to standardised. /// /// - public const int LATEST_VERSION = 30000002; + public const int LATEST_VERSION = 30000003; /// /// The first stable-compatible YYYYMMDD format version given to lazer usage of replays. @@ -65,7 +66,7 @@ namespace osu.Game.Scoring.Legacy { sw.Write((byte)(score.ScoreInfo.Ruleset.OnlineID)); sw.Write(LATEST_VERSION); - sw.Write(score.ScoreInfo.BeatmapInfo.MD5Hash); + sw.Write(score.ScoreInfo.BeatmapInfo!.MD5Hash); sw.Write(score.ScoreInfo.User.Username); sw.Write(FormattableString.Invariant($"lazer-{score.ScoreInfo.User.Username}-{score.ScoreInfo.Date}").ComputeMD5Hash()); sw.Write((ushort)(score.ScoreInfo.GetCount300() ?? 0)); diff --git a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs index e298d51ccb..980b742585 100644 --- a/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs +++ b/osu.Game/Scoring/Legacy/ScoreInfoExtensions.cs @@ -14,13 +14,7 @@ namespace osu.Game.Scoring.Legacy => getDisplayScore(scoreProcessor.Ruleset.RulesetInfo.OnlineID, scoreProcessor.TotalScore.Value, mode, scoreProcessor.MaximumStatistics); public static long GetDisplayScore(this ScoreInfo scoreInfo, ScoringMode mode) - { - // Temporary to not scale stable scores that are already in the XX-millions with the classic scoring mode. - if (scoreInfo.IsLegacyScore) - return scoreInfo.TotalScore; - - return getDisplayScore(scoreInfo.Ruleset.OnlineID, scoreInfo.TotalScore, mode, scoreInfo.MaximumStatistics); - } + => getDisplayScore(scoreInfo.Ruleset.OnlineID, scoreInfo.TotalScore, mode, scoreInfo.MaximumStatistics); private static long getDisplayScore(int rulesetId, long score, ScoringMode mode, IReadOnlyDictionary maximumStatistics) { diff --git a/osu.Game/Scoring/ScoreImporter.cs b/osu.Game/Scoring/ScoreImporter.cs index 16658a598a..81b9f57bbc 100644 --- a/osu.Game/Scoring/ScoreImporter.cs +++ b/osu.Game/Scoring/ScoreImporter.cs @@ -64,12 +64,14 @@ namespace osu.Game.Scoring 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); + model.BeatmapInfo = realm.Find(model.BeatmapInfo.ID)!; if (!model.Ruleset.IsManaged) - model.Ruleset = realm.Find(model.Ruleset.ShortName); + 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. @@ -88,6 +90,11 @@ namespace osu.Game.Scoring // 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()); + } } /// @@ -96,10 +103,12 @@ namespace osu.Game.Scoring /// 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 beatmap = score.BeatmapInfo!.Detach(); var ruleset = score.Ruleset.Detach(); var rulesetInstance = ruleset.CreateInstance(); diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index d56338c6a4..c6f4433824 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -15,6 +15,7 @@ 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; @@ -34,9 +35,16 @@ namespace osu.Game.Scoring /// The this score was made against. /// /// - /// When setting this, make sure to also set to allow relational consistency when a beatmap is potentially changed. + /// + /// 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; } = null!; + public BeatmapInfo? BeatmapInfo { get; set; } /// /// The at the point in time when the score was set. @@ -53,6 +61,26 @@ namespace osu.Game.Scoring 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; } + public int MaxCombo { get; set; } public double Accuracy { get; set; } @@ -82,6 +110,7 @@ namespace osu.Game.Scoring { Ruleset = ruleset ?? new RulesetInfo(); BeatmapInfo = beatmap ?? new BeatmapInfo(); + BeatmapHash = BeatmapInfo.Hash; RealmUser = realmUser ?? new RealmUser(); ID = Guid.NewGuid(); } @@ -128,14 +157,12 @@ namespace osu.Game.Scoring public int RankInt { get; set; } IRulesetInfo IScoreInfo.Ruleset => Ruleset; - IBeatmapInfo IScoreInfo.Beatmap => BeatmapInfo; + IBeatmapInfo? IScoreInfo.Beatmap => BeatmapInfo; IUser IScoreInfo.User => User; IEnumerable IHasNamedFiles.Files => Files; #region Properties required to make things work with existing usages - public Guid BeatmapInfoID => BeatmapInfo.ID; - public int UserID => RealmUser.OnlineID; public int RulesetID => Ruleset.OnlineID; diff --git a/osu.Game/Scoring/ScoreInfoExtensions.cs b/osu.Game/Scoring/ScoreInfoExtensions.cs index 85598076d6..6e57a9fd0b 100644 --- a/osu.Game/Scoring/ScoreInfoExtensions.cs +++ b/osu.Game/Scoring/ScoreInfoExtensions.cs @@ -13,7 +13,7 @@ namespace osu.Game.Scoring /// /// A user-presentable display title representing this score. /// - public static string GetDisplayTitle(this IScoreInfo scoreInfo) => $"{scoreInfo.User.Username} playing {scoreInfo.Beatmap.GetDisplayTitle()}"; + public static string GetDisplayTitle(this IScoreInfo scoreInfo) => $"{scoreInfo.User.Username} playing {scoreInfo.Beatmap?.GetDisplayTitle() ?? "unknown"}"; /// /// Orders an array of s by total score. diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 55bcb9f79d..31b5bd8365 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -141,7 +141,7 @@ namespace osu.Game.Scoring { Realm.Run(r => { - var beatmapScores = r.Find(beatmap.ID).Scores.ToList(); + var beatmapScores = r.Find(beatmap.ID)!.Scores.ToList(); Delete(beatmapScores, silent); }); } diff --git a/osu.Game/Scoring/ScorePerformanceCache.cs b/osu.Game/Scoring/ScorePerformanceCache.cs index bdbcfe4efe..1f2b1aeb95 100644 --- a/osu.Game/Scoring/ScorePerformanceCache.cs +++ b/osu.Game/Scoring/ScorePerformanceCache.cs @@ -34,7 +34,7 @@ namespace osu.Game.Scoring { var score = lookup.ScoreInfo; - var attributes = await difficultyCache.GetDifficultyAsync(score.BeatmapInfo, score.Ruleset, score.Mods, token).ConfigureAwait(false); + var attributes = await difficultyCache.GetDifficultyAsync(score.BeatmapInfo!, score.Ruleset, score.Mods, token).ConfigureAwait(false); // Performance calculation requires the beatmap and ruleset to be locally available. If not, return a default value. if (attributes?.Attributes == null) diff --git a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs index e93b9f0691..5d9fac739c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs +++ b/osu.Game/Screens/Edit/Compose/Components/SelectionBox.cs @@ -377,10 +377,13 @@ namespace osu.Game.Screens.Edit.Compose.Components float leftExcess = thisQuad.TopLeft.X - parentQuad.TopLeft.X; float rightExcess = parentQuad.TopRight.X - thisQuad.TopRight.X; - if (topExcess + bottomExcess < buttons.Height + button_padding) + float minHeight = buttons.ScreenSpaceDrawQuad.Height; + + if (topExcess < minHeight && bottomExcess < minHeight) { buttons.Anchor = Anchor.BottomCentre; buttons.Origin = Anchor.BottomCentre; + buttons.Y = Math.Min(0, ToLocalSpace(Parent.ScreenSpaceDrawQuad.BottomLeft).Y - DrawHeight); } else if (topExcess > bottomExcess) { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 978d77b4f1..ecf38a956d 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -313,16 +313,26 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer client.ChangeBeatmapAvailability(availability.NewValue).FireAndForget(); - if (availability.NewValue.State != DownloadState.LocallyAvailable) + switch (availability.NewValue.State) { - // while this flow is handled server-side, this covers the edge case of the local user being in a ready state and then deleting the current beatmap. - if (client.LocalUser?.State == MultiplayerUserState.Ready) - client.ChangeState(MultiplayerUserState.Idle); - } - else if (client.LocalUser?.State == MultiplayerUserState.Spectating - && (client.Room?.State == MultiplayerRoomState.WaitingForLoad || client.Room?.State == MultiplayerRoomState.Playing)) - { - onLoadRequested(); + case DownloadState.LocallyAvailable: + if (client.LocalUser?.State == MultiplayerUserState.Spectating + && (client.Room?.State == MultiplayerRoomState.WaitingForLoad || client.Room?.State == MultiplayerRoomState.Playing)) + { + onLoadRequested(); + } + + break; + + case DownloadState.Unknown: + // Don't do anything rash in an unknown state. + break; + + default: + // while this flow is handled server-side, this covers the edge case of the local user being in a ready state and then deleting the current beatmap. + if (client.LocalUser?.State == MultiplayerUserState.Ready) + client.ChangeState(MultiplayerUserState.Idle); + break; } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs index bfdc0c02ac..b0cc13d645 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/StateDisplay.cs @@ -154,6 +154,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants this.FadeOut(fade_time); break; + case DownloadState.Unknown: + text.Text = "checking availability"; + icon.Icon = FontAwesome.Solid.Question; + icon.Colour = colours.Orange0; + break; + case DownloadState.NotDownloaded: text.Text = "no map"; icon.Icon = FontAwesome.Solid.MinusCircle; diff --git a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs index b6b385e262..dbb0456cd0 100644 --- a/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs +++ b/osu.Game/Screens/Play/HUD/PlayerSettingsOverlay.cs @@ -3,10 +3,8 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Input.Events; using osuTK; using osu.Game.Screens.Play.PlayerSettings; -using osuTK.Input; namespace osu.Game.Screens.Play.HUD { @@ -14,16 +12,12 @@ namespace osu.Game.Screens.Play.HUD { private const int fade_duration = 200; - public bool ReplayLoaded; - public readonly PlaybackSettings PlaybackSettings; public readonly VisualSettings VisualSettings; public PlayerSettingsOverlay() { - AlwaysPresent = true; - Anchor = Anchor.TopRight; Origin = Anchor.TopRight; AutoSizeAxes = Axes.Both; @@ -37,8 +31,6 @@ namespace osu.Game.Screens.Play.HUD Spacing = new Vector2(0, 20), Children = new PlayerSettingsGroup[] { - //CollectionSettings = new CollectionSettings(), - //DiscussionSettings = new DiscussionSettings(), PlaybackSettings = new PlaybackSettings { Expanded = { Value = false } }, VisualSettings = new VisualSettings { Expanded = { Value = false } }, new AudioSettings { Expanded = { Value = false } } @@ -48,24 +40,5 @@ namespace osu.Game.Screens.Play.HUD protected override void PopIn() => this.FadeIn(fade_duration); protected override void PopOut() => this.FadeOut(fade_duration); - - // We want to handle keyboard inputs all the time in order to trigger ToggleVisibility() when not visible - public override bool PropagateNonPositionalInputSubTree => true; - - protected override bool OnKeyDown(KeyDownEvent e) - { - if (e.Repeat) return false; - - if (e.ControlPressed) - { - if (e.Key == Key.H && ReplayLoaded) - { - ToggleVisibility(); - return true; - } - } - - return base.OnKeyDown(e); - } } } diff --git a/osu.Game/Screens/Play/HUDOverlay.cs b/osu.Game/Screens/Play/HUDOverlay.cs index f0a2975958..d11171e3fe 100644 --- a/osu.Game/Screens/Play/HUDOverlay.cs +++ b/osu.Game/Screens/Play/HUDOverlay.cs @@ -78,6 +78,7 @@ namespace osu.Game.Screens.Play public Bindable ShowHud { get; } = new BindableBool(); private Bindable configVisibilityMode; + private Bindable configSettingsOverlay; private readonly BindableBool replayLoaded = new BindableBool(); @@ -178,6 +179,7 @@ namespace osu.Game.Screens.Play ModDisplay.Current.Value = mods; configVisibilityMode = config.GetBindable(OsuSetting.HUDVisibilityMode); + configSettingsOverlay = config.GetBindable(OsuSetting.ReplaySettingsOverlay); if (configVisibilityMode.Value == HUDVisibilityMode.Never && !hasShownNotificationOnce) { @@ -204,9 +206,24 @@ namespace osu.Game.Screens.Play holdingForHUD.BindValueChanged(_ => updateVisibility()); IsPlaying.BindValueChanged(_ => updateVisibility()); - configVisibilityMode.BindValueChanged(_ => updateVisibility(), true); + configVisibilityMode.BindValueChanged(_ => updateVisibility()); + configSettingsOverlay.BindValueChanged(_ => updateVisibility()); - replayLoaded.BindValueChanged(replayLoadedValueChanged, true); + replayLoaded.BindValueChanged(e => + { + if (e.NewValue) + { + ModDisplay.FadeIn(200); + InputCountController.Margin = new MarginPadding(10) { Bottom = 30 }; + } + else + { + ModDisplay.Delay(2000).FadeOut(200); + InputCountController.Margin = new MarginPadding(10); + } + + updateVisibility(); + }, true); } protected override void Update() @@ -280,6 +297,11 @@ namespace osu.Game.Screens.Play return; } + if (configSettingsOverlay.Value && replayLoaded.Value) + PlayerSettingsOverlay.Show(); + else + PlayerSettingsOverlay.Hide(); + switch (configVisibilityMode.Value) { case HUDVisibilityMode.Never: @@ -297,26 +319,6 @@ namespace osu.Game.Screens.Play } } - private void replayLoadedValueChanged(ValueChangedEvent e) - { - PlayerSettingsOverlay.ReplayLoaded = e.NewValue; - - if (e.NewValue) - { - PlayerSettingsOverlay.Show(); - ModDisplay.FadeIn(200); - InputCountController.Margin = new MarginPadding(10) { Bottom = 30 }; - } - else - { - PlayerSettingsOverlay.Hide(); - ModDisplay.Delay(2000).FadeOut(200); - InputCountController.Margin = new MarginPadding(10); - } - - updateVisibility(); - } - protected virtual void BindDrawableRuleset(DrawableRuleset drawableRuleset) { if (drawableRuleset is ICanAttachHUDPieces attachTarget) @@ -354,6 +356,10 @@ namespace osu.Game.Screens.Play switch (e.Action) { + case GlobalAction.ToggleReplaySettings: + configSettingsOverlay.Value = !configSettingsOverlay.Value; + return true; + case GlobalAction.HoldForHUD: holdingForHUD.Value = true; return true; diff --git a/osu.Game/Screens/Play/SoloPlayer.cs b/osu.Game/Screens/Play/SoloPlayer.cs index dafdf00136..f7ae3eb62b 100644 --- a/osu.Game/Screens/Play/SoloPlayer.cs +++ b/osu.Game/Screens/Play/SoloPlayer.cs @@ -68,7 +68,7 @@ namespace osu.Game.Screens.Play { IBeatmapInfo beatmap = score.ScoreInfo.BeatmapInfo; - Debug.Assert(beatmap.OnlineID > 0); + Debug.Assert(beatmap!.OnlineID > 0); return new SubmitSoloScoreRequest(score.ScoreInfo, token, beatmap.OnlineID); } diff --git a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs index 82c429798e..d1dc1a81db 100644 --- a/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs +++ b/osu.Game/Screens/Ranking/Expanded/ExpandedPanelMiddleContent.cs @@ -66,7 +66,7 @@ namespace osu.Game.Screens.Ranking.Expanded [BackgroundDependencyLoader] private void load(BeatmapDifficultyCache beatmapDifficultyCache) { - var beatmap = score.BeatmapInfo; + var beatmap = score.BeatmapInfo!; var metadata = beatmap.BeatmapSet?.Metadata ?? beatmap.Metadata; string creator = metadata.Author.Username; diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs index c8920a734d..f187b8a302 100644 --- a/osu.Game/Screens/Ranking/SoloResultsScreen.cs +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -63,7 +63,7 @@ namespace osu.Game.Screens.Ranking protected override APIRequest? FetchScores(Action>? scoresCallback) { - if (Score.BeatmapInfo.OnlineID <= 0 || Score.BeatmapInfo.Status <= BeatmapOnlineStatus.Pending) + if (Score.BeatmapInfo!.OnlineID <= 0 || Score.BeatmapInfo.Status <= BeatmapOnlineStatus.Pending) return null; getScoreRequest = new GetScoresRequest(Score.BeatmapInfo, Score.Ruleset); diff --git a/osu.Game/Screens/Select/BeatmapCarousel.cs b/osu.Game/Screens/Select/BeatmapCarousel.cs index 3d87a57295..9af9a0ce72 100644 --- a/osu.Game/Screens/Select/BeatmapCarousel.cs +++ b/osu.Game/Screens/Select/BeatmapCarousel.cs @@ -223,7 +223,7 @@ namespace osu.Game.Screens.Select subscriptionHiddenBeatmaps = realm.RegisterForNotifications(r => r.All().Where(b => b.Hidden), beatmapsChanged); } - private void deletedBeatmapSetsChanged(IRealmCollection sender, ChangeSet? changes, Exception? error) + private void deletedBeatmapSetsChanged(IRealmCollection sender, ChangeSet? changes) { // If loading test beatmaps, avoid overwriting with realm subscription callbacks. if (loadedTestBeatmaps) @@ -236,7 +236,7 @@ namespace osu.Game.Screens.Select removeBeatmapSet(sender[i].ID); } - private void beatmapSetsChanged(IRealmCollection sender, ChangeSet? changes, Exception? error) + private void beatmapSetsChanged(IRealmCollection sender, ChangeSet? changes) { // If loading test beatmaps, avoid overwriting with realm subscription callbacks. if (loadedTestBeatmaps) @@ -255,7 +255,7 @@ namespace osu.Game.Screens.Select foreach (var id in realmSets) { if (!root.BeatmapSetsByID.ContainsKey(id)) - UpdateBeatmapSet(realm.Realm.Find(id).Detach()); + UpdateBeatmapSet(realm.Realm.Find(id)!.Detach()); } foreach (var id in root.BeatmapSetsByID.Keys) @@ -315,7 +315,7 @@ namespace osu.Game.Screens.Select } } - private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes, Exception? error) + private void beatmapsChanged(IRealmCollection sender, ChangeSet? changes) { // we only care about actual changes in hidden status. if (changes == null) diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index 5dd2486e1c..3605e3d706 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -76,14 +76,12 @@ namespace osu.Game.Screens.Select protected override void PopIn() { this.MoveToX(0, animation_duration, Easing.OutQuint); - this.RotateTo(0, animation_duration, Easing.OutQuint); this.FadeIn(transition_duration); } protected override void PopOut() { this.MoveToX(-100, animation_duration, Easing.In); - this.RotateTo(10, animation_duration, Easing.In); this.FadeOut(transition_duration * 2, Easing.In); } diff --git a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs index 7c632b63db..c17de77619 100644 --- a/osu.Game/Screens/Select/Carousel/TopLocalRank.cs +++ b/osu.Game/Screens/Select/Carousel/TopLocalRank.cs @@ -68,7 +68,7 @@ namespace osu.Game.Screens.Select.Carousel localScoresChanged); }, true); - void localScoresChanged(IRealmCollection sender, ChangeSet? changes, Exception _) + void localScoresChanged(IRealmCollection sender, ChangeSet? changes) { // This subscription may fire from changes to linked beatmaps, which we don't care about. // It's currently not possible for a score to be modified after insertion, so we can safely ignore callbacks with only modifications. diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 4c41ed3622..58c14b15b9 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -193,7 +193,7 @@ namespace osu.Game.Screens.Select.Leaderboards + $" AND {nameof(ScoreInfo.DeletePending)} == false" , beatmapInfo.ID, ruleset.Value.ShortName), localScoresChanged); - void localScoresChanged(IRealmCollection sender, ChangeSet? changes, Exception exception) + void localScoresChanged(IRealmCollection sender, ChangeSet? changes) { if (cancellationToken.IsCancellationRequested) return; diff --git a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs index c4add31a4f..cd98872b65 100644 --- a/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs +++ b/osu.Game/Screens/Select/LocalScoreDeleteDialog.cs @@ -4,9 +4,7 @@ using osu.Framework.Allocation; using osu.Game.Overlays.Dialog; using osu.Game.Scoring; -using System.Diagnostics; using osu.Framework.Graphics.Sprites; -using osu.Game.Beatmaps; namespace osu.Game.Screens.Select { @@ -20,11 +18,8 @@ namespace osu.Game.Screens.Select } [BackgroundDependencyLoader] - private void load(BeatmapManager beatmapManager, ScoreManager scoreManager) + private void load(ScoreManager scoreManager) { - BeatmapInfo? beatmapInfo = beatmapManager.QueryBeatmap(b => b.ID == score.BeatmapInfoID); - Debug.Assert(beatmapInfo != null); - BodyText = $"{score.User} ({score.DisplayAccuracy}, {score.Rank})"; Icon = FontAwesome.Regular.TrashAlt; diff --git a/osu.Game/Screens/Spectate/SpectatorScreen.cs b/osu.Game/Screens/Spectate/SpectatorScreen.cs index 2b56767bd0..48b5c210b8 100644 --- a/osu.Game/Screens/Spectate/SpectatorScreen.cs +++ b/osu.Game/Screens/Spectate/SpectatorScreen.cs @@ -90,7 +90,7 @@ namespace osu.Game.Screens.Spectate })); } - private void beatmapsChanged(IRealmCollection items, ChangeSet changes, Exception ___) + private void beatmapsChanged(IRealmCollection items, ChangeSet changes) { if (changes?.InsertedIndices == null) return; diff --git a/osu.Game/Skinning/RealmBackedResourceStore.cs b/osu.Game/Skinning/RealmBackedResourceStore.cs index cc887a7a61..cce099a268 100644 --- a/osu.Game/Skinning/RealmBackedResourceStore.cs +++ b/osu.Game/Skinning/RealmBackedResourceStore.cs @@ -38,7 +38,7 @@ namespace osu.Game.Skinning realmSubscription?.Dispose(); } - private void skinChanged(IRealmCollection sender, ChangeSet changes, Exception error) => invalidateCache(); + private void skinChanged(IRealmCollection sender, ChangeSet? changes) => invalidateCache(); protected override IEnumerable GetFilenames(string name) { diff --git a/osu.Game/Skinning/SkinImporter.cs b/osu.Game/Skinning/SkinImporter.cs index 43760c4a19..f2103a45c4 100644 --- a/osu.Game/Skinning/SkinImporter.cs +++ b/osu.Game/Skinning/SkinImporter.cs @@ -198,7 +198,7 @@ namespace osu.Game.Skinning using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(skinInfoJson))) { - modelManager.AddFile(s, streamContent, skin_info_file, s.Realm); + modelManager.AddFile(s, streamContent, skin_info_file, s.Realm!); } // Then serialise each of the drawable component groups into respective files. @@ -213,9 +213,9 @@ namespace osu.Game.Skinning var oldFile = s.GetFile(filename); if (oldFile != null) - modelManager.ReplaceFile(oldFile, streamContent, s.Realm); + modelManager.ReplaceFile(oldFile, streamContent, s.Realm!); else - modelManager.AddFile(s, streamContent, filename, s.Realm); + modelManager.AddFile(s, streamContent, filename, s.Realm!); } } diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index 1761282e2e..c82f642fdc 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -111,7 +111,7 @@ namespace osu.Game.Users protected string Username => score.User.Username; - public BeatmapInfo BeatmapInfo => score.BeatmapInfo; + public BeatmapInfo? BeatmapInfo => score.BeatmapInfo; public WatchingReplay(ScoreInfo score) { diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index b4d8dd513f..20b1574617 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -35,9 +35,9 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + - +