diff --git a/README.md b/README.md index b28435e5..68554a4e 100644 --- a/README.md +++ b/README.md @@ -119,9 +119,9 @@ Whether to process user total stats. Set to `0` to disable processing. Default is unset (processing enabled). -### REALTIME_DIFFICULTY +### ALWAYS_USE_REALTIME_DIFFICULTY -Whether to use realtime processing (download beatmaps and compute their difficulty attributes on every processed score), or to rely on database data. Set to `0` to disable processing. +Whether to always use realtime processing (download beatmaps and compute their difficulty attributes on every processed score), or to rely on database data when possible. Set to `0` to disable processing. Default is unset (processing enabled). diff --git a/osu.Server.Queues.ScoreStatisticsProcessor.Tests/BeatmapStoreTest.cs b/osu.Server.Queues.ScoreStatisticsProcessor.Tests/BeatmapStoreTest.cs new file mode 100644 index 00000000..f0dad2ca --- /dev/null +++ b/osu.Server.Queues.ScoreStatisticsProcessor.Tests/BeatmapStoreTest.cs @@ -0,0 +1,105 @@ +// 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 osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Catch.Mods; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Mania.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Taiko; +using osu.Game.Rulesets.Taiko.Mods; +using osu.Server.Queues.ScoreStatisticsProcessor.Stores; +using Xunit; + +namespace osu.Server.Queues.ScoreStatisticsProcessor.Tests +{ + public class BeatmapStoreTest + { + private static readonly HashSet osu_ranked_mods = new HashSet + { + typeof(OsuModNoFail), + typeof(OsuModEasy), + typeof(OsuModTouchDevice), + typeof(OsuModHidden), + typeof(OsuModHardRock), + typeof(OsuModSuddenDeath), + typeof(OsuModDoubleTime), + typeof(OsuModHalfTime), + typeof(OsuModNightcore), + typeof(OsuModFlashlight), + typeof(OsuModSpunOut), + typeof(OsuModPerfect), + }; + + private static readonly HashSet taiko_ranked_mods = new HashSet + { + typeof(TaikoModNoFail), + typeof(TaikoModEasy), + typeof(TaikoModHidden), + typeof(TaikoModHardRock), + typeof(TaikoModSuddenDeath), + typeof(TaikoModDoubleTime), + typeof(TaikoModHalfTime), + typeof(TaikoModNightcore), + typeof(TaikoModFlashlight), + typeof(TaikoModPerfect), + }; + + private static readonly HashSet catch_ranked_mods = new HashSet + { + typeof(CatchModNoFail), + typeof(CatchModEasy), + typeof(CatchModHidden), + typeof(CatchModHardRock), + typeof(CatchModSuddenDeath), + typeof(CatchModDoubleTime), + typeof(CatchModHalfTime), + typeof(CatchModNightcore), + typeof(CatchModFlashlight), + typeof(CatchModPerfect), + }; + + private static readonly HashSet mania_ranked_mods = new HashSet + { + typeof(ManiaModNoFail), + typeof(ManiaModEasy), + typeof(ManiaModHidden), + typeof(ManiaModSuddenDeath), + typeof(ManiaModDoubleTime), + typeof(ManiaModHalfTime), + typeof(ManiaModNightcore), + typeof(ManiaModFlashlight), + typeof(ManiaModPerfect), + typeof(ManiaModKey4), + typeof(ManiaModKey5), + typeof(ManiaModKey6), + typeof(ManiaModKey7), + typeof(ManiaModKey8), + typeof(ManiaModFadeIn), + typeof(ManiaModKey9), + typeof(ManiaModMirror), + }; + + public static readonly object[][] RANKED_TEST_DATA = + [ + [new OsuRuleset(), osu_ranked_mods], + [new TaikoRuleset(), taiko_ranked_mods], + [new CatchRuleset(), catch_ranked_mods], + [new ManiaRuleset(), mania_ranked_mods], + ]; + + [Theory] + [MemberData(nameof(RANKED_TEST_DATA))] + public void TestLegacyModsMarkedAsRankedCorrectly(Ruleset ruleset, HashSet legacyModTypes) + { + var rulesetMods = ruleset.CreateAllMods(); + + foreach (var mod in rulesetMods) + Assert.Equal(legacyModTypes.Contains(mod.GetType()), BeatmapStore.IsRankedLegacyMod(mod)); + } + } +} diff --git a/osu.Server.Queues.ScoreStatisticsProcessor.Tests/DatabaseTest.cs b/osu.Server.Queues.ScoreStatisticsProcessor.Tests/DatabaseTest.cs index f597910f..483e72c8 100644 --- a/osu.Server.Queues.ScoreStatisticsProcessor.Tests/DatabaseTest.cs +++ b/osu.Server.Queues.ScoreStatisticsProcessor.Tests/DatabaseTest.cs @@ -45,7 +45,7 @@ protected DatabaseTest(AssemblyName[]? externalProcessorAssemblies = null) ? new CancellationTokenSource() : new CancellationTokenSource(20000); - Environment.SetEnvironmentVariable("REALTIME_DIFFICULTY", "0"); + Environment.SetEnvironmentVariable("ALWAYS_USE_REALTIME_DIFFICULTY", "0"); Processor = new ScoreStatisticsQueueProcessor(externalProcessorAssemblies: externalProcessorAssemblies); Processor.Error += processorOnError; diff --git a/osu.Server.Queues.ScoreStatisticsProcessor.Tests/PerformanceProcessorTests.cs b/osu.Server.Queues.ScoreStatisticsProcessor.Tests/PerformanceProcessorTests.cs index c75a996e..29190406 100644 --- a/osu.Server.Queues.ScoreStatisticsProcessor.Tests/PerformanceProcessorTests.cs +++ b/osu.Server.Queues.ScoreStatisticsProcessor.Tests/PerformanceProcessorTests.cs @@ -576,6 +576,37 @@ public async Task UserDailyRankUpdates() }); } + [Fact] + public void EnforcedRealtimeDifficultyModsAwardPP() + { + AddBeatmap(b => b.beatmap_id = 315); + + // difficulty attributes are intentionally not added to the database + // so if pp was populated, it had to go through the realtime calculations + + ScoreItem score; + + using (MySqlConnection conn = Processor.GetDatabaseConnection()) + { + score = CreateTestScore(rulesetId: 0, beatmapId: 315); + + score.Score.ScoreData.Statistics[HitResult.Great] = 100; + score.Score.max_combo = 100; + score.Score.accuracy = 1; + score.Score.build_id = TestBuildID; + score.Score.ScoreData.Mods = new[] { new APIMod(new OsuModMuted()), new APIMod(new OsuModTraceable()) }; + score.Score.preserve = true; + + conn.Insert(score.Score); + PushToQueueAndWaitForProcess(score); + } + + WaitForDatabaseState("SELECT COUNT(*) FROM scores WHERE id = @ScoreId AND pp IS NOT NULL", 1, CancellationToken, new + { + ScoreId = score.Score.id + }); + } + private class InvalidMod : Mod { public override string Name => "Invalid"; diff --git a/osu.Server.Queues.ScoreStatisticsProcessor/Stores/BeatmapStore.cs b/osu.Server.Queues.ScoreStatisticsProcessor/Stores/BeatmapStore.cs index 59d503f8..3eb46872 100644 --- a/osu.Server.Queues.ScoreStatisticsProcessor/Stores/BeatmapStore.cs +++ b/osu.Server.Queues.ScoreStatisticsProcessor/Stores/BeatmapStore.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; @@ -13,10 +14,15 @@ using osu.Game.Beatmaps; using osu.Game.Beatmaps.Legacy; using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch.Mods; using osu.Game.Rulesets.Difficulty; +using osu.Game.Rulesets.Mania.Mods; using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Rulesets.Taiko.Mods; using osu.Server.Queues.ScoreStatisticsProcessor.Helpers; using osu.Server.Queues.ScoreStatisticsProcessor.Models; +using StatsdClient; using Beatmap = osu.Server.Queues.ScoreStatisticsProcessor.Models.Beatmap; namespace osu.Server.Queues.ScoreStatisticsProcessor.Stores @@ -26,7 +32,7 @@ namespace osu.Server.Queues.ScoreStatisticsProcessor.Stores /// public class BeatmapStore { - private static readonly bool use_realtime_difficulty_calculation = Environment.GetEnvironmentVariable("REALTIME_DIFFICULTY") != "0"; + private static readonly bool always_use_realtime_difficulty_calculation = Environment.GetEnvironmentVariable("ALWAYS_USE_REALTIME_DIFFICULTY") != "0"; private static readonly string beatmap_download_path = Environment.GetEnvironmentVariable("BEATMAP_DOWNLOAD_PATH") ?? "https://osu.ppy.sh/osu/{0}"; private readonly ConcurrentDictionary beatmapCache = new ConcurrentDictionary(); @@ -65,8 +71,16 @@ public static async Task CreateAsync(MySqlConnection connection, M /// The difficulty attributes or null if not existing. public async Task GetDifficultyAttributesAsync(Beatmap beatmap, Ruleset ruleset, Mod[] mods, MySqlConnection connection, MySqlTransaction? transaction = null) { - if (use_realtime_difficulty_calculation) + // database attributes are stored using the default mod configurations + // if we want to support mods with non-default configurations (i.e non-1.5x rates on DT/NC) + // or non-legacy mods which aren't populated into the database (with exception to CL) + // then we must calculate difficulty attributes in real-time. + bool mustUseRealtimeDifficulty = mods.Any(m => !m.UsesDefaultConfiguration || (!IsRankedLegacyMod(m) && m is not ModClassic)); + + if (always_use_realtime_difficulty_calculation || mustUseRealtimeDifficulty) { + var stopwatch = Stopwatch.StartNew(); + using var req = new WebRequest(string.Format(beatmap_download_path, beatmap.beatmap_id)); req.AllowInsecureRequests = true; @@ -79,7 +93,17 @@ public static async Task CreateAsync(MySqlConnection connection, M var workingBeatmap = new StreamedWorkingBeatmap(req.ResponseStream); var calculator = ruleset.CreateDifficultyCalculator(workingBeatmap); - return calculator.Calculate(mods); + var attributes = calculator.Calculate(mods); + + string[] tags = + { + $"ruleset:{ruleset.RulesetInfo.OnlineID}", + $"mods:{string.Join("", mods.Select(x => x.Acronym))}" + }; + + DogStatsd.Timer("calculate-realtime-difficulty-attributes", stopwatch.ElapsedMilliseconds, tags: tags); + + return attributes; } BeatmapDifficultyAttribute[]? rawDifficultyAttributes; @@ -107,12 +131,44 @@ public static async Task CreateAsync(MySqlConnection connection, M return difficultyAttributes; } + /// + /// This method attempts to create a simple solution to deciding if a can be considered a ranked "legacy" mod. + /// Used by to decide if the current mod combination's difficulty attributes + /// can be fetched from the database. + /// + public static bool IsRankedLegacyMod(Mod mod) => + mod is ModNoFail + or ModEasy + or ModPerfect + or ModSuddenDeath + or ModNightcore + or ModDoubleTime + or ModHalfTime + or ModFlashlight + or ModTouchDevice + or OsuModHardRock + or OsuModSpunOut + or OsuModHidden + or TaikoModHardRock + or TaikoModHidden + or CatchModHardRock + or CatchModHidden + or ManiaModKey4 + or ManiaModKey5 + or ManiaModKey6 + or ManiaModKey7 + or ManiaModKey8 + or ManiaModKey9 + or ManiaModMirror + or ManiaModHidden + or ManiaModFadeIn; + /// /// This method attempts to choose the best possible set of to use for looking up stored difficulty attributes. /// The match is not always exact; for some mods that award pp but do not exist in stable /// (such as ) the closest available approximation is used. /// Moreover, the set of returned is constrained to mods that actually affect difficulty in the legacy sense. - /// The entirety of this workaround is not used / unnecessary if is . + /// The entirety of this workaround is not used / unnecessary if is . /// private static LegacyMods getLegacyModsForAttributeLookup(Beatmap beatmap, Ruleset ruleset, Mod[] mods) {