diff --git a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs index ac8168dfc9..8e09a01469 100644 --- a/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs +++ b/osu.Game.Rulesets.Mania/Configuration/ManiaRulesetConfigManager.cs @@ -28,7 +28,12 @@ namespace osu.Game.Rulesets.Mania.Configuration public override TrackedSettings CreateTrackedSettings() => new TrackedSettings { new TrackedSetting(ManiaRulesetSetting.ScrollTime, - v => new SettingDescription(v, "Scroll Speed", $"{(int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / v)} ({v}ms)")) + scrollTime => new SettingDescription( + rawValue: scrollTime, + name: "Scroll Speed", + value: $"{(int)Math.Round(DrawableManiaRuleset.MAX_TIME_RANGE / scrollTime)} ({scrollTime}ms)" + ) + ) }; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs index bd4c0f2ad5..a08fe3b7c5 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyAttributes.cs @@ -14,6 +14,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty public double OverallDifficulty { get; set; } public double DrainRate { get; set; } public int HitCircleCount { get; set; } + public int SliderCount { get; set; } public int SpinnerCount { get; set; } } } diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs index 790aa0eb7d..b0a764dc4d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuDifficultyCalculator.cs @@ -64,6 +64,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty maxCombo += beatmap.HitObjects.OfType().Sum(s => s.NestedHitObjects.Count - 1); int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle); + int sliderCount = beatmap.HitObjects.Count(h => h is Slider); int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner); return new OsuDifficultyAttributes @@ -78,6 +79,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty DrainRate = drainRate, MaxCombo = maxCombo, HitCircleCount = hitCirclesCount, + SliderCount = sliderCount, SpinnerCount = spinnerCount, Skills = skills }; diff --git a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs index ac60504b92..bf4d92652c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs @@ -40,21 +40,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty countMeh = Score.Statistics.GetValueOrDefault(HitResult.Meh); countMiss = Score.Statistics.GetValueOrDefault(HitResult.Miss); + // Custom multipliers for NoFail and SpunOut. double multiplier = 1.12; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. - // Custom multipliers for NoFail and SpunOut. if (mods.Any(m => m is OsuModNoFail)) multiplier *= Math.Max(0.90, 1.0 - 0.02 * countMiss); if (mods.Any(m => m is OsuModSpunOut)) multiplier *= 1.0 - Math.Pow((double)Attributes.SpinnerCount / totalHits, 0.85); - if (mods.Any(h => h is OsuModRelax)) - { - countMiss += countOk + countMeh; - multiplier *= 0.6; - } - double aimValue = computeAimValue(); double speedValue = computeSpeedValue(); double accuracyValue = computeAccuracyValue(); @@ -114,18 +108,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty double approachRateBonus = 1.0 + (0.03 + 0.37 * approachRateTotalHitsFactor) * approachRateFactor; - if (mods.Any(m => m is OsuModBlinds)) - aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * countMiss)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * Attributes.DrainRate * Attributes.DrainRate); - else if (mods.Any(h => h is OsuModHidden)) - { - // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. + // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. + if (mods.Any(h => h is OsuModHidden)) aimValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate); - } aimValue *= approachRateBonus; - // Scale the aim value with accuracy - aimValue *= accuracy; + // Scale the aim value with accuracy _slightly_. + aimValue *= 0.5 + accuracy / 2.0; // It is important to also consider accuracy difficulty when doing that. aimValue *= 0.98 + Math.Pow(Attributes.OverallDifficulty, 2) / 2500; @@ -157,20 +147,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty speedValue *= 1.0 + (0.03 + 0.37 * approachRateTotalHitsFactor) * approachRateFactor; - if (mods.Any(m => m is OsuModBlinds)) - { - // Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given. - speedValue *= 1.12; - } - else if (mods.Any(m => m is OsuModHidden)) - { - // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. + if (mods.Any(m => m is OsuModHidden)) speedValue *= 1.0 + 0.04 * (12.0 - Attributes.ApproachRate); - } // Scale the speed value with accuracy and OD. speedValue *= (0.95 + Math.Pow(Attributes.OverallDifficulty, 2) / 750) * Math.Pow(accuracy, (14.5 - Math.Max(Attributes.OverallDifficulty, 8)) / 2); - // Scale the speed value with # of 50s to punish doubletapping. speedValue *= Math.Pow(0.98, countMeh < totalHits / 500.0 ? 0 : countMeh - totalHits / 500.0); @@ -179,9 +160,6 @@ namespace osu.Game.Rulesets.Osu.Difficulty private double computeAccuracyValue() { - if (mods.Any(h => h is OsuModRelax)) - return 0.0; - // This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window. double betterAccuracyPercentage; int amountHitObjectsWithAccuracy = Attributes.HitCircleCount; @@ -202,12 +180,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty // Bonus for many hitcircles - it's harder to keep good accuracy up for longer. accuracyValue *= Math.Min(1.15, Math.Pow(amountHitObjectsWithAccuracy / 1000.0, 0.3)); - // Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given. - if (mods.Any(m => m is OsuModBlinds)) - accuracyValue *= 1.14; - else if (mods.Any(m => m is OsuModHidden)) + if (mods.Any(m => m is OsuModHidden)) accuracyValue *= 1.08; - if (mods.Any(m => m is OsuModFlashlight)) accuracyValue *= 1.02; diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index d86358fb57..5d85c4338c 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -13,6 +13,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing public class OsuDifficultyHitObject : DifficultyHitObject { private const int normalized_radius = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths. + private const int min_delta_time = 25; protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject; @@ -26,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing /// public double MovementDistance { get; private set; } - /// JumpTravel + /// /// Normalized distance between the start and end position of the previous . /// public double TravelDistance { get; private set; } @@ -62,7 +63,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing this.lastObject = (OsuHitObject)lastObject; // Capped to 25ms to prevent difficulty calculation breaking from simulatenous objects. - StrainTime = Math.Max(DeltaTime, 25); + StrainTime = Math.Max(DeltaTime, min_delta_time); setDistances(clockRate); } @@ -82,22 +83,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing scalingFactor *= 1 + smallCircleBonus; } - double sliderAbuseIndex = 1; - if (lastObject is Slider lastSlider) { computeSliderCursorPosition(lastSlider); - sliderAbuseIndex = Math.Clamp(Vector2.Subtract(lastSlider.StackedPosition * scalingFactor, BaseObject.StackedPosition * scalingFactor).Length - 100, 0, 25) / 25; - TravelDistance = lastSlider.LazyTravelDistance * scalingFactor * sliderAbuseIndex; - TravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, 25); - MovementTime = Math.Max(StrainTime - TravelTime, 25); + TravelDistance = lastSlider.LazyTravelDistance * scalingFactor; + TravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time); + MovementTime = Math.Max(StrainTime - TravelTime, min_delta_time); MovementDistance = Vector2.Subtract(lastSlider.TailCircle.StackedPosition, BaseObject.StackedPosition).Length * scalingFactor; } Vector2 lastCursorPosition = getEndCursorPosition(lastObject); - JumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length * sliderAbuseIndex; - MovementDistance = Math.Min(JumpDistance, MovementDistance) * sliderAbuseIndex; + JumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; + MovementDistance = Math.Min(JumpDistance, MovementDistance); if (lastLastObject != null && !(lastLastObject is Spinner)) { @@ -120,7 +118,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing slider.LazyEndPosition = slider.StackedPosition; - float approxFollowCircleRadius = (float)(slider.Radius * 2.4); + float followCircleRadius = (float)(slider.Radius * 2.4); var computeVertex = new Action(t => { double progress = (t - slider.StartTime) / slider.SpanDuration; @@ -135,11 +133,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing slider.LazyTravelTime = t - slider.StartTime; - if (dist > approxFollowCircleRadius) + if (dist > followCircleRadius) { // The cursor would be outside the follow circle, we need to move it diff.Normalize(); // Obtain direction of diff - dist -= approxFollowCircleRadius; + dist -= followCircleRadius; slider.LazyEndPosition += diff * dist; slider.LazyTravelDistance += dist; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 1fa0be32b0..33d443c6ca 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -40,7 +40,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills var osuPrevObj = (OsuDifficultyHitObject)Previous[0]; var osuLastObj = (OsuDifficultyHitObject)Previous[1]; - double currVelocity = osuCurrObj.JumpDistance / osuCurrObj.StrainTime; // Start iwth the base distance / time + double currVelocity = osuCurrObj.JumpDistance / osuCurrObj.StrainTime; // Start with the base distance / time if (osuPrevObj.BaseObject is Slider) // If object is a slider { diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs new file mode 100644 index 0000000000..4cdcf507b6 --- /dev/null +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -0,0 +1,820 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using osu.Framework.Extensions; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Logging; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.IO.Archives; +using osu.Game.Models; +using osu.Game.Stores; +using osu.Game.Tests.Resources; +using Realms; +using SharpCompress.Archives; +using SharpCompress.Archives.Zip; +using SharpCompress.Common; +using SharpCompress.Writers.Zip; + +#nullable enable + +namespace osu.Game.Tests.Database +{ + [TestFixture] + public class BeatmapImporterTests : RealmTest + { + [Test] + public void TestImportBeatmapThenCleanup() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using (var importer = new BeatmapImporter(realmFactory, storage)) + using (new RealmRulesetStore(realmFactory, storage)) + { + ILive? imported; + + using (var reader = new ZipArchiveReader(TestResources.GetTestBeatmapStream())) + imported = await importer.Import(reader); + + Assert.AreEqual(1, realmFactory.Context.All().Count()); + + Assert.NotNull(imported); + Debug.Assert(imported != null); + + imported.PerformWrite(s => s.DeletePending = true); + + Assert.AreEqual(1, realmFactory.Context.All().Count(s => s.DeletePending)); + } + }); + + Logger.Log("Running with no work to purge pending deletions"); + + RunTestWithRealm((realmFactory, _) => { Assert.AreEqual(0, realmFactory.Context.All().Count()); }); + } + + [Test] + public void TestImportWhenClosed() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + await LoadOszIntoStore(importer, realmFactory.Context); + }); + } + + [Test] + public void TestImportThenDelete() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + deleteBeatmapSet(imported, realmFactory.Context); + }); + } + + [Test] + public void TestImportThenDeleteFromStream() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var tempPath = TestResources.GetTestBeatmapForImport(); + + ILive? importedSet; + + using (var stream = File.OpenRead(tempPath)) + { + importedSet = await importer.Import(new ImportTask(stream, Path.GetFileName(tempPath))); + ensureLoaded(realmFactory.Context); + } + + Assert.NotNull(importedSet); + Debug.Assert(importedSet != null); + + Assert.IsTrue(File.Exists(tempPath), "Stream source file somehow went missing"); + File.Delete(tempPath); + + var imported = realmFactory.Context.All().First(beatmapSet => beatmapSet.ID == importedSet.ID); + + deleteBeatmapSet(imported, realmFactory.Context); + }); + } + + [Test] + public void TestImportThenImport() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + var importedSecondTime = await LoadOszIntoStore(importer, realmFactory.Context); + + // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. + Assert.IsTrue(imported.ID == importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); + + checkBeatmapSetCount(realmFactory.Context, 1); + checkSingleReferencedFileCount(realmFactory.Context, 18); + }); + } + + [Test] + public void TestImportThenImportWithReZip() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + string hashBefore = hashFile(temp); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + // zip files differ because different compression or encoder. + Assert.AreNotEqual(hashBefore, hashFile(temp)); + + var importedSecondTime = await importer.Import(new ImportTask(temp)); + + ensureLoaded(realmFactory.Context); + + Assert.NotNull(importedSecondTime); + Debug.Assert(importedSecondTime != null); + + // but contents doesn't, so existing should still be used. + Assert.IsTrue(imported.ID == importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + + [Test] + public void TestImportThenImportWithChangedHashedFile() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + await createScoreForBeatmap(realmFactory.Context, imported.Beatmaps.First()); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + // arbitrary write to hashed file + // this triggers the special BeatmapManager.PreImport deletion/replacement flow. + using (var sw = new FileInfo(Directory.GetFiles(extractedFolder, "*.osu").First()).AppendText()) + await sw.WriteLineAsync("// changed"); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var importedSecondTime = await importer.Import(new ImportTask(temp)); + + ensureLoaded(realmFactory.Context); + + // check the newly "imported" beatmap is not the original. + Assert.NotNull(importedSecondTime); + Debug.Assert(importedSecondTime != null); + + Assert.IsTrue(imported.ID != importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + + [Test] + [Ignore("intentionally broken by import optimisations")] + public void TestImportThenImportWithChangedFile() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + // arbitrary write to non-hashed file + using (var sw = new FileInfo(Directory.GetFiles(extractedFolder, "*.mp3").First()).AppendText()) + await sw.WriteLineAsync("text"); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var importedSecondTime = await importer.Import(new ImportTask(temp)); + + ensureLoaded(realmFactory.Context); + + Assert.NotNull(importedSecondTime); + Debug.Assert(importedSecondTime != null); + + // check the newly "imported" beatmap is not the original. + Assert.IsTrue(imported.ID != importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + + [Test] + public void TestImportThenImportWithDifferentFilename() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + // change filename + var firstFile = new FileInfo(Directory.GetFiles(extractedFolder).First()); + firstFile.MoveTo(Path.Combine(firstFile.DirectoryName.AsNonNull(), $"{firstFile.Name}-changed{firstFile.Extension}")); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var importedSecondTime = await importer.Import(new ImportTask(temp)); + + ensureLoaded(realmFactory.Context); + + Assert.NotNull(importedSecondTime); + Debug.Assert(importedSecondTime != null); + + // check the newly "imported" beatmap is not the original. + Assert.IsTrue(imported.ID != importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.PerformRead(s => s.Beatmaps.First().ID)); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + + [Test] + [Ignore("intentionally broken by import optimisations")] + public void TestImportCorruptThenImport() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + var firstFile = imported.Files.First(); + + long originalLength; + using (var stream = storage.GetStream(firstFile.File.StoragePath)) + originalLength = stream.Length; + + using (var stream = storage.GetStream(firstFile.File.StoragePath, FileAccess.Write, FileMode.Create)) + stream.WriteByte(0); + + var importedSecondTime = await LoadOszIntoStore(importer, realmFactory.Context); + + using (var stream = storage.GetStream(firstFile.File.StoragePath)) + Assert.AreEqual(stream.Length, originalLength, "Corruption was not fixed on second import"); + + // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. + Assert.IsTrue(imported.ID == importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); + + checkBeatmapSetCount(realmFactory.Context, 1); + checkSingleReferencedFileCount(realmFactory.Context, 18); + }); + } + + [Test] + public void TestRollbackOnFailure() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + int loggedExceptionCount = 0; + + Logger.NewEntry += l => + { + if (l.Target == LoggingTarget.Database && l.Exception != null) + Interlocked.Increment(ref loggedExceptionCount); + }; + + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + realmFactory.Context.Write(() => imported.Hash += "-changed"); + + checkBeatmapSetCount(realmFactory.Context, 1); + checkBeatmapCount(realmFactory.Context, 12); + checkSingleReferencedFileCount(realmFactory.Context, 18); + + var brokenTempFilename = TestResources.GetTestBeatmapForImport(); + + MemoryStream brokenOsu = new MemoryStream(); + MemoryStream brokenOsz = new MemoryStream(await File.ReadAllBytesAsync(brokenTempFilename)); + + File.Delete(brokenTempFilename); + + using (var outStream = File.Open(brokenTempFilename, FileMode.CreateNew)) + using (var zip = ZipArchive.Open(brokenOsz)) + { + zip.AddEntry("broken.osu", brokenOsu, false); + zip.SaveTo(outStream, CompressionType.Deflate); + } + + // this will trigger purging of the existing beatmap (online set id match) but should rollback due to broken osu. + try + { + await importer.Import(new ImportTask(brokenTempFilename)); + } + catch + { + } + + checkBeatmapSetCount(realmFactory.Context, 1); + checkBeatmapCount(realmFactory.Context, 12); + + checkSingleReferencedFileCount(realmFactory.Context, 18); + + Assert.AreEqual(1, loggedExceptionCount); + + File.Delete(brokenTempFilename); + }); + } + + [Test] + public void TestImportThenDeleteThenImport() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + deleteBeatmapSet(imported, realmFactory.Context); + + var importedSecondTime = await LoadOszIntoStore(importer, realmFactory.Context); + + // check the newly "imported" beatmap is actually just the restored previous import. since it matches hash. + Assert.IsTrue(imported.ID == importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID == importedSecondTime.Beatmaps.First().ID); + }); + } + + [Test] + public void TestImportThenDeleteThenImportWithOnlineIDsMissing() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + realmFactory.Context.Write(() => + { + foreach (var b in imported.Beatmaps) + b.OnlineID = -1; + }); + + deleteBeatmapSet(imported, realmFactory.Context); + + var importedSecondTime = await LoadOszIntoStore(importer, realmFactory.Context); + + // check the newly "imported" beatmap has been reimported due to mismatch (even though hashes matched) + Assert.IsTrue(imported.ID != importedSecondTime.ID); + Assert.IsTrue(imported.Beatmaps.First().ID != importedSecondTime.Beatmaps.First().ID); + }); + } + + [Test] + public void TestImportWithDuplicateBeatmapIDs() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var metadata = new RealmBeatmapMetadata + { + Artist = "SomeArtist", + Author = "SomeAuthor" + }; + + var ruleset = realmFactory.Context.All().First(); + + var toImport = new RealmBeatmapSet + { + OnlineID = 1, + Beatmaps = + { + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) + { + OnlineID = 2, + }, + new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), metadata) + { + OnlineID = 2, + Status = BeatmapSetOnlineStatus.Loved, + } + } + }; + + var imported = await importer.Import(toImport); + + Assert.NotNull(imported); + Debug.Assert(imported != null); + + Assert.AreEqual(-1, imported.PerformRead(s => s.Beatmaps[0].OnlineID)); + Assert.AreEqual(-1, imported.PerformRead(s => s.Beatmaps[1].OnlineID)); + }); + } + + [Test] + public void TestImportWhenFileOpen() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + using (File.OpenRead(temp)) + await importer.Import(temp); + ensureLoaded(realmFactory.Context); + File.Delete(temp); + Assert.IsFalse(File.Exists(temp), "We likely held a read lock on the file when we shouldn't"); + }); + } + + [Test] + public void TestImportWithDuplicateHashes() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + Directory.CreateDirectory(extractedFolder); + + try + { + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(extractedFolder); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.AddEntry("duplicate.osu", Directory.GetFiles(extractedFolder, "*.osu").First()); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + await importer.Import(temp); + + ensureLoaded(realmFactory.Context); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + + [Test] + public void TestImportNestedStructure() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + string subfolder = Path.Combine(extractedFolder, "subfolder"); + + Directory.CreateDirectory(subfolder); + + try + { + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(subfolder); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var imported = await importer.Import(new ImportTask(temp)); + + Assert.NotNull(imported); + Debug.Assert(imported != null); + + ensureLoaded(realmFactory.Context); + + Assert.IsFalse(imported.PerformRead(s => s.Files.Any(f => f.Filename.Contains("subfolder"))), "Files contain common subfolder"); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + + [Test] + public void TestImportWithIgnoredDirectoryInArchive() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + + string extractedFolder = $"{temp}_extracted"; + string dataFolder = Path.Combine(extractedFolder, "actual_data"); + string resourceForkFolder = Path.Combine(extractedFolder, "__MACOSX"); + string resourceForkFilePath = Path.Combine(resourceForkFolder, ".extracted"); + + Directory.CreateDirectory(dataFolder); + Directory.CreateDirectory(resourceForkFolder); + + using (var resourceForkFile = File.CreateText(resourceForkFilePath)) + { + await resourceForkFile.WriteLineAsync("adding content so that it's not empty"); + } + + try + { + using (var zip = ZipArchive.Open(temp)) + zip.WriteToDirectory(dataFolder); + + using (var zip = ZipArchive.Create()) + { + zip.AddAllFromDirectory(extractedFolder); + zip.SaveTo(temp, new ZipWriterOptions(CompressionType.Deflate)); + } + + var imported = await importer.Import(new ImportTask(temp)); + + Assert.NotNull(imported); + Debug.Assert(imported != null); + + ensureLoaded(realmFactory.Context); + + Assert.IsFalse(imported.PerformRead(s => s.Files.Any(f => f.Filename.Contains("__MACOSX"))), "Files contain resource fork folder, which should be ignored"); + Assert.IsFalse(imported.PerformRead(s => s.Files.Any(f => f.Filename.Contains("actual_data"))), "Files contain common subfolder"); + } + finally + { + Directory.Delete(extractedFolder, true); + } + }); + } + + [Test] + public void TestUpdateBeatmapInfo() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new BeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var temp = TestResources.GetTestBeatmapForImport(); + await importer.Import(temp); + + // Update via the beatmap, not the beatmap info, to ensure correct linking + RealmBeatmapSet setToUpdate = realmFactory.Context.All().First(); + + var beatmapToUpdate = setToUpdate.Beatmaps.First(); + + realmFactory.Context.Write(() => beatmapToUpdate.DifficultyName = "updated"); + + RealmBeatmap updatedInfo = realmFactory.Context.All().First(b => b.ID == beatmapToUpdate.ID); + Assert.That(updatedInfo.DifficultyName, Is.EqualTo("updated")); + }); + } + + public static async Task LoadQuickOszIntoOsu(BeatmapImporter importer, Realm realm) + { + var temp = TestResources.GetQuickTestBeatmapForImport(); + + var importedSet = await importer.Import(new ImportTask(temp)); + + Assert.NotNull(importedSet); + + ensureLoaded(realm); + + waitForOrAssert(() => !File.Exists(temp), "Temporary file still exists after standard import", 5000); + + return realm.All().FirstOrDefault(beatmapSet => beatmapSet.ID == importedSet!.ID); + } + + public static async Task LoadOszIntoStore(BeatmapImporter importer, Realm realm, string? path = null, bool virtualTrack = false) + { + var temp = path ?? TestResources.GetTestBeatmapForImport(virtualTrack); + + var importedSet = await importer.Import(new ImportTask(temp)); + + Assert.NotNull(importedSet); + Debug.Assert(importedSet != null); + + ensureLoaded(realm); + + waitForOrAssert(() => !File.Exists(temp), "Temporary file still exists after standard import", 5000); + + return realm.All().First(beatmapSet => beatmapSet.ID == importedSet.ID); + } + + private void deleteBeatmapSet(RealmBeatmapSet imported, Realm realm) + { + realm.Write(() => imported.DeletePending = true); + + checkBeatmapSetCount(realm, 0); + checkBeatmapSetCount(realm, 1, true); + + Assert.IsTrue(realm.All().First(_ => true).DeletePending); + } + + private static Task createScoreForBeatmap(Realm realm, RealmBeatmap beatmap) + { + // TODO: reimplement when we have score support in realm. + // return ImportScoreTest.LoadScoreIntoOsu(osu, new ScoreInfo + // { + // OnlineScoreID = 2, + // Beatmap = beatmap, + // BeatmapInfoID = beatmap.ID + // }, new ImportScoreTest.TestArchiveReader()); + + return Task.CompletedTask; + } + + private static void checkBeatmapSetCount(Realm realm, int expected, bool includeDeletePending = false) + { + Assert.AreEqual(expected, includeDeletePending + ? realm.All().Count() + : realm.All().Count(s => !s.DeletePending)); + } + + private static string hashFile(string filename) + { + using (var s = File.OpenRead(filename)) + return s.ComputeMD5Hash(); + } + + private static void checkBeatmapCount(Realm realm, int expected) + { + Assert.AreEqual(expected, realm.All().Where(_ => true).ToList().Count); + } + + private static void checkSingleReferencedFileCount(Realm realm, int expected) + { + int singleReferencedCount = 0; + + foreach (var f in realm.All()) + { + if (f.BacklinksCount == 1) + singleReferencedCount++; + } + + Assert.AreEqual(expected, singleReferencedCount); + } + + private static void ensureLoaded(Realm realm, int timeout = 60000) + { + IQueryable? resultSets = null; + + waitForOrAssert(() => (resultSets = realm.All().Where(s => !s.DeletePending && s.OnlineID == 241526)).Any(), + @"BeatmapSet did not import to the database in allocated time.", timeout); + + // ensure we were stored to beatmap database backing... + Assert.IsTrue(resultSets?.Count() == 1, $@"Incorrect result count found ({resultSets?.Count()} but should be 1)."); + + IEnumerable queryBeatmapSets() => realm.All().Where(s => !s.DeletePending && s.OnlineID == 241526); + + var set = queryBeatmapSets().First(); + + // ReSharper disable once PossibleUnintendedReferenceComparison + IEnumerable queryBeatmaps() => realm.All().Where(s => s.BeatmapSet != null && s.BeatmapSet == set); + + waitForOrAssert(() => queryBeatmaps().Count() == 12, @"Beatmaps did not import to the database in allocated time", timeout); + waitForOrAssert(() => queryBeatmapSets().Count() == 1, @"BeatmapSet did not import to the database in allocated time", timeout); + + int countBeatmapSetBeatmaps = 0; + int countBeatmaps = 0; + + waitForOrAssert(() => + (countBeatmapSetBeatmaps = queryBeatmapSets().First().Beatmaps.Count) == + (countBeatmaps = queryBeatmaps().Count()), + $@"Incorrect database beatmap count post-import ({countBeatmaps} but should be {countBeatmapSetBeatmaps}).", timeout); + + foreach (RealmBeatmap b in set.Beatmaps) + Assert.IsTrue(set.Beatmaps.Any(c => c.OnlineID == b.OnlineID)); + Assert.IsTrue(set.Beatmaps.Count > 0); + } + + private static void waitForOrAssert(Func result, string failureMessage, int timeout = 60000) + { + const int sleep = 200; + + while (timeout > 0) + { + Thread.Sleep(sleep); + timeout -= sleep; + + if (result()) + return; + } + + Assert.Fail(failureMessage); + } + } +} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs index 00b5c38e20..c5ab3974a4 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneAllRulesetPlayers.cs @@ -20,14 +20,15 @@ namespace osu.Game.Tests.Visual.Gameplay /// public abstract class TestSceneAllRulesetPlayers : RateAdjustedBeatmapTestScene { - protected Player Player; + protected Player Player { get; private set; } + + protected OsuConfigManager Config { get; private set; } [BackgroundDependencyLoader] private void load(RulesetStore rulesets) { - OsuConfigManager manager; - Dependencies.Cache(manager = new OsuConfigManager(LocalStorage)); - manager.GetBindable(OsuSetting.DimLevel).Value = 1.0; + Dependencies.Cache(Config = new OsuConfigManager(LocalStorage)); + Config.GetBindable(OsuSetting.DimLevel).Value = 1.0; } [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs index 85aaf20a19..36fc6812bd 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailAnimation.cs @@ -2,7 +2,9 @@ // See the LICENCE file in the repository root for full licence text. using System; +using NUnit.Framework; using osu.Framework.Graphics.Containers; +using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Screens.Play; @@ -17,6 +19,14 @@ namespace osu.Game.Tests.Visual.Gameplay return new FailPlayer(); } + [Test] + public void TestOsuWithoutRedTint() + { + AddStep("Disable red tint", () => Config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, false)); + TestOsu(); + AddStep("Enable red tint", () => Config.SetValue(OsuSetting.FadePlayfieldWhenHealthLow, true)); + } + protected override void AddCheckSteps() { AddUntilStep("wait for fail", () => Player.HasFailed); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index aee15a145c..ba0ee5ac6e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -291,7 +291,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for current", () => loader.IsCurrentScreen()); - AddAssert($"epilepsy warning {(warning ? "present" : "absent")}", () => this.ChildrenOfType().Any() == warning); + AddAssert($"epilepsy warning {(warning ? "present" : "absent")}", () => (getWarning() != null) == warning); if (warning) { @@ -335,12 +335,17 @@ namespace osu.Game.Tests.Visual.Gameplay AddUntilStep("wait for current", () => loader.IsCurrentScreen()); - AddUntilStep("wait for epilepsy warning", () => loader.ChildrenOfType().Single().Alpha > 0); + AddUntilStep("wait for epilepsy warning", () => getWarning().Alpha > 0); + AddUntilStep("warning is shown", () => getWarning().State.Value == Visibility.Visible); + AddStep("exit early", () => loader.Exit()); + AddUntilStep("warning is hidden", () => getWarning().State.Value == Visibility.Hidden); AddUntilStep("sound volume restored", () => Beatmap.Value.Track.AggregateVolume.Value == 1); } + private EpilepsyWarning getWarning() => loader.ChildrenOfType().SingleOrDefault(); + private class TestPlayerLoader : PlayerLoader { public new VisualSettings VisualSettings => base.VisualSettings; diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs index 5ff2e9c439..bf864f844c 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerScoreSubmission.cs @@ -3,6 +3,7 @@ using System; using System.Linq; +using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Screens; using osu.Game.Beatmaps; @@ -10,10 +11,13 @@ using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Online.Solo; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Judgements; using osu.Game.Rulesets.Scoring; +using osu.Game.Rulesets.Taiko; +using osu.Game.Scoring; using osu.Game.Screens.Ranking; using osu.Game.Tests.Beatmaps; @@ -32,7 +36,7 @@ namespace osu.Game.Tests.Visual.Gameplay protected override bool HasCustomSteps => true; - protected override TestPlayer CreatePlayer(Ruleset ruleset) => new TestPlayer(false); + protected override TestPlayer CreatePlayer(Ruleset ruleset) => new NonImportingPlayer(false); protected override Ruleset CreatePlayerRuleset() => createCustomRuleset?.Invoke() ?? new OsuRuleset(); @@ -86,6 +90,46 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("ensure passing submission", () => Player.SubmittedScore?.ScoreInfo.Passed == true); } + [Test] + public void TestSubmissionForDifferentRuleset() + { + prepareTokenResponse(true); + + createPlayerTest(createRuleset: () => new TaikoRuleset()); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + + addFakeHit(); + + AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); + + AddUntilStep("results displayed", () => Player.GetChildScreen() is ResultsScreen); + AddAssert("ensure passing submission", () => Player.SubmittedScore?.ScoreInfo.Passed == true); + AddAssert("submitted score has correct ruleset ID", () => Player.SubmittedScore?.ScoreInfo.RulesetID == new TaikoRuleset().RulesetInfo.ID); + } + + [Test] + public void TestSubmissionForConvertedBeatmap() + { + prepareTokenResponse(true); + + createPlayerTest(createRuleset: () => new ManiaRuleset(), createBeatmap: _ => createTestBeatmap(new OsuRuleset().RulesetInfo)); + + AddUntilStep("wait for token request", () => Player.TokenCreationRequested); + + AddUntilStep("wait for track to start running", () => Beatmap.Value.Track.IsRunning); + + addFakeHit(); + + AddStep("seek to completion", () => Player.GameplayClockContainer.Seek(Player.DrawableRuleset.Objects.Last().GetEndTime())); + + AddUntilStep("results displayed", () => Player.GetChildScreen() is ResultsScreen); + AddAssert("ensure passing submission", () => Player.SubmittedScore?.ScoreInfo.Passed == true); + AddAssert("submitted score has correct ruleset ID", () => Player.SubmittedScore?.ScoreInfo.RulesetID == new ManiaRuleset().RulesetInfo.ID); + } + [Test] public void TestNoSubmissionOnExitWithNoToken() { @@ -183,12 +227,13 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("ensure no submission", () => Player.SubmittedScore == null); } - [Test] - public void TestNoSubmissionOnCustomRuleset() + [TestCase(null)] + [TestCase(10)] + public void TestNoSubmissionOnCustomRuleset(int? rulesetId) { prepareTokenResponse(true); - createPlayerTest(false, createRuleset: () => new OsuRuleset { RulesetInfo = { ID = 10 } }); + createPlayerTest(false, createRuleset: () => new OsuRuleset { RulesetInfo = { ID = rulesetId } }); AddUntilStep("wait for token request", () => Player.TokenCreationRequested); @@ -242,5 +287,33 @@ namespace osu.Game.Tests.Visual.Gameplay }); }); } + + private class NonImportingPlayer : TestPlayer + { + public NonImportingPlayer(bool allowPause = true, bool showResults = true, bool pauseOnFocusLost = false) + : base(allowPause, showResults, pauseOnFocusLost) + { + } + + protected override Task ImportScore(Score score) + { + // It was discovered that Score members could sometimes be half-populated. + // In particular, the RulesetID property could be set to 0 even on non-osu! maps. + // We want to test that the state of that property is consistent in this test. + // EF makes this impossible. + // + // First off, because of the EF navigational property-explicit foreign key field duality, + // it can happen that - for example - the Ruleset navigational property is correctly initialised to mania, + // but the RulesetID foreign key property is not initialised and remains 0. + // EF silently bypasses this by prioritising the Ruleset navigational property over the RulesetID foreign key one. + // + // Additionally, adding an entity to an EF DbSet CAUSES SIDE EFFECTS with regard to the foreign key property. + // In the above instance, if a ScoreInfo with Ruleset = {mania} and RulesetID = 0 is attached to an EF context, + // RulesetID WILL BE SILENTLY SET TO THE CORRECT VALUE of 3. + // + // For the above reasons, importing is disabled in this test. + return Task.CompletedTask; + } + } } } diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorHost.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorHost.cs new file mode 100644 index 0000000000..89fea1f92d --- /dev/null +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorHost.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Game.Online.API; +using osu.Game.Online.Spectator; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Mania; +using osu.Game.Tests.Visual.Spectator; +using osu.Game.Users; + +namespace osu.Game.Tests.Visual.Gameplay +{ + public class TestSceneSpectatorHost : PlayerTestScene + { + protected override Ruleset CreatePlayerRuleset() => new ManiaRuleset(); + + [Cached(typeof(SpectatorClient))] + private TestSpectatorClient spectatorClient { get; } = new TestSpectatorClient(); + + private DummyAPIAccess dummyAPIAccess => (DummyAPIAccess)API; + private const int dummy_user_id = 42; + + public override void SetUpSteps() + { + AddStep("set dummy user", () => dummyAPIAccess.LocalUser.Value = new User + { + Id = dummy_user_id, + Username = "DummyUser" + }); + AddStep("add test spectator client", () => Add(spectatorClient)); + AddStep("add watching user", () => spectatorClient.WatchUser(dummy_user_id)); + base.SetUpSteps(); + } + + [Test] + public void TestClientSendsCorrectRuleset() + { + AddUntilStep("spectator client sending frames", () => spectatorClient.PlayingUserStates.ContainsKey(dummy_user_id)); + AddAssert("spectator client sent correct ruleset", () => spectatorClient.PlayingUserStates[dummy_user_id].RulesetID == Ruleset.Value.ID); + } + + public override void TearDownSteps() + { + base.TearDownSteps(); + AddStep("stop watching user", () => spectatorClient.StopWatchingUser(dummy_user_id)); + AddStep("remove test spectator client", () => Remove(spectatorClient)); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index c4ebc13245..d1980b03c7 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -275,6 +275,68 @@ namespace osu.Game.Tests.Visual.Multiplayer var state = i; AddStep($"set state: {state}", () => Client.ChangeUserState(0, state)); } + + AddStep("set state: downloading", () => Client.ChangeUserBeatmapAvailability(0, BeatmapAvailability.Downloading(0))); + + AddStep("set state: locally available", () => Client.ChangeUserBeatmapAvailability(0, BeatmapAvailability.LocallyAvailable())); + } + + [Test] + public void TestModOverlap() + { + AddStep("add dummy mods", () => + { + Client.ChangeUserMods(new Mod[] + { + new OsuModNoFail(), + new OsuModDoubleTime() + }); + }); + + AddStep("add user with mods", () => + { + Client.AddUser(new User + { + Id = 0, + Username = "Baka", + RulesetsStatistics = new Dictionary + { + { + Ruleset.Value.ShortName, + new UserStatistics { GlobalRank = RNG.Next(1, 100000), } + } + }, + CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }); + Client.ChangeUserMods(0, new Mod[] + { + new OsuModHardRock(), + new OsuModDoubleTime() + }); + }); + + AddStep("set 0 ready", () => Client.ChangeState(MultiplayerUserState.Ready)); + + AddStep("set 1 spectate", () => Client.ChangeUserState(0, MultiplayerUserState.Spectating)); + + // Have to set back to idle due to status priority. + AddStep("set 0 no map, 1 ready", () => + { + Client.ChangeState(MultiplayerUserState.Idle); + Client.ChangeBeatmapAvailability(BeatmapAvailability.NotDownloaded()); + Client.ChangeUserState(0, MultiplayerUserState.Ready); + }); + + AddStep("set 0 downloading", () => Client.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0))); + + AddStep("set 0 spectate", () => Client.ChangeUserState(0, MultiplayerUserState.Spectating)); + + AddStep("make both default", () => + { + Client.ChangeBeatmapAvailability(BeatmapAvailability.LocallyAvailable()); + Client.ChangeUserState(0, MultiplayerUserState.Idle); + Client.ChangeState(MultiplayerUserState.Idle); + }); } private void createNewParticipantsList() diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapAvailability.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapAvailability.cs index fe94165777..6f9744ca73 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapAvailability.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapAvailability.cs @@ -3,6 +3,7 @@ using NUnit.Framework; using osu.Game.Beatmaps; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapSet; namespace osu.Game.Tests.Visual.Online @@ -22,7 +23,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("set undownloadable beatmapset with link", () => container.BeatmapSet = new BeatmapSetInfo { - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Availability = new BeatmapSetOnlineAvailability { @@ -40,7 +41,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("set undownloadable beatmapset without link", () => container.BeatmapSet = new BeatmapSetInfo { - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Availability = new BeatmapSetOnlineAvailability { @@ -57,7 +58,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("set parts-removed beatmapset with link", () => container.BeatmapSet = new BeatmapSetInfo { - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Availability = new BeatmapSetOnlineAvailability { @@ -75,7 +76,7 @@ namespace osu.Game.Tests.Visual.Online { AddStep("set normal beatmapset", () => container.BeatmapSet = new BeatmapSetInfo { - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Availability = new BeatmapSetOnlineAvailability { diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index 453e26ef96..ef89a86e79 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -11,6 +11,7 @@ using osu.Game.Users; using System; using System.Collections.Generic; using System.Linq; +using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Tests.Visual.Online { @@ -63,7 +64,7 @@ namespace osu.Game.Tests.Visual.Online Id = 3, }, }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Preview = @"https://b.ppy.sh/preview/12345.mp3", PlayCount = 123, @@ -134,7 +135,7 @@ namespace osu.Game.Tests.Visual.Online Id = 3, }, }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Availability = new BeatmapSetOnlineAvailability { @@ -224,7 +225,7 @@ namespace osu.Game.Tests.Visual.Online Id = 3, } }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers(), }, @@ -309,7 +310,7 @@ namespace osu.Game.Tests.Visual.Online Id = 3, }, }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Preview = @"https://b.ppy.sh/preview/123.mp3", HasVideo = true, diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs index f7099b0615..c15c9f44e4 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlayDetails.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapSet; using osu.Game.Screens.Select.Details; @@ -57,7 +58,7 @@ namespace osu.Game.Tests.Visual.Online }, } }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Status = BeatmapSetOnlineStatus.Ranked } diff --git a/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs b/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs index 3fc894da0d..bb7fcc2fce 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDirectDownloadButton.cs @@ -7,6 +7,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Beatmaps; using osu.Game.Online; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Rulesets.Osu; using osu.Game.Tests.Resources; @@ -74,7 +75,7 @@ namespace osu.Game.Tests.Visual.Online { ID = 1, OnlineBeatmapSetID = 241526, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Availability = new BeatmapSetOnlineAvailability { diff --git a/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs index 722010ace2..6caca2a67c 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneDirectPanel.cs @@ -7,6 +7,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Audio; using osu.Game.Beatmaps; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapListing.Panels; using osu.Game.Rulesets; using osu.Game.Users; @@ -31,7 +32,7 @@ namespace osu.Game.Tests.Visual.Online Id = 3, }, }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Availability = new BeatmapSetOnlineAvailability { @@ -86,7 +87,7 @@ namespace osu.Game.Tests.Visual.Online Id = 3, } }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { HasVideo = true, HasStoryboard = true, diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs index 1e9d62f379..b5d2d15392 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfilePreviousUsernames.cs @@ -3,11 +3,7 @@ using System; using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Framework.Bindables; using osu.Framework.Graphics; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Overlays.Profile.Header.Components; using osu.Game.Users; @@ -16,48 +12,50 @@ namespace osu.Game.Tests.Visual.Online [TestFixture] public class TestSceneUserProfilePreviousUsernames : OsuTestScene { - [Resolved] - private IAPIProvider api { get; set; } + private PreviousUsernames container; - private readonly Bindable user = new Bindable(); - - public TestSceneUserProfilePreviousUsernames() + [SetUp] + public void SetUp() => Schedule(() => { - Child = new PreviousUsernames + Child = container = new PreviousUsernames { Anchor = Anchor.Centre, Origin = Anchor.Centre, - User = { BindTarget = user }, }; + }); - User[] users = - { - new User { PreviousUsernames = new[] { "username1" } }, - new User { PreviousUsernames = new[] { "longusername", "longerusername" } }, - new User { PreviousUsernames = new[] { "test", "angelsim", "verylongusername" } }, - new User { PreviousUsernames = new[] { "ihavenoidea", "howcani", "makethistext", "anylonger" } }, - new User { PreviousUsernames = Array.Empty() }, - null - }; - - AddStep("single username", () => user.Value = users[0]); - AddStep("two usernames", () => user.Value = users[1]); - AddStep("three usernames", () => user.Value = users[2]); - AddStep("four usernames", () => user.Value = users[3]); - AddStep("no username", () => user.Value = users[4]); - AddStep("null user", () => user.Value = users[5]); - } - - protected override void LoadComplete() + [Test] + public void TestVisibility() { - base.LoadComplete(); + AddAssert("Is Hidden", () => container.Alpha == 0); - AddStep("online user (Angelsim)", () => - { - var request = new GetUserRequest(1777162); - request.Success += user => this.user.Value = user; - api.Queue(request); - }); + AddStep("1 username", () => container.User.Value = users[0]); + AddUntilStep("Is visible", () => container.Alpha == 1); + + AddStep("2 usernames", () => container.User.Value = users[1]); + AddUntilStep("Is visible", () => container.Alpha == 1); + + AddStep("3 usernames", () => container.User.Value = users[2]); + AddUntilStep("Is visible", () => container.Alpha == 1); + + AddStep("4 usernames", () => container.User.Value = users[3]); + AddUntilStep("Is visible", () => container.Alpha == 1); + + AddStep("No username", () => container.User.Value = users[4]); + AddUntilStep("Is hidden", () => container.Alpha == 0); + + AddStep("Null user", () => container.User.Value = users[5]); + AddUntilStep("Is hidden", () => container.Alpha == 0); } + + private static readonly User[] users = + { + new User { Id = 1, PreviousUsernames = new[] { "username1" } }, + new User { Id = 2, PreviousUsernames = new[] { "longusername", "longerusername" } }, + new User { Id = 3, PreviousUsernames = new[] { "test", "angelsim", "verylongusername" } }, + new User { Id = 4, PreviousUsernames = new[] { "ihavenoidea", "howcani", "makethistext", "anylonger" } }, + new User { Id = 5, PreviousUsernames = Array.Empty() }, + null + }; } } diff --git a/osu.Game.Tests/Visual/Settings/TestSceneRestoreDefaultValueButton.cs b/osu.Game.Tests/Visual/Settings/TestSceneRestoreDefaultValueButton.cs new file mode 100644 index 0000000000..3eb7a77600 --- /dev/null +++ b/osu.Game.Tests/Visual/Settings/TestSceneRestoreDefaultValueButton.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Tests.Visual.Settings +{ + public class TestSceneRestoreDefaultValueButton : OsuTestScene + { + [Resolved] + private OsuColour colours { get; set; } + + private float scale = 1; + + private readonly Bindable current = new Bindable + { + Default = default, + Value = 1, + }; + + [Test] + public void TestBasic() + { + RestoreDefaultValueButton restoreDefaultValueButton = null; + + AddStep("create button", () => Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.GreySeafoam + }, + restoreDefaultValueButton = new RestoreDefaultValueButton + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Scale = new Vector2(scale), + Current = current, + } + } + }); + AddSliderStep("set scale", 1, 4, 1, scale => + { + this.scale = scale; + if (restoreDefaultValueButton != null) + restoreDefaultValueButton.Scale = new Vector2(scale); + }); + AddToggleStep("toggle default state", state => current.Value = state ? default : 1); + AddToggleStep("toggle disabled state", state => current.Disabled = state); + } + } +} diff --git a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs index d9cce69ee3..83265e13ad 100644 --- a/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs +++ b/osu.Game.Tests/Visual/Settings/TestSceneSettingsItem.cs @@ -5,6 +5,9 @@ using System.Linq; using NUnit.Framework; using osu.Framework.Bindables; using osu.Framework.Testing; +using osu.Framework.Utils; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; using osu.Game.Overlays.Settings; using osu.Game.Overlays; @@ -29,9 +32,10 @@ namespace osu.Game.Tests.Visual.Settings Value = "test" } }; - - restoreDefaultValueButton = textBox.ChildrenOfType>().Single(); }); + AddUntilStep("wait for loaded", () => textBox.IsLoaded); + AddStep("retrieve restore default button", () => restoreDefaultValueButton = textBox.ChildrenOfType>().Single()); + AddAssert("restore button hidden", () => restoreDefaultValueButton.Alpha == 0); AddStep("change value from default", () => textBox.Current.Value = "non-default"); @@ -41,6 +45,48 @@ namespace osu.Game.Tests.Visual.Settings AddUntilStep("restore button hidden", () => restoreDefaultValueButton.Alpha == 0); } + [Test] + public void TestSetAndClearLabelText() + { + SettingsTextBox textBox = null; + RestoreDefaultValueButton restoreDefaultValueButton = null; + OsuTextBox control = null; + + AddStep("create settings item", () => + { + Child = textBox = new SettingsTextBox + { + Current = new Bindable + { + Default = "test", + Value = "test" + } + }; + }); + AddUntilStep("wait for loaded", () => textBox.IsLoaded); + AddStep("retrieve components", () => + { + restoreDefaultValueButton = textBox.ChildrenOfType>().Single(); + control = textBox.ChildrenOfType().Single(); + }); + + AddStep("set non-default value", () => restoreDefaultValueButton.Current.Value = "non-default"); + AddAssert("default value button centre aligned to control size", () => Precision.AlmostEquals(restoreDefaultValueButton.Parent.DrawHeight, control.DrawHeight, 1)); + + AddStep("set label", () => textBox.LabelText = "label text"); + AddAssert("default value button centre aligned to label size", () => + { + var label = textBox.ChildrenOfType().Single(spriteText => spriteText.Text == "label text"); + return Precision.AlmostEquals(restoreDefaultValueButton.Parent.DrawHeight, label.DrawHeight, 1); + }); + + AddStep("clear label", () => textBox.LabelText = default); + AddAssert("default value button centre aligned to control size", () => Precision.AlmostEquals(restoreDefaultValueButton.Parent.DrawHeight, control.DrawHeight, 1)); + + AddStep("set warning text", () => textBox.WarningText = "This is some very important warning text! Hopefully it doesn't break the alignment of the default value indicator..."); + AddAssert("default value button centre aligned to control size", () => Precision.AlmostEquals(restoreDefaultValueButton.Parent.DrawHeight, control.DrawHeight, 1)); + } + /// /// Ensures that the reset to default button uses the correct implementation of IsDefault to determine whether it should be shown or not. /// Values have been chosen so that after being set, Value != Default (but they are close enough that the difference is negligible compared to Precision). @@ -64,9 +110,9 @@ namespace osu.Game.Tests.Visual.Settings Precision = 0.1f, } }; - - restoreDefaultValueButton = sliderBar.ChildrenOfType>().Single(); }); + AddUntilStep("wait for loaded", () => sliderBar.IsLoaded); + AddStep("retrieve restore default button", () => restoreDefaultValueButton = sliderBar.ChildrenOfType>().Single()); AddAssert("restore button hidden", () => restoreDefaultValueButton.Alpha == 0); diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs index 008d91f649..a9fe7ed7d8 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatmapListingSearchControl.cs @@ -10,6 +10,7 @@ using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Graphics.Sprites; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.BeatmapListing; using osuTK; @@ -111,7 +112,7 @@ namespace osu.Game.Tests.Visual.UserInterface private static readonly BeatmapSetInfo beatmap_set = new BeatmapSetInfo { - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers { @@ -122,7 +123,7 @@ namespace osu.Game.Tests.Visual.UserInterface private static readonly BeatmapSetInfo no_cover_beatmap_set = new BeatmapSetInfo { - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs index c51204eaba..6727c7560b 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDashboardBeatmapListing.cs @@ -11,6 +11,7 @@ using osu.Game.Users; using System; using osu.Framework.Graphics.Shapes; using System.Collections.Generic; +using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Tests.Visual.UserInterface { @@ -69,7 +70,7 @@ namespace osu.Game.Tests.Visual.UserInterface Id = 100 } }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers { @@ -90,7 +91,7 @@ namespace osu.Game.Tests.Visual.UserInterface Id = 100 } }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers { @@ -115,7 +116,7 @@ namespace osu.Game.Tests.Visual.UserInterface Id = 100 } }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers { @@ -136,7 +137,7 @@ namespace osu.Game.Tests.Visual.UserInterface Id = 100 } }, - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs new file mode 100644 index 0000000000..9e77fcf675 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuDropdown.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Game.Beatmaps; +using osu.Game.Graphics.UserInterface; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public class TestSceneOsuDropdown : ThemeComparisonTestScene + { + protected override Drawable CreateContent() => + new OsuEnumDropdown + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Width = 150 + }; + } +} diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs index 756928d3ec..fc1866cdf3 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneOsuTextBox.cs @@ -1,80 +1,67 @@ // 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 NUnit.Framework; +using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; +using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; +using osu.Game.Overlays; using osuTK; -using osuTK.Graphics; namespace osu.Game.Tests.Visual.UserInterface { - public class TestSceneOsuTextBox : OsuTestScene + public class TestSceneOsuTextBox : ThemeComparisonTestScene { - private readonly OsuNumberBox numberBox; + private IEnumerable numberBoxes => this.ChildrenOfType(); - public TestSceneOsuTextBox() + protected override Drawable CreateContent() => new FillFlowContainer { - Child = new Container + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Padding = new MarginPadding(50f), + Spacing = new Vector2(0f, 50f), + Children = new[] { - Masking = true, - CornerRadius = 10f, - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding(15f), - Children = new Drawable[] + new OsuTextBox { - new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Color4.DarkSlateGray, - Alpha = 0.75f, - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Vertical, - Padding = new MarginPadding(50f), - Spacing = new Vector2(0f, 50f), - Children = new[] - { - new OsuTextBox - { - Width = 500f, - PlaceholderText = "Normal textbox", - }, - new OsuPasswordTextBox - { - Width = 500f, - PlaceholderText = "Password textbox", - }, - numberBox = new OsuNumberBox - { - Width = 500f, - PlaceholderText = "Number textbox" - } - } - } + RelativeSizeAxes = Axes.X, + PlaceholderText = "Normal textbox", + }, + new OsuPasswordTextBox + { + RelativeSizeAxes = Axes.X, + PlaceholderText = "Password textbox", + }, + new OsuNumberBox + { + RelativeSizeAxes = Axes.X, + PlaceholderText = "Number textbox" } - }; - } + } + }; [Test] public void TestNumberBox() { - clearTextbox(numberBox); - AddStep("enter numbers", () => numberBox.Text = "987654321"); - expectedValue(numberBox, "987654321"); + AddStep("create themed content", () => CreateThemedContent(OverlayColourScheme.Red)); - clearTextbox(numberBox); - AddStep("enter text + single number", () => numberBox.Text = "1 hello 2 world 3"); - expectedValue(numberBox, "123"); + clearTextboxes(numberBoxes); + AddStep("enter numbers", () => numberBoxes.ForEach(numberBox => numberBox.Text = "987654321")); + expectedValue(numberBoxes, "987654321"); - clearTextbox(numberBox); + clearTextboxes(numberBoxes); + AddStep("enter text + single number", () => numberBoxes.ForEach(numberBox => numberBox.Text = "1 hello 2 world 3")); + expectedValue(numberBoxes, "123"); + + clearTextboxes(numberBoxes); } - private void clearTextbox(OsuTextBox textBox) => AddStep("clear textbox", () => textBox.Text = null); - private void expectedValue(OsuTextBox textBox, string value) => AddAssert("expected textbox value", () => textBox.Text == value); + private void clearTextboxes(IEnumerable textBoxes) => AddStep("clear textbox", () => textBoxes.ForEach(textBox => textBox.Text = null)); + private void expectedValue(IEnumerable textBoxes, string value) => AddAssert("expected textbox value", () => textBoxes.All(textbox => textbox.Text == value)); } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs index 4fef93e291..3ac3002713 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapSetCover.cs @@ -13,6 +13,7 @@ using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; using osuTK; namespace osu.Game.Tests.Visual.UserInterface @@ -22,21 +23,21 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestLocal([Values] BeatmapSetCoverType coverType) { - AddStep("setup cover", () => Child = new UpdateableBeatmapSetCover(coverType) + AddStep("setup cover", () => Child = new UpdateableOnlineBeatmapSetCover(coverType) { BeatmapSet = CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet, RelativeSizeAxes = Axes.Both, Masking = true, }); - AddUntilStep("wait for load", () => this.ChildrenOfType().SingleOrDefault()?.IsLoaded ?? false); + AddUntilStep("wait for load", () => this.ChildrenOfType().SingleOrDefault()?.IsLoaded ?? false); } [Test] public void TestUnloadAndReload() { OsuScrollContainer scroll = null; - List covers = new List(); + List covers = new List(); AddStep("setup covers", () => { @@ -65,7 +66,7 @@ namespace osu.Game.Tests.Visual.UserInterface { var coverType = coverTypes[i % coverTypes.Count]; - var cover = new UpdateableBeatmapSetCover(coverType) + var cover = new UpdateableOnlineBeatmapSetCover(coverType) { BeatmapSet = setInfo, Height = 100, @@ -84,7 +85,7 @@ namespace osu.Game.Tests.Visual.UserInterface } }); - var loadedCovers = covers.Where(c => c.ChildrenOfType().SingleOrDefault()?.IsLoaded ?? false); + var loadedCovers = covers.Where(c => c.ChildrenOfType().SingleOrDefault()?.IsLoaded ?? false); AddUntilStep("some loaded", () => loadedCovers.Any()); AddStep("scroll to end", () => scroll.ScrollToEnd()); @@ -94,9 +95,9 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestSetNullBeatmapWhileLoading() { - TestUpdateableBeatmapSetCover updateableCover = null; + TestUpdateableOnlineBeatmapSetCover updateableCover = null; - AddStep("setup cover", () => Child = updateableCover = new TestUpdateableBeatmapSetCover + AddStep("setup cover", () => Child = updateableCover = new TestUpdateableOnlineBeatmapSetCover { BeatmapSet = CreateBeatmap(Ruleset.Value).BeatmapInfo.BeatmapSet, RelativeSizeAxes = Axes.Both, @@ -111,10 +112,10 @@ namespace osu.Game.Tests.Visual.UserInterface [Test] public void TestCoverChangeOnNewBeatmap() { - TestUpdateableBeatmapSetCover updateableCover = null; - BeatmapSetCover initialCover = null; + TestUpdateableOnlineBeatmapSetCover updateableCover = null; + OnlineBeatmapSetCover initialCover = null; - AddStep("setup cover", () => Child = updateableCover = new TestUpdateableBeatmapSetCover(0) + AddStep("setup cover", () => Child = updateableCover = new TestUpdateableOnlineBeatmapSetCover(0) { BeatmapSet = createBeatmapWithCover("https://assets.ppy.sh/beatmaps/1189904/covers/cover.jpg"), RelativeSizeAxes = Axes.Both, @@ -122,38 +123,38 @@ namespace osu.Game.Tests.Visual.UserInterface Alpha = 0.4f }); - AddUntilStep("cover loaded", () => updateableCover.ChildrenOfType().Any()); - AddStep("store initial cover", () => initialCover = updateableCover.ChildrenOfType().Single()); + AddUntilStep("cover loaded", () => updateableCover.ChildrenOfType().Any()); + AddStep("store initial cover", () => initialCover = updateableCover.ChildrenOfType().Single()); AddUntilStep("wait for fade complete", () => initialCover.Alpha == 1); AddStep("switch beatmap", () => updateableCover.BeatmapSet = createBeatmapWithCover("https://assets.ppy.sh/beatmaps/1079428/covers/cover.jpg")); - AddUntilStep("new cover loaded", () => updateableCover.ChildrenOfType().Except(new[] { initialCover }).Any()); + AddUntilStep("new cover loaded", () => updateableCover.ChildrenOfType().Except(new[] { initialCover }).Any()); } private static BeatmapSetInfo createBeatmapWithCover(string coverUrl) => new BeatmapSetInfo { - OnlineInfo = new BeatmapSetOnlineInfo + OnlineInfo = new APIBeatmapSet { Covers = new BeatmapSetOnlineCovers { Cover = coverUrl } } }; - private class TestUpdateableBeatmapSetCover : UpdateableBeatmapSetCover + private class TestUpdateableOnlineBeatmapSetCover : UpdateableOnlineBeatmapSetCover { private readonly int loadDelay; - public TestUpdateableBeatmapSetCover(int loadDelay = 10000) + public TestUpdateableOnlineBeatmapSetCover(int loadDelay = 10000) { this.loadDelay = loadDelay; } - protected override Drawable CreateDrawable(BeatmapSetInfo model) + protected override Drawable CreateDrawable(IBeatmapSetOnlineInfo model) { if (model == null) return null; - return new TestBeatmapSetCover(model, loadDelay) + return new TestOnlineBeatmapSetCover(model, loadDelay) { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -163,11 +164,11 @@ namespace osu.Game.Tests.Visual.UserInterface } } - private class TestBeatmapSetCover : BeatmapSetCover + private class TestOnlineBeatmapSetCover : OnlineBeatmapSetCover { private readonly int loadDelay; - public TestBeatmapSetCover(BeatmapSetInfo set, int loadDelay) + public TestOnlineBeatmapSetCover(IBeatmapSetOnlineInfo set, int loadDelay) : base(set) { this.loadDelay = loadDelay; diff --git a/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs b/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs new file mode 100644 index 0000000000..db1c90f287 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/ThemeComparisonTestScene.cs @@ -0,0 +1,69 @@ +// 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.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Shapes; +using osu.Game.Graphics; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.UserInterface +{ + public abstract class ThemeComparisonTestScene : OsuGridTestScene + { + protected ThemeComparisonTestScene() + : base(1, 2) + { + } + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + Cell(0, 0).AddRange(new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colours.GreySeafoam + }, + CreateContent() + }); + } + + protected void CreateThemedContent(OverlayColourScheme colourScheme) + { + var colourProvider = new OverlayColourProvider(colourScheme); + + Cell(0, 1).Clear(); + Cell(0, 1).Add(new DependencyProvidingContainer + { + RelativeSizeAxes = Axes.Both, + CachedDependencies = new (Type, object)[] + { + (typeof(OverlayColourProvider), colourProvider) + }, + Children = new[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background4 + }, + CreateContent() + } + }); + } + + protected abstract Drawable CreateContent(); + + [Test] + public void TestAllColourSchemes() + { + foreach (var scheme in Enum.GetValues(typeof(OverlayColourScheme)).Cast()) + AddStep($"set {scheme} scheme", () => CreateThemedContent(scheme)); + } + } +} diff --git a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs index 0e5a66e7fe..be29566e07 100644 --- a/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs +++ b/osu.Game.Tournament/Components/TournamentBeatmapPanel.cs @@ -10,7 +10,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Textures; -using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Graphics; @@ -21,7 +20,8 @@ namespace osu.Game.Tournament.Components { public class TournamentBeatmapPanel : CompositeDrawable { - public readonly BeatmapInfo BeatmapInfo; + public readonly IBeatmapInfo BeatmapInfo; + private readonly string mod; private const float horizontal_padding = 10; @@ -32,12 +32,13 @@ namespace osu.Game.Tournament.Components private readonly Bindable currentMatch = new Bindable(); private Box flash; - public TournamentBeatmapPanel(BeatmapInfo beatmapInfo, string mod = null) + public TournamentBeatmapPanel(IBeatmapInfo beatmapInfo, string mod = null) { if (beatmapInfo == null) throw new ArgumentNullException(nameof(beatmapInfo)); BeatmapInfo = beatmapInfo; this.mod = mod; + Width = 400; Height = HEIGHT; } @@ -57,11 +58,11 @@ namespace osu.Game.Tournament.Components RelativeSizeAxes = Axes.Both, Colour = Color4.Black, }, - new UpdateableBeatmapSetCover + new UpdateableOnlineBeatmapSetCover { RelativeSizeAxes = Axes.Both, Colour = OsuColour.Gray(0.5f), - BeatmapSet = BeatmapInfo.BeatmapSet, + BeatmapSet = BeatmapInfo.BeatmapSet as IBeatmapSetOnlineInfo, }, new FillFlowContainer { @@ -74,9 +75,7 @@ namespace osu.Game.Tournament.Components { new TournamentSpriteText { - Text = new RomanisableString( - $"{BeatmapInfo.Metadata.ArtistUnicode ?? BeatmapInfo.Metadata.Artist} - {BeatmapInfo.Metadata.TitleUnicode ?? BeatmapInfo.Metadata.Title}", - $"{BeatmapInfo.Metadata.Artist} - {BeatmapInfo.Metadata.Title}"), + Text = BeatmapInfo.GetDisplayTitleRomanisable(false), Font = OsuFont.Torus.With(weight: FontWeight.Bold), }, new FillFlowContainer @@ -93,7 +92,7 @@ namespace osu.Game.Tournament.Components }, new TournamentSpriteText { - Text = BeatmapInfo.Metadata.AuthorString, + Text = BeatmapInfo.Metadata?.Author, Padding = new MarginPadding { Right = 20 }, Font = OsuFont.Torus.With(weight: FontWeight.Bold, size: 14) }, @@ -105,7 +104,7 @@ namespace osu.Game.Tournament.Components }, new TournamentSpriteText { - Text = BeatmapInfo.Version, + Text = BeatmapInfo.DifficultyName, Font = OsuFont.Torus.With(weight: FontWeight.Bold, size: 14) }, } @@ -149,7 +148,7 @@ namespace osu.Game.Tournament.Components private void updateState() { - var found = currentMatch.Value.PicksBans.FirstOrDefault(p => p.BeatmapID == BeatmapInfo.OnlineBeatmapID); + var found = currentMatch.Value.PicksBans.FirstOrDefault(p => p.BeatmapID == BeatmapInfo.OnlineID); bool doFlash = found != choice; choice = found; diff --git a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs index 1e3c550323..5f6546c303 100644 --- a/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs +++ b/osu.Game.Tournament/Screens/MapPool/MapPoolScreen.cs @@ -147,11 +147,11 @@ namespace osu.Game.Tournament.Screens.MapPool if (map != null) { - if (e.Button == MouseButton.Left && map.BeatmapInfo.OnlineBeatmapID != null) - addForBeatmap(map.BeatmapInfo.OnlineBeatmapID.Value); + if (e.Button == MouseButton.Left && map.BeatmapInfo.OnlineID > 0) + addForBeatmap(map.BeatmapInfo.OnlineID); else { - var existing = CurrentMatch.Value.PicksBans.FirstOrDefault(p => p.BeatmapID == map.BeatmapInfo.OnlineBeatmapID); + var existing = CurrentMatch.Value.PicksBans.FirstOrDefault(p => p.BeatmapID == map.BeatmapInfo.OnlineID); if (existing != null) { diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index ac5b5d7a8a..3bcc00f5de 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -178,7 +178,7 @@ namespace osu.Game.Beatmaps #region Implementation of IHasOnlineID - public int? OnlineID => OnlineBeatmapID; + public int OnlineID => OnlineBeatmapID ?? -1; #endregion diff --git a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs index eba19ac1a1..836302c424 100644 --- a/osu.Game/Beatmaps/BeatmapInfoExtensions.cs +++ b/osu.Game/Beatmaps/BeatmapInfoExtensions.cs @@ -16,12 +16,17 @@ namespace osu.Game.Beatmaps /// /// A user-presentable display title representing this beatmap, with localisation handling for potentially romanisable fields. /// - public static RomanisableString GetDisplayTitleRomanisable(this IBeatmapInfo beatmapInfo) + public static RomanisableString GetDisplayTitleRomanisable(this IBeatmapInfo beatmapInfo, bool includeDifficultyName = true) { var metadata = getClosestMetadata(beatmapInfo).GetDisplayTitleRomanisable(); - var versionString = getVersionString(beatmapInfo); - return new RomanisableString($"{metadata.GetPreferred(true)} {versionString}".Trim(), $"{metadata.GetPreferred(false)} {versionString}".Trim()); + if (includeDifficultyName) + { + var versionString = getVersionString(beatmapInfo); + return new RomanisableString($"{metadata.GetPreferred(true)} {versionString}".Trim(), $"{metadata.GetPreferred(false)} {versionString}".Trim()); + } + + return new RomanisableString($"{metadata.GetPreferred(true)}".Trim(), $"{metadata.GetPreferred(false)}".Trim()); } public static string[] GetSearchableTerms(this IBeatmapInfo beatmapInfo) => new[] diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index 562cbfabf0..0509a9db47 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -29,7 +29,7 @@ namespace osu.Game.Beatmaps /// Handles general operations related to global beatmap management. /// [ExcludeFromDynamicCompile] - public class BeatmapManager : IModelDownloader, IModelManager, IModelFileManager, ICanAcceptFiles, IWorkingBeatmapCache, IDisposable + public class BeatmapManager : IModelDownloader, IModelManager, IModelFileManager, IWorkingBeatmapCache, IDisposable { private readonly BeatmapModelManager beatmapModelManager; private readonly BeatmapModelDownloader beatmapModelDownloader; diff --git a/osu.Game/Beatmaps/BeatmapModelManager.cs b/osu.Game/Beatmaps/BeatmapModelManager.cs index 76019a15ae..16cf6193f9 100644 --- a/osu.Game/Beatmaps/BeatmapModelManager.cs +++ b/osu.Game/Beatmaps/BeatmapModelManager.cs @@ -123,15 +123,15 @@ namespace osu.Game.Beatmaps // check if a set already exists with the same online id, delete if it does. if (beatmapSet.OnlineBeatmapSetID != null) { - var existingOnlineId = beatmaps.ConsumableItems.FirstOrDefault(b => b.OnlineBeatmapSetID == beatmapSet.OnlineBeatmapSetID); + var existingSetWithSameOnlineID = beatmaps.ConsumableItems.FirstOrDefault(b => b.OnlineBeatmapSetID == beatmapSet.OnlineBeatmapSetID); - if (existingOnlineId != null) + if (existingSetWithSameOnlineID != null) { - Delete(existingOnlineId); + Delete(existingSetWithSameOnlineID); // in order to avoid a unique key constraint, immediately remove the online ID from the previous set. - existingOnlineId.OnlineBeatmapSetID = null; - foreach (var b in existingOnlineId.Beatmaps) + existingSetWithSameOnlineID.OnlineBeatmapSetID = null; + foreach (var b in existingSetWithSameOnlineID.Beatmaps) b.OnlineBeatmapID = null; LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineBeatmapSetID ({beatmapSet.OnlineBeatmapSetID}). It has been deleted."); diff --git a/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs b/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs index 1fe120557d..b05ad9a1dd 100644 --- a/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs +++ b/osu.Game/Beatmaps/BeatmapOnlineLookupQueue.cs @@ -83,9 +83,9 @@ namespace osu.Game.Beatmaps if (res != null) { beatmapInfo.Status = res.Status; - beatmapInfo.BeatmapSet.Status = res.BeatmapSet.Status; + beatmapInfo.BeatmapSet.Status = res.BeatmapSet?.Status ?? BeatmapSetOnlineStatus.None; beatmapInfo.BeatmapSet.OnlineBeatmapSetID = res.OnlineBeatmapSetID; - beatmapInfo.OnlineBeatmapID = res.OnlineBeatmapID; + beatmapInfo.OnlineBeatmapID = res.OnlineID; if (beatmapInfo.Metadata != null) beatmapInfo.Metadata.AuthorID = res.AuthorID; @@ -93,7 +93,7 @@ namespace osu.Game.Beatmaps if (beatmapInfo.BeatmapSet.Metadata != null) beatmapInfo.BeatmapSet.Metadata.AuthorID = res.AuthorID; - logForModel(set, $"Online retrieval mapped {beatmapInfo} to {res.OnlineBeatmapSetID} / {res.OnlineBeatmapID}."); + logForModel(set, $"Online retrieval mapped {beatmapInfo} to {res.OnlineBeatmapSetID} / {res.OnlineID}."); } } catch (Exception e) diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index 8b01831b3c..c3e2399d53 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -6,13 +6,15 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; using System.Linq; using JetBrains.Annotations; +using Newtonsoft.Json; using osu.Framework.Testing; using osu.Game.Database; +using osu.Game.Online.API.Requests.Responses; namespace osu.Game.Beatmaps { [ExcludeFromDynamicCompile] - public class BeatmapSetInfo : IHasPrimaryKey, IHasFiles, ISoftDelete, IEquatable, IBeatmapSetInfo + public class BeatmapSetInfo : IHasPrimaryKey, IHasFiles, ISoftDelete, IEquatable, IBeatmapSetInfo, IBeatmapSetOnlineInfo { public int ID { get; set; } @@ -26,8 +28,6 @@ namespace osu.Game.Beatmaps public DateTimeOffset DateAdded { get; set; } - public BeatmapSetOnlineStatus Status { get; set; } = BeatmapSetOnlineStatus.None; - public BeatmapMetadata Metadata { get; set; } public List Beatmaps { get; set; } @@ -36,7 +36,7 @@ namespace osu.Game.Beatmaps public List Files { get; set; } = new List(); [NotMapped] - public BeatmapSetOnlineInfo OnlineInfo { get; set; } + public APIBeatmapSet OnlineInfo { get; set; } [NotMapped] public BeatmapSetMetrics Metrics { get; set; } @@ -91,7 +91,7 @@ namespace osu.Game.Beatmaps #region Implementation of IHasOnlineID - public int? OnlineID => OnlineBeatmapSetID; + public int OnlineID => OnlineBeatmapSetID ?? -1; #endregion @@ -102,5 +102,141 @@ namespace osu.Game.Beatmaps IEnumerable IBeatmapSetInfo.Files => Files; #endregion + + #region Delegation for IBeatmapSetOnlineInfo + + [NotMapped] + [JsonIgnore] + public DateTimeOffset Submitted + { + get => OnlineInfo.Submitted; + set => OnlineInfo.Submitted = value; + } + + [NotMapped] + [JsonIgnore] + public DateTimeOffset? Ranked + { + get => OnlineInfo.Ranked; + set => OnlineInfo.Ranked = value; + } + + [NotMapped] + [JsonIgnore] + public DateTimeOffset? LastUpdated + { + get => OnlineInfo.LastUpdated; + set => OnlineInfo.LastUpdated = value; + } + + [NotMapped] + [JsonIgnore] + public BeatmapSetOnlineStatus Status { get; set; } = BeatmapSetOnlineStatus.None; + + [NotMapped] + [JsonIgnore] + public bool HasExplicitContent + { + get => OnlineInfo.HasExplicitContent; + set => OnlineInfo.HasExplicitContent = value; + } + + [NotMapped] + [JsonIgnore] + public bool HasVideo + { + get => OnlineInfo.HasVideo; + set => OnlineInfo.HasVideo = value; + } + + [NotMapped] + [JsonIgnore] + public bool HasStoryboard + { + get => OnlineInfo.HasStoryboard; + set => OnlineInfo.HasStoryboard = value; + } + + [NotMapped] + [JsonIgnore] + public BeatmapSetOnlineCovers Covers + { + get => OnlineInfo.Covers; + set => OnlineInfo.Covers = value; + } + + [NotMapped] + [JsonIgnore] + public string Preview + { + get => OnlineInfo.Preview; + set => OnlineInfo.Preview = value; + } + + [NotMapped] + [JsonIgnore] + public double BPM + { + get => OnlineInfo.BPM; + set => OnlineInfo.BPM = value; + } + + [NotMapped] + [JsonIgnore] + public int PlayCount + { + get => OnlineInfo.PlayCount; + set => OnlineInfo.PlayCount = value; + } + + [NotMapped] + [JsonIgnore] + public int FavouriteCount + { + get => OnlineInfo.FavouriteCount; + set => OnlineInfo.FavouriteCount = value; + } + + [NotMapped] + [JsonIgnore] + public bool HasFavourited + { + get => OnlineInfo.HasFavourited; + set => OnlineInfo.HasFavourited = value; + } + + [NotMapped] + [JsonIgnore] + public BeatmapSetOnlineAvailability Availability + { + get => OnlineInfo.Availability; + set => OnlineInfo.Availability = value; + } + + [NotMapped] + [JsonIgnore] + public BeatmapSetOnlineGenre Genre + { + get => OnlineInfo.Genre; + set => OnlineInfo.Genre = value; + } + + [NotMapped] + [JsonIgnore] + public BeatmapSetOnlineLanguage Language + { + get => OnlineInfo.Language; + set => OnlineInfo.Language = value; + } + + [NotMapped] + [JsonIgnore] + public int? TrackId + { + get => OnlineInfo.TrackId; + set => OnlineInfo.TrackId = value; + } + + #endregion } } diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineAvailability.cs b/osu.Game/Beatmaps/BeatmapSetOnlineAvailability.cs new file mode 100644 index 0000000000..14a63f3279 --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapSetOnlineAvailability.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Beatmaps +{ + public struct BeatmapSetOnlineAvailability + { + [JsonProperty(@"download_disabled")] + public bool DownloadDisabled { get; set; } + + [JsonProperty(@"more_information")] + public string ExternalLink { get; set; } + } +} diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineCovers.cs b/osu.Game/Beatmaps/BeatmapSetOnlineCovers.cs new file mode 100644 index 0000000000..aad31befa8 --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapSetOnlineCovers.cs @@ -0,0 +1,25 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; + +namespace osu.Game.Beatmaps +{ + public struct BeatmapSetOnlineCovers + { + public string CoverLowRes { get; set; } + + [JsonProperty(@"cover@2x")] + public string Cover { get; set; } + + public string CardLowRes { get; set; } + + [JsonProperty(@"card@2x")] + public string Card { get; set; } + + public string ListLowRes { get; set; } + + [JsonProperty(@"list@2x")] + public string List { get; set; } + } +} diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineGenre.cs b/osu.Game/Beatmaps/BeatmapSetOnlineGenre.cs new file mode 100644 index 0000000000..e727e2c37f --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapSetOnlineGenre.cs @@ -0,0 +1,11 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Beatmaps +{ + public struct BeatmapSetOnlineGenre + { + public int Id { get; set; } + public string Name { get; set; } + } +} diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineLanguage.cs b/osu.Game/Beatmaps/BeatmapSetOnlineLanguage.cs new file mode 100644 index 0000000000..658e5a4005 --- /dev/null +++ b/osu.Game/Beatmaps/BeatmapSetOnlineLanguage.cs @@ -0,0 +1,11 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Beatmaps +{ + public struct BeatmapSetOnlineLanguage + { + public int Id { get; set; } + public string Name { get; set; } + } +} diff --git a/osu.Game/Beatmaps/Drawables/BeatmapSetCover.cs b/osu.Game/Beatmaps/Drawables/OnlineBeatmapSetCover.cs similarity index 76% rename from osu.Game/Beatmaps/Drawables/BeatmapSetCover.cs rename to osu.Game/Beatmaps/Drawables/OnlineBeatmapSetCover.cs index 5245bc319d..0b19c27022 100644 --- a/osu.Game/Beatmaps/Drawables/BeatmapSetCover.cs +++ b/osu.Game/Beatmaps/Drawables/OnlineBeatmapSetCover.cs @@ -9,12 +9,12 @@ using osu.Framework.Graphics.Textures; namespace osu.Game.Beatmaps.Drawables { [LongRunningLoad] - public class BeatmapSetCover : Sprite + public class OnlineBeatmapSetCover : Sprite { - private readonly BeatmapSetInfo set; + private readonly IBeatmapSetOnlineInfo set; private readonly BeatmapSetCoverType type; - public BeatmapSetCover(BeatmapSetInfo set, BeatmapSetCoverType type = BeatmapSetCoverType.Cover) + public OnlineBeatmapSetCover(IBeatmapSetOnlineInfo set, BeatmapSetCoverType type = BeatmapSetCoverType.Cover) { if (set == null) throw new ArgumentNullException(nameof(set)); @@ -31,15 +31,15 @@ namespace osu.Game.Beatmaps.Drawables switch (type) { case BeatmapSetCoverType.Cover: - resource = set.OnlineInfo.Covers.Cover; + resource = set.Covers.Cover; break; case BeatmapSetCoverType.Card: - resource = set.OnlineInfo.Covers.Card; + resource = set.Covers.Card; break; case BeatmapSetCoverType.List: - resource = set.OnlineInfo.Covers.List; + resource = set.Covers.List; break; } diff --git a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs index 3206f7b3ab..8943ad350e 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableBeatmapBackgroundSprite.cs @@ -54,7 +54,7 @@ namespace osu.Game.Beatmaps.Drawables { // prefer online cover where available. if (model?.BeatmapSet?.OnlineInfo != null) - return new BeatmapSetCover(model.BeatmapSet, beatmapSetCoverType); + return new OnlineBeatmapSetCover(model.BeatmapSet, beatmapSetCoverType); return model?.ID > 0 ? new BeatmapBackgroundSprite(beatmaps.GetWorkingBeatmap(model)) diff --git a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs b/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs similarity index 78% rename from osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs rename to osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs index 7248c9213c..73f87beb58 100644 --- a/osu.Game/Beatmaps/Drawables/UpdateableBeatmapSetCover.cs +++ b/osu.Game/Beatmaps/Drawables/UpdateableOnlineBeatmapSetCover.cs @@ -9,11 +9,11 @@ using osu.Game.Graphics; namespace osu.Game.Beatmaps.Drawables { - public class UpdateableBeatmapSetCover : ModelBackedDrawable + public class UpdateableOnlineBeatmapSetCover : ModelBackedDrawable { private readonly BeatmapSetCoverType coverType; - public BeatmapSetInfo BeatmapSet + public IBeatmapSetOnlineInfo BeatmapSet { get => Model; set => Model = value; @@ -25,7 +25,7 @@ namespace osu.Game.Beatmaps.Drawables set => base.Masking = value; } - public UpdateableBeatmapSetCover(BeatmapSetCoverType coverType = BeatmapSetCoverType.Cover) + public UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType coverType = BeatmapSetCoverType.Cover) { this.coverType = coverType; @@ -43,12 +43,12 @@ namespace osu.Game.Beatmaps.Drawables protected override DelayedLoadWrapper CreateDelayedLoadWrapper(Func createContentFunc, double timeBeforeLoad) => new DelayedLoadUnloadWrapper(createContentFunc, timeBeforeLoad); - protected override Drawable CreateDrawable(BeatmapSetInfo model) + protected override Drawable CreateDrawable(IBeatmapSetOnlineInfo model) { if (model == null) return null; - return new BeatmapSetCover(model, coverType) + return new OnlineBeatmapSetCover(model, coverType) { RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, diff --git a/osu.Game/Beatmaps/BeatmapSetOnlineInfo.cs b/osu.Game/Beatmaps/IBeatmapSetOnlineInfo.cs similarity index 52% rename from osu.Game/Beatmaps/BeatmapSetOnlineInfo.cs rename to osu.Game/Beatmaps/IBeatmapSetOnlineInfo.cs index 3658dbab83..b9800bc2e6 100644 --- a/osu.Game/Beatmaps/BeatmapSetOnlineInfo.cs +++ b/osu.Game/Beatmaps/IBeatmapSetOnlineInfo.cs @@ -1,139 +1,101 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// 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 Newtonsoft.Json; + +#nullable enable namespace osu.Game.Beatmaps { /// /// Beatmap set info retrieved for previewing locally without having the set downloaded. /// - public class BeatmapSetOnlineInfo + public interface IBeatmapSetOnlineInfo { /// /// The date this beatmap set was submitted to the online listing. /// - public DateTimeOffset Submitted { get; set; } + DateTimeOffset Submitted { get; set; } /// /// The date this beatmap set was ranked. /// - public DateTimeOffset? Ranked { get; set; } + DateTimeOffset? Ranked { get; set; } /// /// The date this beatmap set was last updated. /// - public DateTimeOffset? LastUpdated { get; set; } + DateTimeOffset? LastUpdated { get; set; } /// /// The status of this beatmap set. /// - public BeatmapSetOnlineStatus Status { get; set; } + BeatmapSetOnlineStatus Status { get; set; } /// /// Whether or not this beatmap set has explicit content. /// - public bool HasExplicitContent { get; set; } + bool HasExplicitContent { get; set; } /// /// Whether or not this beatmap set has a background video. /// - public bool HasVideo { get; set; } + bool HasVideo { get; set; } /// /// Whether or not this beatmap set has a storyboard. /// - public bool HasStoryboard { get; set; } + bool HasStoryboard { get; set; } /// /// The different sizes of cover art for this beatmap set. /// - public BeatmapSetOnlineCovers Covers { get; set; } + BeatmapSetOnlineCovers Covers { get; set; } /// /// A small sample clip of this beatmap set's song. /// - public string Preview { get; set; } + string Preview { get; set; } /// /// The beats per minute of this beatmap set's song. /// - public double BPM { get; set; } + double BPM { get; set; } /// /// The amount of plays this beatmap set has. /// - public int PlayCount { get; set; } + int PlayCount { get; set; } /// /// The amount of people who have favourited this beatmap set. /// - public int FavouriteCount { get; set; } + int FavouriteCount { get; set; } /// /// Whether this beatmap set has been favourited by the current user. /// - public bool HasFavourited { get; set; } + bool HasFavourited { get; set; } /// /// The availability of this beatmap set. /// - public BeatmapSetOnlineAvailability Availability { get; set; } + BeatmapSetOnlineAvailability Availability { get; set; } /// /// The song genre of this beatmap set. /// - public BeatmapSetOnlineGenre Genre { get; set; } + BeatmapSetOnlineGenre Genre { get; set; } /// /// The song language of this beatmap set. /// - public BeatmapSetOnlineLanguage Language { get; set; } + BeatmapSetOnlineLanguage Language { get; set; } /// /// The track ID of this beatmap set. /// Non-null only if the track is linked to a featured artist track entry. /// - public int? TrackId { get; set; } - } - - public class BeatmapSetOnlineGenre - { - public int Id { get; set; } - public string Name { get; set; } - } - - public class BeatmapSetOnlineLanguage - { - public int Id { get; set; } - public string Name { get; set; } - } - - public class BeatmapSetOnlineCovers - { - public string CoverLowRes { get; set; } - - [JsonProperty(@"cover@2x")] - public string Cover { get; set; } - - public string CardLowRes { get; set; } - - [JsonProperty(@"card@2x")] - public string Card { get; set; } - - public string ListLowRes { get; set; } - - [JsonProperty(@"list@2x")] - public string List { get; set; } - } - - public class BeatmapSetOnlineAvailability - { - [JsonProperty(@"download_disabled")] - public bool DownloadDisabled { get; set; } - - [JsonProperty(@"more_information")] - public string ExternalLink { get; set; } + int? TrackId { get; set; } } } diff --git a/osu.Game/Collections/CollectionFilterDropdown.cs b/osu.Game/Collections/CollectionFilterDropdown.cs index 1eceb56e33..7067f82fd3 100644 --- a/osu.Game/Collections/CollectionFilterDropdown.cs +++ b/osu.Game/Collections/CollectionFilterDropdown.cs @@ -181,7 +181,11 @@ namespace osu.Game.Collections MaxHeight = 200; } - protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownMenuItem(item); + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new CollectionDropdownMenuItem(item) + { + BackgroundColourHover = HoverColour, + BackgroundColourSelected = SelectionColour + }; } protected class CollectionDropdownMenuItem : OsuDropdownMenu.DrawableOsuDropdownMenuItem diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 9b0d7f51da..acb4a9ca02 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -6,10 +6,13 @@ using System.Diagnostics; using osu.Framework.Configuration; using osu.Framework.Configuration.Tracking; using osu.Framework.Extensions; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Localisation; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Input; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osu.Game.Overlays; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Select; @@ -185,20 +188,52 @@ namespace osu.Game.Configuration return new TrackedSettings { - new TrackedSetting(OsuSetting.MouseDisableButtons, v => new SettingDescription(!v, "gameplay mouse buttons", v ? "disabled" : "enabled", LookupKeyBindings(GlobalAction.ToggleGameplayMouseButtons))), - new TrackedSetting(OsuSetting.HUDVisibilityMode, m => new SettingDescription(m, "HUD Visibility", m.GetDescription(), $"cycle: {LookupKeyBindings(GlobalAction.ToggleInGameInterface)} quick view: {LookupKeyBindings(GlobalAction.HoldForHUD)}")), - new TrackedSetting(OsuSetting.Scaling, m => new SettingDescription(m, "scaling", m.GetDescription())), - new TrackedSetting(OsuSetting.Skin, m => + new TrackedSetting(OsuSetting.MouseDisableButtons, disabledState => new SettingDescription( + rawValue: !disabledState, + name: GlobalActionKeyBindingStrings.ToggleGameplayMouseButtons, + value: disabledState ? CommonStrings.Disabled.ToLower() : CommonStrings.Enabled.ToLower(), + shortcut: LookupKeyBindings(GlobalAction.ToggleGameplayMouseButtons)) + ), + new TrackedSetting(OsuSetting.HUDVisibilityMode, visibilityMode => new SettingDescription( + rawValue: visibilityMode, + name: GameplaySettingsStrings.HUDVisibilityMode, + value: visibilityMode.GetLocalisableDescription(), + shortcut: new TranslatableString(@"_", @"{0}: {1} {2}: {3}", + GlobalActionKeyBindingStrings.ToggleInGameInterface, + LookupKeyBindings(GlobalAction.ToggleInGameInterface), + GlobalActionKeyBindingStrings.HoldForHUD, + LookupKeyBindings(GlobalAction.HoldForHUD))) + ), + new TrackedSetting(OsuSetting.Scaling, scalingMode => new SettingDescription( + rawValue: scalingMode, + name: GraphicsSettingsStrings.ScreenScaling, + value: scalingMode.GetLocalisableDescription() + ) + ), + new TrackedSetting(OsuSetting.Skin, skin => { - string skinName = LookupSkinName(m) ?? string.Empty; - return new SettingDescription(skinName, "skin", skinName, $"random: {LookupKeyBindings(GlobalAction.RandomSkin)}"); - }) + string skinName = LookupSkinName(skin) ?? string.Empty; + + return new SettingDescription( + rawValue: skinName, + name: SkinSettingsStrings.SkinSectionHeader, + value: skinName, + shortcut: $"{GlobalActionKeyBindingStrings.RandomSkin}: {LookupKeyBindings(GlobalAction.RandomSkin)}" + ); + }), + new TrackedSetting(OsuSetting.UIScale, scale => new SettingDescription( + rawValue: scale, + name: GraphicsSettingsStrings.UIScaling, + value: $"{scale:N2}x" + // TODO: implement lookup for framework platform key bindings + ) + ), }; } public Func LookupSkinName { private get; set; } - public Func LookupKeyBindings { get; set; } + public Func LookupKeyBindings { get; set; } } // IMPORTANT: These are used in user configuration files. diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index 84e33e3f36..9c777d324b 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -30,7 +30,7 @@ namespace osu.Game.Database /// /// The model type. /// The associated file join type. - public abstract class ArchiveModelManager : ICanAcceptFiles, IModelManager, IModelFileManager + public abstract class ArchiveModelManager : IModelManager, IModelFileManager where TModel : class, IHasFiles, IHasPrimaryKey, ISoftDelete where TFileModel : class, INamedFileInfo, new() { diff --git a/osu.Game/Database/IHasOnlineID.cs b/osu.Game/Database/IHasOnlineID.cs index c55c461d2d..6e2be7e1f9 100644 --- a/osu.Game/Database/IHasOnlineID.cs +++ b/osu.Game/Database/IHasOnlineID.cs @@ -8,8 +8,12 @@ namespace osu.Game.Database public interface IHasOnlineID { /// - /// The server-side ID representing this instance, if one exists. + /// The server-side ID representing this instance, if one exists. Any value 0 or less denotes a missing ID. /// - int? OnlineID { get; } + /// + /// Generally we use -1 when specifying "missing" in code, but values of 0 are also considered missing as the online source + /// is generally a MySQL autoincrement value, which can never be 0. + /// + int OnlineID { get; } } } diff --git a/osu.Game/Database/IModelImporter.cs b/osu.Game/Database/IModelImporter.cs index 479f33c3b4..5d0a044578 100644 --- a/osu.Game/Database/IModelImporter.cs +++ b/osu.Game/Database/IModelImporter.cs @@ -13,21 +13,9 @@ namespace osu.Game.Database /// A class which handles importing of associated models to the game store. /// /// The model type. - public interface IModelImporter : IPostNotifications, IPostImports + public interface IModelImporter : IPostNotifications, IPostImports, ICanAcceptFiles where TModel : class { - /// - /// Import one or more items from filesystem . - /// - /// - /// This will be treated as a low priority import if more than one path is specified; use to always import at standard priority. - /// This will post notifications tracking progress. - /// - /// One or more archive locations on disk. - Task Import(params string[] paths); - - Task Import(params ImportTask[] tasks); - Task>> Import(ProgressNotification notification, params ImportTask[] tasks); /// diff --git a/osu.Game/Database/IPostImports.cs b/osu.Game/Database/IPostImports.cs index f09285089a..b3b83f23ef 100644 --- a/osu.Game/Database/IPostImports.cs +++ b/osu.Game/Database/IPostImports.cs @@ -4,6 +4,8 @@ using System; using System.Collections.Generic; +#nullable enable + namespace osu.Game.Database { public interface IPostImports @@ -12,6 +14,6 @@ namespace osu.Game.Database /// /// Fired when the user requests to view the resulting import. /// - public Action>> PostImport { set; } + public Action>>? PostImport { set; } } } diff --git a/osu.Game/Database/RealmContextFactory.cs b/osu.Game/Database/RealmContextFactory.cs index 82d51e365e..3d0bb34dc1 100644 --- a/osu.Game/Database/RealmContextFactory.cs +++ b/osu.Game/Database/RealmContextFactory.cs @@ -2,12 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; using System.Threading; using osu.Framework.Allocation; using osu.Framework.Development; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Statistics; +using osu.Game.Models; using Realms; #nullable enable @@ -26,7 +28,12 @@ namespace osu.Game.Database /// public readonly string Filename; - private const int schema_version = 6; + /// + /// Version history: + /// 6 First tracked version (~20211018) + /// 7 Changed OnlineID fields to non-nullable to add indexing support (20211018) + /// + private const int schema_version = 7; /// /// Lock object which is held during sections, blocking context creation during blocking periods. @@ -70,6 +77,27 @@ namespace osu.Game.Database if (!Filename.EndsWith(realm_extension, StringComparison.Ordinal)) Filename += realm_extension; + + cleanupPendingDeletions(); + } + + private void cleanupPendingDeletions() + { + using (var realm = CreateContext()) + using (var transaction = realm.BeginWrite()) + { + var pendingDeleteSets = realm.All().Where(s => s.DeletePending); + + foreach (var s in pendingDeleteSets) + { + foreach (var b in s.Beatmaps) + realm.Remove(b); + + realm.Remove(s); + } + + transaction.Commit(); + } } /// @@ -120,6 +148,36 @@ namespace osu.Game.Database private void onMigration(Migration migration, ulong lastSchemaVersion) { + if (lastSchemaVersion < 7) + { + convertOnlineIDs(); + convertOnlineIDs(); + convertOnlineIDs(); + + void convertOnlineIDs() where T : RealmObject + { + var className = typeof(T).Name.Replace(@"Realm", string.Empty); + + // version was not bumped when the beatmap/ruleset models were added + // therefore we must manually check for their presence to avoid throwing on the `DynamicApi` calls. + if (!migration.OldRealm.Schema.TryFindObjectSchema(className, out _)) + return; + + var oldItems = migration.OldRealm.DynamicApi.All(className); + var newItems = migration.NewRealm.DynamicApi.All(className); + + int itemCount = newItems.Count(); + + for (int i = 0; i < itemCount; i++) + { + var oldItem = oldItems.ElementAt(i); + var newItem = newItems.ElementAt(i); + + long? nullableOnlineID = oldItem?.OnlineID; + newItem.OnlineID = (int)(nullableOnlineID ?? -1); + } + } + } } /// diff --git a/osu.Game/Graphics/OsuColour.cs b/osu.Game/Graphics/OsuColour.cs index af2bb26871..40d163635a 100644 --- a/osu.Game/Graphics/OsuColour.cs +++ b/osu.Game/Graphics/OsuColour.cs @@ -235,11 +235,18 @@ namespace osu.Game.Graphics /// public readonly Color4 Blue3 = Color4Extensions.FromHex(@"3399cc"); + public readonly Color4 Lime0 = Color4Extensions.FromHex(@"ccff99"); + /// /// Equivalent to 's . /// public readonly Color4 Lime1 = Color4Extensions.FromHex(@"b2ff66"); + /// + /// Equivalent to 's . + /// + public readonly Color4 Lime3 = Color4Extensions.FromHex(@"7fcc33"); + /// /// Equivalent to 's . /// diff --git a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs b/osu.Game/Graphics/UserInterface/FocusedTextBox.cs index ceea9620c8..88608bf43c 100644 --- a/osu.Game/Graphics/UserInterface/FocusedTextBox.cs +++ b/osu.Game/Graphics/UserInterface/FocusedTextBox.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. +#nullable enable + using osuTK.Graphics; using osu.Framework.Allocation; using osu.Framework.Input.Events; @@ -8,6 +10,7 @@ using osu.Framework.Platform; using osu.Game.Input.Bindings; using osuTK.Input; using osu.Framework.Input.Bindings; +using osu.Game.Overlays; namespace osu.Game.Graphics.UserInterface { @@ -42,13 +45,13 @@ namespace osu.Game.Graphics.UserInterface } [Resolved] - private GameHost host { get; set; } + private GameHost? host { get; set; } - [BackgroundDependencyLoader] - private void load() + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider) { - BackgroundUnfocused = new Color4(10, 10, 10, 255); - BackgroundFocused = new Color4(10, 10, 10, 255); + BackgroundUnfocused = colourProvider?.Background5 ?? new Color4(10, 10, 10, 255); + BackgroundFocused = colourProvider?.Background5 ?? new Color4(10, 10, 10, 255); } // We may not be focused yet, but we need to handle keyboard input to be able to request focus diff --git a/osu.Game/Graphics/UserInterface/OsuDropdown.cs b/osu.Game/Graphics/UserInterface/OsuDropdown.cs index 5831d9ab1f..b1d4691938 100644 --- a/osu.Game/Graphics/UserInterface/OsuDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuDropdown.cs @@ -21,44 +21,17 @@ using osuTK.Graphics; namespace osu.Game.Graphics.UserInterface { - public class OsuDropdown : Dropdown, IHasAccentColour + public class OsuDropdown : Dropdown { private const float corner_radius = 5; - private Color4 accentColour; - - public Color4 AccentColour - { - get => accentColour; - set - { - accentColour = value; - updateAccentColour(); - } - } - - [BackgroundDependencyLoader(true)] - private void load(OverlayColourProvider? colourProvider, OsuColour colours) - { - if (accentColour == default) - accentColour = colourProvider?.Light4 ?? colours.PinkDarker; - updateAccentColour(); - } - - private void updateAccentColour() - { - if (Header is IHasAccentColour header) header.AccentColour = accentColour; - - if (Menu is IHasAccentColour menu) menu.AccentColour = accentColour; - } - protected override DropdownHeader CreateHeader() => new OsuDropdownHeader(); protected override DropdownMenu CreateMenu() => new OsuDropdownMenu(); #region OsuDropdownMenu - protected class OsuDropdownMenu : DropdownMenu, IHasAccentColour + protected class OsuDropdownMenu : DropdownMenu { public override bool HandleNonPositionalInput => State == MenuState.Open; @@ -78,9 +51,11 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader(true)] - private void load(OverlayColourProvider? colourProvider, AudioManager audio) + private void load(OverlayColourProvider? colourProvider, OsuColour colours, AudioManager audio) { BackgroundColour = colourProvider?.Background5 ?? Color4.Black.Opacity(0.5f); + HoverColour = colourProvider?.Light4 ?? colours.PinkDarker; + SelectionColour = colourProvider?.Background3 ?? colours.PinkDarker.Opacity(0.5f); sampleOpen = audio.Samples.Get(@"UI/dropdown-open"); sampleClose = audio.Samples.Get(@"UI/dropdown-close"); @@ -121,57 +96,77 @@ namespace osu.Game.Graphics.UserInterface } } - private Color4 accentColour; + private Color4 hoverColour; - public Color4 AccentColour + public Color4 HoverColour { - get => accentColour; + get => hoverColour; set { - accentColour = value; - foreach (var c in Children.OfType()) - c.AccentColour = value; + hoverColour = value; + foreach (var c in Children.OfType()) + c.BackgroundColourHover = value; + } + } + + private Color4 selectionColour; + + public Color4 SelectionColour + { + get => selectionColour; + set + { + selectionColour = value; + foreach (var c in Children.OfType()) + c.BackgroundColourSelected = value; } } protected override Menu CreateSubMenu() => new OsuMenu(Direction.Vertical); - protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableOsuDropdownMenuItem(item) { AccentColour = accentColour }; + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableOsuDropdownMenuItem(item) + { + BackgroundColourHover = HoverColour, + BackgroundColourSelected = SelectionColour + }; protected override ScrollContainer CreateScrollContainer(Direction direction) => new OsuScrollContainer(direction); #region DrawableOsuDropdownMenuItem - public class DrawableOsuDropdownMenuItem : DrawableDropdownMenuItem, IHasAccentColour + public class DrawableOsuDropdownMenuItem : DrawableDropdownMenuItem { // IsHovered is used public override bool HandlePositionalInput => true; - private Color4? accentColour; - - public Color4 AccentColour + public new Color4 BackgroundColourHover { - get => accentColour ?? nonAccentSelectedColour; + get => base.BackgroundColourHover; set { - accentColour = value; + base.BackgroundColourHover = value; + updateColours(); + } + } + + public new Color4 BackgroundColourSelected + { + get => base.BackgroundColourSelected; + set + { + base.BackgroundColourSelected = value; updateColours(); } } private void updateColours() { - BackgroundColourHover = accentColour ?? nonAccentHoverColour; - BackgroundColourSelected = accentColour ?? nonAccentSelectedColour; BackgroundColour = BackgroundColourHover.Opacity(0); UpdateBackgroundColour(); UpdateForegroundColour(); } - private Color4 nonAccentHoverColour; - private Color4 nonAccentSelectedColour; - public DrawableOsuDropdownMenuItem(MenuItem item) : base(item) { @@ -182,12 +177,8 @@ namespace osu.Game.Graphics.UserInterface } [BackgroundDependencyLoader] - private void load(OsuColour colours) + private void load() { - nonAccentHoverColour = colours.PinkDarker; - nonAccentSelectedColour = Color4.Black.Opacity(0.5f); - updateColours(); - AddInternal(new HoverSounds()); } @@ -290,7 +281,7 @@ namespace osu.Game.Graphics.UserInterface #endregion - public class OsuDropdownHeader : DropdownHeader, IHasAccentColour + public class OsuDropdownHeader : DropdownHeader { protected readonly SpriteText Text; @@ -302,18 +293,6 @@ namespace osu.Game.Graphics.UserInterface protected readonly SpriteIcon Icon; - private Color4 accentColour; - - public virtual Color4 AccentColour - { - get => accentColour; - set - { - accentColour = value; - BackgroundColourHover = accentColour; - } - } - public OsuDropdownHeader() { Foreground.Padding = new MarginPadding(10); diff --git a/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs b/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs index 24b9ca8d90..68ffc6bf4e 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs @@ -11,13 +11,33 @@ using osu.Framework.Input.Events; namespace osu.Game.Graphics.UserInterface { - public class OsuTabDropdown : OsuDropdown + public class OsuTabDropdown : OsuDropdown, IHasAccentColour { + private Color4 accentColour; + + public Color4 AccentColour + { + get => accentColour; + set + { + accentColour = value; + + if (IsLoaded) + propagateAccentColour(); + } + } + public OsuTabDropdown() { RelativeSizeAxes = Axes.X; } + protected override void LoadComplete() + { + base.LoadComplete(); + propagateAccentColour(); + } + protected override DropdownMenu CreateMenu() => new OsuTabDropdownMenu(); protected override DropdownHeader CreateHeader() => new OsuTabDropdownHeader @@ -26,6 +46,18 @@ namespace osu.Game.Graphics.UserInterface Origin = Anchor.TopRight }; + private void propagateAccentColour() + { + if (Menu is OsuDropdownMenu dropdownMenu) + { + dropdownMenu.HoverColour = accentColour; + dropdownMenu.SelectionColour = accentColour.Opacity(0.5f); + } + + if (Header is OsuTabDropdownHeader tabDropdownHeader) + tabDropdownHeader.AccentColour = accentColour; + } + private class OsuTabDropdownMenu : OsuDropdownMenu { public OsuTabDropdownMenu() @@ -37,7 +69,7 @@ namespace osu.Game.Graphics.UserInterface MaxHeight = 400; } - protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableOsuTabDropdownMenuItem(item) { AccentColour = AccentColour }; + protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableOsuTabDropdownMenuItem(item); private class DrawableOsuTabDropdownMenuItem : DrawableOsuDropdownMenuItem { @@ -49,15 +81,18 @@ namespace osu.Game.Graphics.UserInterface } } - protected class OsuTabDropdownHeader : OsuDropdownHeader + protected class OsuTabDropdownHeader : OsuDropdownHeader, IHasAccentColour { - public override Color4 AccentColour + private Color4 accentColour; + + public Color4 AccentColour { - get => base.AccentColour; + get => accentColour; set { - base.AccentColour = value; - Foreground.Colour = value; + accentColour = value; + BackgroundColourHover = value; + updateColour(); } } @@ -93,15 +128,20 @@ namespace osu.Game.Graphics.UserInterface protected override bool OnHover(HoverEvent e) { - Foreground.Colour = BackgroundColour; + updateColour(); return base.OnHover(e); } protected override void OnHoverLost(HoverLostEvent e) { - Foreground.Colour = BackgroundColourHover; + updateColour(); base.OnHoverLost(e); } + + private void updateColour() + { + Foreground.Colour = IsHovered ? BackgroundColour : BackgroundColourHover; + } } } } diff --git a/osu.Game/Graphics/UserInterface/OsuTextBox.cs b/osu.Game/Graphics/UserInterface/OsuTextBox.cs index 75af9efc38..96319b9fdd 100644 --- a/osu.Game/Graphics/UserInterface/OsuTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTextBox.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. +#nullable enable + using System.Linq; using osu.Framework.Allocation; using osu.Framework.Audio; @@ -17,18 +19,13 @@ using osu.Framework.Input.Events; using osu.Framework.Utils; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Graphics.Containers; +using osu.Game.Overlays; using osuTK; namespace osu.Game.Graphics.UserInterface { public class OsuTextBox : BasicTextBox { - private readonly Sample[] textAddedSamples = new Sample[4]; - private Sample capsTextAddedSample; - private Sample textRemovedSample; - private Sample textCommittedSample; - private Sample caretMovedSample; - /// /// Whether to allow playing a different samples based on the type of character. /// If set to false, the same sample will be used for all characters. @@ -42,10 +39,17 @@ namespace osu.Game.Graphics.UserInterface protected override SpriteText CreatePlaceholder() => new OsuSpriteText { Font = OsuFont.GetFont(italics: true), - Colour = new Color4(180, 180, 180, 255), Margin = new MarginPadding { Left = 2 }, }; + private readonly Sample?[] textAddedSamples = new Sample[4]; + private Sample? capsTextAddedSample; + private Sample? textRemovedSample; + private Sample? textCommittedSample; + private Sample? caretMovedSample; + + private OsuCaret? caret; + public OsuTextBox() { Height = 40; @@ -56,12 +60,18 @@ namespace osu.Game.Graphics.UserInterface Current.DisabledChanged += disabled => { Alpha = disabled ? 0.3f : 1; }; } - [BackgroundDependencyLoader] - private void load(OsuColour colour, AudioManager audio) + [BackgroundDependencyLoader(true)] + private void load(OverlayColourProvider? colourProvider, OsuColour colour, AudioManager audio) { - BackgroundUnfocused = Color4.Black.Opacity(0.5f); - BackgroundFocused = OsuColour.Gray(0.3f).Opacity(0.8f); - BackgroundCommit = BorderColour = colour.Yellow; + BackgroundUnfocused = colourProvider?.Background5 ?? Color4.Black.Opacity(0.5f); + BackgroundFocused = colourProvider?.Background4 ?? OsuColour.Gray(0.3f).Opacity(0.8f); + BackgroundCommit = BorderColour = colourProvider?.Highlight1 ?? colour.Yellow; + selectionColour = colourProvider?.Background1 ?? new Color4(249, 90, 255, 255); + + if (caret != null) + caret.SelectionColour = selectionColour; + + Placeholder.Colour = colourProvider?.Foreground1 ?? new Color4(180, 180, 180, 255); for (int i = 0; i < textAddedSamples.Length; i++) textAddedSamples[i] = audio.Samples.Get($@"Keyboard/key-press-{1 + i}"); @@ -72,7 +82,9 @@ namespace osu.Game.Graphics.UserInterface caretMovedSample = audio.Samples.Get(@"Keyboard/key-movement"); } - protected override Color4 SelectionColour => new Color4(249, 90, 255, 255); + private Color4 selectionColour; + + protected override Color4 SelectionColour => selectionColour; protected override void OnUserTextAdded(string added) { @@ -124,7 +136,7 @@ namespace osu.Game.Graphics.UserInterface Child = new OsuSpriteText { Text = c.ToString(), Font = OsuFont.GetFont(size: CalculatedTextSize) }, }; - protected override Caret CreateCaret() => new OsuCaret + protected override Caret CreateCaret() => caret = new OsuCaret { CaretWidth = CaretWidth, SelectionColour = SelectionColour, diff --git a/osu.Game/Localisation/CommonStrings.cs b/osu.Game/Localisation/CommonStrings.cs index 33a6eb5d58..3ea337c279 100644 --- a/osu.Game/Localisation/CommonStrings.cs +++ b/osu.Game/Localisation/CommonStrings.cs @@ -24,6 +24,11 @@ namespace osu.Game.Localisation /// public static LocalisableString Enabled => new TranslatableString(getKey(@"enabled"), @"Enabled"); + /// + /// "Disabled" + /// + public static LocalisableString Disabled => new TranslatableString(getKey(@"disabled"), @"Disabled"); + /// /// "Default" /// diff --git a/osu.Game/Localisation/ResourceManagerLocalisationStore.cs b/osu.Game/Localisation/ResourceManagerLocalisationStore.cs index a35ce7a9c8..6a4e38fb38 100644 --- a/osu.Game/Localisation/ResourceManagerLocalisationStore.cs +++ b/osu.Game/Localisation/ResourceManagerLocalisationStore.cs @@ -29,6 +29,9 @@ namespace osu.Game.Localisation { var split = lookup.Split(':'); + if (split.Length < 2) + return null; + string ns = split[0]; string key = split[1]; diff --git a/osu.Game/Localisation/ToastStrings.cs b/osu.Game/Localisation/ToastStrings.cs new file mode 100644 index 0000000000..52e75425bf --- /dev/null +++ b/osu.Game/Localisation/ToastStrings.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Localisation; + +namespace osu.Game.Localisation +{ + public static class ToastStrings + { + private const string prefix = @"osu.Game.Resources.Localisation.Toast"; + + /// + /// "no key bound" + /// + public static LocalisableString NoKeyBound => new TranslatableString(getKey(@"no_key_bound"), @"no key bound"); + + /// + /// "Music Playback" + /// + public static LocalisableString MusicPlayback => new TranslatableString(getKey(@"music_playback"), @"Music Playback"); + + /// + /// "Pause track" + /// + public static LocalisableString PauseTrack => new TranslatableString(getKey(@"pause_track"), @"Pause track"); + + /// + /// "Play track" + /// + public static LocalisableString PlayTrack => new TranslatableString(getKey(@"play_track"), @"Play track"); + + /// + /// "Restart track" + /// + public static LocalisableString RestartTrack => new TranslatableString(getKey(@"restart_track"), @"Restart track"); + + private static string getKey(string key) => $@"{prefix}:{key}"; + } +} diff --git a/osu.Game/Models/RealmBeatmap.cs b/osu.Game/Models/RealmBeatmap.cs index 5049c1384d..9311425cb7 100644 --- a/osu.Game/Models/RealmBeatmap.cs +++ b/osu.Game/Models/RealmBeatmap.cs @@ -44,7 +44,8 @@ namespace osu.Game.Models [MapTo(nameof(Status))] public int StatusInt { get; set; } - public int? OnlineID { get; set; } + [Indexed] + public int OnlineID { get; set; } = -1; public double Length { get; set; } diff --git a/osu.Game/Models/RealmBeatmapSet.cs b/osu.Game/Models/RealmBeatmapSet.cs index 314ca4494b..6735510422 100644 --- a/osu.Game/Models/RealmBeatmapSet.cs +++ b/osu.Game/Models/RealmBeatmapSet.cs @@ -20,7 +20,8 @@ namespace osu.Game.Models [PrimaryKey] public Guid ID { get; set; } = Guid.NewGuid(); - public int? OnlineID { get; set; } + [Indexed] + public int OnlineID { get; set; } = -1; public DateTimeOffset DateAdded { get; set; } @@ -62,7 +63,7 @@ namespace osu.Game.Models if (IsManaged && other.IsManaged) return ID == other.ID; - if (OnlineID.HasValue && other.OnlineID.HasValue) + if (OnlineID > 0 && other.OnlineID > 0) return OnlineID == other.OnlineID; if (!string.IsNullOrEmpty(Hash) && !string.IsNullOrEmpty(other.Hash)) diff --git a/osu.Game/Models/RealmRuleset.cs b/osu.Game/Models/RealmRuleset.cs index 0dcd701ed2..5d70324713 100644 --- a/osu.Game/Models/RealmRuleset.cs +++ b/osu.Game/Models/RealmRuleset.cs @@ -18,7 +18,8 @@ namespace osu.Game.Models [PrimaryKey] public string ShortName { get; set; } = string.Empty; - public int? OnlineID { get; set; } + [Indexed] + public int OnlineID { get; set; } = -1; public string Name { get; set; } = string.Empty; @@ -29,7 +30,7 @@ namespace osu.Game.Models ShortName = shortName; Name = name; InstantiationInfo = instantiationInfo; - OnlineID = onlineID; + OnlineID = onlineID ?? -1; } [UsedImplicitly] @@ -39,7 +40,7 @@ namespace osu.Game.Models public RealmRuleset(int? onlineID, string name, string shortName, bool available) { - OnlineID = onlineID; + OnlineID = onlineID ?? -1; Name = name; ShortName = shortName; Available = available; diff --git a/osu.Game/Online/API/OAuth.cs b/osu.Game/Online/API/OAuth.cs index d79fc58d1c..1feb3076d1 100644 --- a/osu.Game/Online/API/OAuth.cs +++ b/osu.Game/Online/API/OAuth.cs @@ -39,17 +39,19 @@ namespace osu.Game.Online.API if (string.IsNullOrEmpty(username)) throw new ArgumentException("Missing username."); if (string.IsNullOrEmpty(password)) throw new ArgumentException("Missing password."); - using (var req = new AccessTokenRequestPassword(username, password) + var accessTokenRequest = new AccessTokenRequestPassword(username, password) { Url = $@"{endpoint}/oauth/token", Method = HttpMethod.Post, ClientId = clientId, ClientSecret = clientSecret - }) + }; + + using (accessTokenRequest) { try { - req.Perform(); + accessTokenRequest.Perform(); } catch (Exception ex) { @@ -60,7 +62,7 @@ namespace osu.Game.Online.API try { // attempt to decode a displayable error string. - var error = JsonConvert.DeserializeObject(req.GetResponseString() ?? string.Empty); + var error = JsonConvert.DeserializeObject(accessTokenRequest.GetResponseString() ?? string.Empty); if (error != null) throwableException = new APIException(error.UserDisplayableError, ex); } @@ -71,7 +73,7 @@ namespace osu.Game.Online.API throw throwableException; } - Token.Value = req.ResponseObject; + Token.Value = accessTokenRequest.ResponseObject; } } @@ -79,17 +81,19 @@ namespace osu.Game.Online.API { try { - using (var req = new AccessTokenRequestRefresh(refresh) + var refreshRequest = new AccessTokenRequestRefresh(refresh) { Url = $@"{endpoint}/oauth/token", Method = HttpMethod.Post, ClientId = clientId, ClientSecret = clientSecret - }) - { - req.Perform(); + }; - Token.Value = req.ResponseObject; + using (refreshRequest) + { + refreshRequest.Perform(); + + Token.Value = refreshRequest.ResponseObject; return true; } } diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs index c2a68c8ca1..42e519223b 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmap.cs @@ -6,12 +6,14 @@ using Newtonsoft.Json; using osu.Game.Beatmaps; using osu.Game.Rulesets; +#nullable enable + namespace osu.Game.Online.API.Requests.Responses { - public class APIBeatmap : BeatmapMetadata + public class APIBeatmap : BeatmapMetadata, IBeatmapInfo { [JsonProperty(@"id")] - public int OnlineBeatmapID { get; set; } + public int OnlineID { get; set; } [JsonProperty(@"beatmapset_id")] public int OnlineBeatmapSetID { get; set; } @@ -19,8 +21,11 @@ namespace osu.Game.Online.API.Requests.Responses [JsonProperty(@"status")] public BeatmapSetOnlineStatus Status { get; set; } + [JsonProperty("checksum")] + public string Checksum { get; set; } = string.Empty; + [JsonProperty(@"beatmapset")] - public APIBeatmapSet BeatmapSet { get; set; } + public APIBeatmapSet? BeatmapSet { get; set; } [JsonProperty(@"playcount")] private int playCount { get; set; } @@ -29,10 +34,10 @@ namespace osu.Game.Online.API.Requests.Responses private int passCount { get; set; } [JsonProperty(@"mode_int")] - private int ruleset { get; set; } + public int RulesetID { get; set; } [JsonProperty(@"difficulty_rating")] - private double starDifficulty { get; set; } + public double StarRating { get; set; } [JsonProperty(@"drain")] private float drainRate { get; set; } @@ -47,7 +52,7 @@ namespace osu.Game.Online.API.Requests.Responses private float overallDifficulty { get; set; } [JsonProperty(@"total_length")] - private double length { get; set; } + public double Length { get; set; } [JsonProperty(@"count_circles")] private int circleCount { get; set; } @@ -56,10 +61,10 @@ namespace osu.Game.Online.API.Requests.Responses private int sliderCount { get; set; } [JsonProperty(@"version")] - private string version { get; set; } + public string DifficultyName { get; set; } = string.Empty; [JsonProperty(@"failtimes")] - private BeatmapMetrics metrics { get; set; } + private BeatmapMetrics? metrics { get; set; } [JsonProperty(@"max_combo")] private int? maxCombo { get; set; } @@ -71,13 +76,14 @@ namespace osu.Game.Online.API.Requests.Responses return new BeatmapInfo { Metadata = set?.Metadata ?? this, - Ruleset = rulesets.GetRuleset(ruleset), - StarDifficulty = starDifficulty, - OnlineBeatmapID = OnlineBeatmapID, - Version = version, + Ruleset = rulesets.GetRuleset(RulesetID), + StarDifficulty = StarRating, + OnlineBeatmapID = OnlineID, + Version = DifficultyName, // this is actually an incorrect mapping (Length is calculated as drain length in lazer's import process, see BeatmapManager.calculateLength). - Length = TimeSpan.FromSeconds(length).TotalMilliseconds, + Length = TimeSpan.FromSeconds(Length).TotalMilliseconds, Status = Status, + MD5Hash = Checksum, BeatmapSet = set, Metrics = metrics, MaxCombo = maxCombo, @@ -97,5 +103,28 @@ namespace osu.Game.Online.API.Requests.Responses }, }; } + + #region Implementation of IBeatmapInfo + + public IBeatmapMetadataInfo Metadata => this; + + public IBeatmapDifficultyInfo Difficulty => new BeatmapDifficulty + { + DrainRate = drainRate, + CircleSize = circleSize, + ApproachRate = approachRate, + OverallDifficulty = overallDifficulty, + }; + + IBeatmapSetInfo? IBeatmapInfo.BeatmapSet => BeatmapSet; + + public string MD5Hash => Checksum; + + public IRulesetInfo Ruleset => new RulesetInfo { ID = RulesetID }; + + public double BPM => throw new NotImplementedException(); + public string Hash => throw new NotImplementedException(); + + #endregion } } diff --git a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs index 35963792d0..47f880cf54 100644 --- a/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs +++ b/osu.Game/Online/API/Requests/Responses/APIBeatmapSet.cs @@ -6,65 +6,62 @@ using System.Collections.Generic; using System.Linq; using Newtonsoft.Json; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Rulesets; +#nullable enable + namespace osu.Game.Online.API.Requests.Responses { - public class APIBeatmapSet : BeatmapMetadata // todo: this is a bit wrong... + public class APIBeatmapSet : BeatmapMetadata, IBeatmapSetOnlineInfo, IBeatmapSetInfo { [JsonProperty(@"covers")] - private BeatmapSetOnlineCovers covers { get; set; } - - private int? onlineBeatmapSetID; + public BeatmapSetOnlineCovers Covers { get; set; } [JsonProperty(@"id")] - public int? OnlineBeatmapSetID - { - get => onlineBeatmapSetID; - set => onlineBeatmapSetID = value > 0 ? value : null; - } + public int OnlineID { get; set; } [JsonProperty(@"status")] public BeatmapSetOnlineStatus Status { get; set; } [JsonProperty(@"preview_url")] - private string preview { get; set; } + public string Preview { get; set; } = string.Empty; [JsonProperty(@"has_favourited")] - private bool hasFavourited { get; set; } + public bool HasFavourited { get; set; } [JsonProperty(@"play_count")] - private int playCount { get; set; } + public int PlayCount { get; set; } [JsonProperty(@"favourite_count")] - private int favouriteCount { get; set; } + public int FavouriteCount { get; set; } [JsonProperty(@"bpm")] - private double bpm { get; set; } + public double BPM { get; set; } [JsonProperty(@"nsfw")] - private bool hasExplicitContent { get; set; } + public bool HasExplicitContent { get; set; } [JsonProperty(@"video")] - private bool hasVideo { get; set; } + public bool HasVideo { get; set; } [JsonProperty(@"storyboard")] - private bool hasStoryboard { get; set; } + public bool HasStoryboard { get; set; } [JsonProperty(@"submitted_date")] - private DateTimeOffset submitted { get; set; } + public DateTimeOffset Submitted { get; set; } [JsonProperty(@"ranked_date")] - private DateTimeOffset? ranked { get; set; } + public DateTimeOffset? Ranked { get; set; } [JsonProperty(@"last_updated")] - private DateTimeOffset lastUpdated { get; set; } + public DateTimeOffset? LastUpdated { get; set; } [JsonProperty(@"ratings")] - private int[] ratings { get; set; } + private int[] ratings { get; set; } = Array.Empty(); [JsonProperty(@"track_id")] - private int? trackId { get; set; } + public int? TrackId { get; set; } [JsonProperty(@"user_id")] private int creatorId @@ -73,48 +70,29 @@ namespace osu.Game.Online.API.Requests.Responses } [JsonProperty(@"availability")] - private BeatmapSetOnlineAvailability availability { get; set; } + public BeatmapSetOnlineAvailability Availability { get; set; } [JsonProperty(@"genre")] - private BeatmapSetOnlineGenre genre { get; set; } + public BeatmapSetOnlineGenre Genre { get; set; } [JsonProperty(@"language")] - private BeatmapSetOnlineLanguage language { get; set; } + public BeatmapSetOnlineLanguage Language { get; set; } [JsonProperty(@"beatmaps")] - private IEnumerable beatmaps { get; set; } + private IEnumerable beatmaps { get; set; } = Array.Empty(); public virtual BeatmapSetInfo ToBeatmapSet(RulesetStore rulesets) { var beatmapSet = new BeatmapSetInfo { - OnlineBeatmapSetID = OnlineBeatmapSetID, + OnlineBeatmapSetID = OnlineID, Metadata = this, Status = Status, - Metrics = ratings == null ? null : new BeatmapSetMetrics { Ratings = ratings }, - OnlineInfo = new BeatmapSetOnlineInfo - { - Covers = covers, - Preview = preview, - PlayCount = playCount, - FavouriteCount = favouriteCount, - BPM = bpm, - Status = Status, - HasExplicitContent = hasExplicitContent, - HasVideo = hasVideo, - HasStoryboard = hasStoryboard, - Submitted = submitted, - Ranked = ranked, - LastUpdated = lastUpdated, - Availability = availability, - HasFavourited = hasFavourited, - Genre = genre, - Language = language, - TrackId = trackId - }, + Metrics = new BeatmapSetMetrics { Ratings = ratings }, + OnlineInfo = this }; - beatmapSet.Beatmaps = beatmaps?.Select(b => + beatmapSet.Beatmaps = beatmaps.Select(b => { var beatmap = b.ToBeatmapInfo(rulesets); beatmap.BeatmapSet = beatmapSet; @@ -124,5 +102,19 @@ namespace osu.Game.Online.API.Requests.Responses return beatmapSet; } + + #region Implementation of IBeatmapSetInfo + + IEnumerable IBeatmapSetInfo.Beatmaps => beatmaps; + + IBeatmapMetadataInfo IBeatmapSetInfo.Metadata => this; + + DateTimeOffset IBeatmapSetInfo.DateAdded => throw new NotImplementedException(); + IEnumerable IBeatmapSetInfo.Files => throw new NotImplementedException(); + double IBeatmapSetInfo.MaxStarDifficulty => throw new NotImplementedException(); + double IBeatmapSetInfo.MaxLength => throw new NotImplementedException(); + double IBeatmapSetInfo.MaxBPM => throw new NotImplementedException(); + + #endregion } } diff --git a/osu.Game/Online/Rooms/APIPlaylistBeatmap.cs b/osu.Game/Online/Rooms/APIPlaylistBeatmap.cs deleted file mode 100644 index 00623282d3..0000000000 --- a/osu.Game/Online/Rooms/APIPlaylistBeatmap.cs +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using Newtonsoft.Json; -using osu.Game.Beatmaps; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets; - -namespace osu.Game.Online.Rooms -{ - public class APIPlaylistBeatmap : APIBeatmap - { - [JsonProperty("checksum")] - public string Checksum { get; set; } - - public override BeatmapInfo ToBeatmapInfo(RulesetStore rulesets) - { - var b = base.ToBeatmapInfo(rulesets); - b.MD5Hash = Checksum; - return b; - } - } -} diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs index 86879ba245..52aa115083 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs @@ -59,7 +59,7 @@ namespace osu.Game.Online.Rooms protected override bool VerifyDatabasedModel(BeatmapSetInfo databasedSet) { - int? beatmapId = SelectedItem.Value?.Beatmap.Value.OnlineBeatmapID; + int beatmapId = SelectedItem.Value?.Beatmap.Value.OnlineID ?? -1; string checksum = SelectedItem.Value?.Beatmap.Value.MD5Hash; var matchingBeatmap = databasedSet.Beatmaps.FirstOrDefault(b => b.OnlineBeatmapID == beatmapId && b.MD5Hash == checksum); @@ -75,10 +75,10 @@ namespace osu.Game.Online.Rooms protected override bool IsModelAvailableLocally() { - int? beatmapId = SelectedItem.Value.Beatmap.Value.OnlineBeatmapID; + int onlineId = SelectedItem.Value.Beatmap.Value.OnlineID; string checksum = SelectedItem.Value.Beatmap.Value.MD5Hash; - var beatmap = Manager.QueryBeatmap(b => b.OnlineBeatmapID == beatmapId && b.MD5Hash == checksum); + var beatmap = Manager.QueryBeatmap(b => b.OnlineBeatmapID == onlineId && b.MD5Hash == checksum); return beatmap?.BeatmapSet.DeletePending == false; } diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index 48f1347fa1..7fcce1514d 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -7,6 +7,7 @@ using Newtonsoft.Json; using osu.Framework.Bindables; using osu.Game.Beatmaps; using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -42,7 +43,7 @@ namespace osu.Game.Online.Rooms public readonly BindableList RequiredMods = new BindableList(); [JsonProperty("beatmap")] - private APIPlaylistBeatmap apiBeatmap { get; set; } + private APIBeatmap apiBeatmap { get; set; } private APIMod[] allowedModsBacking; diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 820597488b..2cbe05fecd 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -657,9 +657,9 @@ namespace osu.Game var combinations = KeyBindingStore.GetReadableKeyCombinationsFor(l); if (combinations.Count == 0) - return "none"; + return ToastStrings.NoKeyBound; - return string.Join(" or ", combinations); + return string.Join(" / ", combinations); }; Container logoContainer; diff --git a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs index bbde03aa10..da2dcfebdf 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingSearchControl.cs @@ -76,7 +76,7 @@ namespace osu.Game.Overlays.BeatmapListing private readonly BeatmapSearchFilterRow explicitContentFilter; private readonly Box background; - private readonly UpdateableBeatmapSetCover beatmapCover; + private readonly UpdateableOnlineBeatmapSetCover beatmapCover; public BeatmapListingSearchControl() { @@ -196,7 +196,7 @@ namespace osu.Game.Overlays.BeatmapListing } } - private class TopSearchBeatmapSetCover : UpdateableBeatmapSetCover + private class TopSearchBeatmapSetCover : UpdateableOnlineBeatmapSetCover { protected override bool TransformImmediately => true; } diff --git a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs index 9ff39ce1dd..779f3860f2 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanel.cs @@ -160,7 +160,7 @@ namespace osu.Game.Overlays.BeatmapListing.Panels return icons; } - protected Drawable CreateBackground() => new UpdateableBeatmapSetCover + protected Drawable CreateBackground() => new UpdateableOnlineBeatmapSetCover { RelativeSizeAxes = Axes.Both, BeatmapSet = SetInfo, diff --git a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs index 47b477ef9a..a8c4334ffb 100644 --- a/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs +++ b/osu.Game/Overlays/BeatmapListing/Panels/BeatmapPanelDownloadButton.cs @@ -92,7 +92,7 @@ namespace osu.Game.Overlays.BeatmapListing.Panels break; default: - if (BeatmapSet.Value?.OnlineInfo?.Availability?.DownloadDisabled ?? false) + if (BeatmapSet.Value?.OnlineInfo?.Availability.DownloadDisabled ?? false) { button.Enabled.Value = false; button.TooltipText = "this beatmap is currently not available for download."; diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs b/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs index 896c646552..f005a37eaa 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapAvailability.cs @@ -16,8 +16,8 @@ namespace osu.Game.Overlays.BeatmapSet { private BeatmapSetInfo beatmapSet; - private bool downloadDisabled => BeatmapSet?.OnlineInfo.Availability?.DownloadDisabled ?? false; - private bool hasExternalLink => !string.IsNullOrEmpty(BeatmapSet?.OnlineInfo.Availability?.ExternalLink); + private bool downloadDisabled => BeatmapSet?.OnlineInfo.Availability.DownloadDisabled ?? false; + private bool hasExternalLink => !string.IsNullOrEmpty(BeatmapSet?.OnlineInfo.Availability.ExternalLink); private readonly LinkFlowContainer textContainer; diff --git a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs index dcf06ac7fb..c1029923f7 100644 --- a/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs +++ b/osu.Game/Overlays/BeatmapSet/BeatmapSetHeaderContent.cs @@ -32,7 +32,7 @@ namespace osu.Game.Overlays.BeatmapSet public readonly Details Details; public readonly BeatmapPicker Picker; - private readonly UpdateableBeatmapSetCover cover; + private readonly UpdateableOnlineBeatmapSetCover cover; private readonly Box coverGradient; private readonly OsuSpriteText title, artist; private readonly AuthorInfo author; @@ -68,7 +68,7 @@ namespace osu.Game.Overlays.BeatmapSet RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - cover = new UpdateableBeatmapSetCover + cover = new UpdateableOnlineBeatmapSetCover { RelativeSizeAxes = Axes.Both, Masking = true, @@ -266,7 +266,7 @@ namespace osu.Game.Overlays.BeatmapSet { if (BeatmapSet.Value == null) return; - if ((BeatmapSet.Value.OnlineInfo.Availability?.DownloadDisabled ?? false) && State.Value != DownloadState.LocallyAvailable) + if (BeatmapSet.Value.OnlineInfo.Availability.DownloadDisabled && State.Value != DownloadState.LocallyAvailable) { downloadButtonsContainer.Clear(); return; diff --git a/osu.Game/Overlays/BeatmapSet/Info.cs b/osu.Game/Overlays/BeatmapSet/Info.cs index 61c660cbaa..8bc5c6d27e 100644 --- a/osu.Game/Overlays/BeatmapSet/Info.cs +++ b/osu.Game/Overlays/BeatmapSet/Info.cs @@ -117,8 +117,8 @@ namespace osu.Game.Overlays.BeatmapSet { source.Text = b.NewValue?.Metadata.Source ?? string.Empty; tags.Text = b.NewValue?.Metadata.Tags ?? string.Empty; - genre.Text = b.NewValue?.OnlineInfo?.Genre?.Name ?? string.Empty; - language.Text = b.NewValue?.OnlineInfo?.Language?.Name ?? string.Empty; + genre.Text = b.NewValue?.OnlineInfo?.Genre.Name ?? string.Empty; + language.Text = b.NewValue?.OnlineInfo?.Language.Name ?? string.Empty; var setHasLeaderboard = b.NewValue?.OnlineInfo?.Status > 0; successRate.Alpha = setHasLeaderboard ? 1 : 0; notRankedPlaceholder.Alpha = setHasLeaderboard ? 0 : 1; diff --git a/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs index 3badea155d..edc737d8fe 100644 --- a/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs +++ b/osu.Game/Overlays/Dashboard/Home/DashboardBeatmapPanel.cs @@ -77,7 +77,7 @@ namespace osu.Game.Overlays.Dashboard.Home RelativeSizeAxes = Axes.Both, Masking = true, CornerRadius = 6, - Child = new UpdateableBeatmapSetCover + Child = new UpdateableOnlineBeatmapSetCover { RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, diff --git a/osu.Game/Overlays/Login/UserDropdown.cs b/osu.Game/Overlays/Login/UserDropdown.cs index ac4e7f8eda..5c3a41aec9 100644 --- a/osu.Game/Overlays/Login/UserDropdown.cs +++ b/osu.Game/Overlays/Login/UserDropdown.cs @@ -29,12 +29,6 @@ namespace osu.Game.Overlays.Login } } - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - AccentColour = colours.Gray5; - } - protected class UserDropdownMenu : OsuDropdownMenu { public UserDropdownMenu() @@ -56,6 +50,8 @@ namespace osu.Game.Overlays.Login private void load(OsuColour colours) { BackgroundColour = colours.Gray3; + SelectionColour = colours.Gray4; + HoverColour = colours.Gray5; } protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableUserDropdownMenuItem(item); @@ -118,6 +114,7 @@ namespace osu.Game.Overlays.Login private void load(OsuColour colours) { BackgroundColour = colours.Gray3; + BackgroundColourHover = colours.Gray5; } } } diff --git a/osu.Game/Overlays/Music/CollectionDropdown.cs b/osu.Game/Overlays/Music/CollectionDropdown.cs index ed0ebf696b..658eebe67b 100644 --- a/osu.Game/Overlays/Music/CollectionDropdown.cs +++ b/osu.Game/Overlays/Music/CollectionDropdown.cs @@ -19,12 +19,6 @@ namespace osu.Game.Overlays.Music { protected override bool ShowManageCollectionsItem => false; - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - AccentColour = colours.Gray6; - } - protected override CollectionDropdownHeader CreateCollectionHeader() => new CollectionsHeader(); protected override CollectionDropdownMenu CreateCollectionMenu() => new CollectionsMenu(); @@ -41,6 +35,8 @@ namespace osu.Game.Overlays.Music private void load(OsuColour colours) { BackgroundColour = colours.Gray4; + SelectionColour = colours.Gray5; + HoverColour = colours.Gray6; } } @@ -50,6 +46,7 @@ namespace osu.Game.Overlays.Music private void load(OsuColour colours) { BackgroundColour = colours.Gray4; + BackgroundColourHover = colours.Gray6; } public CollectionsHeader() diff --git a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs index dba4bf926f..18ec69e106 100644 --- a/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs +++ b/osu.Game/Overlays/Music/MusicKeyBindingHandler.cs @@ -3,12 +3,15 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Localisation; using osu.Game.Beatmaps; using osu.Game.Configuration; using osu.Game.Input.Bindings; +using osu.Game.Localisation; using osu.Game.Overlays.OSD; namespace osu.Game.Overlays.Music @@ -39,11 +42,11 @@ namespace osu.Game.Overlays.Music bool wasPlaying = musicController.IsPlaying; if (musicController.TogglePause()) - onScreenDisplay?.Display(new MusicActionToast(wasPlaying ? "Pause track" : "Play track", e.Action)); + onScreenDisplay?.Display(new MusicActionToast(wasPlaying ? ToastStrings.PauseTrack : ToastStrings.PlayTrack, e.Action)); return true; case GlobalAction.MusicNext: - musicController.NextTrack(() => onScreenDisplay?.Display(new MusicActionToast("Next track", e.Action))); + musicController.NextTrack(() => onScreenDisplay?.Display(new MusicActionToast(GlobalActionKeyBindingStrings.MusicNext, e.Action))); return true; @@ -53,11 +56,11 @@ namespace osu.Game.Overlays.Music switch (res) { case PreviousTrackResult.Restart: - onScreenDisplay?.Display(new MusicActionToast("Restart track", e.Action)); + onScreenDisplay?.Display(new MusicActionToast(ToastStrings.RestartTrack, e.Action)); break; case PreviousTrackResult.Previous: - onScreenDisplay?.Display(new MusicActionToast("Previous track", e.Action)); + onScreenDisplay?.Display(new MusicActionToast(GlobalActionKeyBindingStrings.MusicPrev, e.Action)); break; } }); @@ -76,8 +79,8 @@ namespace osu.Game.Overlays.Music { private readonly GlobalAction action; - public MusicActionToast(string value, GlobalAction action) - : base("Music Playback", value, string.Empty) + public MusicActionToast(LocalisableString value, GlobalAction action) + : base(ToastStrings.MusicPlayback, value, string.Empty) { this.action = action; } @@ -85,7 +88,7 @@ namespace osu.Game.Overlays.Music [BackgroundDependencyLoader] private void load(OsuConfigManager config) { - ShortcutText.Text = config.LookupKeyBindings(action).ToUpperInvariant(); + ShortcutText.Text = config.LookupKeyBindings(action).ToUpper(); } } } diff --git a/osu.Game/Overlays/OSD/Toast.cs b/osu.Game/Overlays/OSD/Toast.cs index 4a6316df3f..12e30d8de2 100644 --- a/osu.Game/Overlays/OSD/Toast.cs +++ b/osu.Game/Overlays/OSD/Toast.cs @@ -1,13 +1,16 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Extensions.LocalisationExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; +using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osuTK; using osuTK.Graphics; +using osu.Game.Localisation; namespace osu.Game.Overlays.OSD { @@ -23,7 +26,7 @@ namespace osu.Game.Overlays.OSD protected readonly OsuSpriteText ShortcutText; - protected Toast(string description, string value, string shortcut) + protected Toast(LocalisableString description, LocalisableString value, LocalisableString shortcut) { Anchor = Anchor.Centre; Origin = Anchor.Centre; @@ -60,12 +63,12 @@ namespace osu.Game.Overlays.OSD Spacing = new Vector2(1, 0), Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - Text = description.ToUpperInvariant() + Text = description.ToUpper() }, ValueText = new OsuSpriteText { Font = OsuFont.GetFont(size: 24, weight: FontWeight.Light), - Padding = new MarginPadding { Left = 10, Right = 10 }, + Padding = new MarginPadding { Horizontal = 10 }, Name = "Value", Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -77,9 +80,9 @@ namespace osu.Game.Overlays.OSD Origin = Anchor.BottomCentre, Name = "Shortcut", Alpha = 0.3f, - Margin = new MarginPadding { Bottom = 15 }, + Margin = new MarginPadding { Bottom = 15, Horizontal = 10 }, Font = OsuFont.GetFont(size: 12, weight: FontWeight.Bold), - Text = string.IsNullOrEmpty(shortcut) ? "NO KEY BOUND" : shortcut.ToUpperInvariant() + Text = string.IsNullOrEmpty(shortcut.ToString()) ? ToastStrings.NoKeyBound.ToUpper() : shortcut.ToUpper() }, }; } diff --git a/osu.Game/Overlays/OSD/TrackedSettingToast.cs b/osu.Game/Overlays/OSD/TrackedSettingToast.cs index 198aa1438a..51214fe460 100644 --- a/osu.Game/Overlays/OSD/TrackedSettingToast.cs +++ b/osu.Game/Overlays/OSD/TrackedSettingToast.cs @@ -29,7 +29,7 @@ namespace osu.Game.Overlays.OSD private Sample sampleChange; public TrackedSettingToast(SettingDescription description) - : base(description.Name.ToString(), description.Value.ToString(), description.Shortcut.ToString()) + : base(description.Name, description.Value, description.Shortcut) { FillFlowContainer optionLights; diff --git a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs index 8657e356c9..c1e56facd9 100644 --- a/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Beatmaps/PaginatedBeatmapContainer.cs @@ -60,12 +60,12 @@ namespace osu.Game.Overlays.Profile.Sections.Beatmaps protected override APIRequest> CreateRequest() => new GetUserBeatmapsRequest(User.Value.Id, type, VisiblePages++, ItemsPerPage); - protected override Drawable CreateDrawableItem(APIBeatmapSet model) => !model.OnlineBeatmapSetID.HasValue - ? null - : new GridBeatmapPanel(model.ToBeatmapSet(Rulesets)) + protected override Drawable CreateDrawableItem(APIBeatmapSet model) => model.OnlineID > 0 + ? new GridBeatmapPanel(model.ToBeatmapSet(Rulesets)) { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, - }; + } + : null; } } diff --git a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs index 2c6fa76ca4..32201e36a9 100644 --- a/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs +++ b/osu.Game/Overlays/Profile/Sections/Historical/DrawableMostPlayedBeatmap.cs @@ -42,7 +42,7 @@ namespace osu.Game.Overlays.Profile.Sections.Historical { AddRangeInternal(new Drawable[] { - new UpdateableBeatmapSetCover(BeatmapSetCoverType.List) + new UpdateableOnlineBeatmapSetCover(BeatmapSetCoverType.List) { RelativeSizeAxes = Axes.Y, Width = cover_width, diff --git a/osu.Game/Overlays/Rankings/SpotlightSelector.cs b/osu.Game/Overlays/Rankings/SpotlightSelector.cs index 0f071883ca..dfa45cc543 100644 --- a/osu.Game/Overlays/Rankings/SpotlightSelector.cs +++ b/osu.Game/Overlays/Rankings/SpotlightSelector.cs @@ -175,18 +175,18 @@ namespace osu.Game.Overlays.Rankings private class SpotlightsDropdown : OsuDropdown { - private DropdownMenu menu; + private OsuDropdownMenu menu; - protected override DropdownMenu CreateMenu() => menu = base.CreateMenu().With(m => m.MaxHeight = 400); + protected override DropdownMenu CreateMenu() => menu = (OsuDropdownMenu)base.CreateMenu().With(m => m.MaxHeight = 400); protected override DropdownHeader CreateHeader() => new SpotlightsDropdownHeader(); [BackgroundDependencyLoader] private void load(OverlayColourProvider colourProvider) { - // osu-web adds a 0.6 opacity container on top of the 0.5 base one when hovering, 0.8 on a single container here matches the resulting colour - AccentColour = colourProvider.Background6.Opacity(0.8f); menu.BackgroundColour = colourProvider.Background5; + menu.HoverColour = colourProvider.Background4; + menu.SelectionColour = colourProvider.Background3; Padding = new MarginPadding { Vertical = 20 }; } @@ -205,7 +205,8 @@ namespace osu.Game.Overlays.Rankings private void load(OverlayColourProvider colourProvider) { BackgroundColour = colourProvider.Background6.Opacity(0.5f); - BackgroundColourHover = colourProvider.Background5; + // osu-web adds a 0.6 opacity container on top of the 0.5 base one when hovering, 0.8 on a single container here matches the resulting colour + BackgroundColourHover = colourProvider.Background6.Opacity(0.8f); } } } diff --git a/osu.Game/Overlays/RestoreDefaultValueButton.cs b/osu.Game/Overlays/RestoreDefaultValueButton.cs index 87a294cc10..afc4146199 100644 --- a/osu.Game/Overlays/RestoreDefaultValueButton.cs +++ b/osu.Game/Overlays/RestoreDefaultValueButton.cs @@ -3,10 +3,8 @@ using osu.Framework.Allocation; using osu.Framework.Bindables; -using osuTK.Graphics; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; -using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.UserInterface; @@ -14,6 +12,7 @@ using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.UserInterface; +using osuTK; namespace osu.Game.Overlays { @@ -45,30 +44,21 @@ namespace osu.Game.Overlays } } - private bool hovering; + [Resolved] + private OsuColour colours { get; set; } - public RestoreDefaultValueButton() - { - Height = 1; - - RelativeSizeAxes = Axes.Y; - Width = SettingsPanel.CONTENT_MARGINS; - } + private const float size = 4; [BackgroundDependencyLoader] private void load(OsuColour colour) { - BackgroundColour = colour.Yellow; - Content.Width = 0.33f; - Content.CornerRadius = 3; - Content.EdgeEffect = new EdgeEffectParameters - { - Colour = BackgroundColour.Opacity(0.1f), - Type = EdgeEffectType.Glow, - Radius = 2, - }; + BackgroundColour = colour.Lime1; + Size = new Vector2(3 * size); + + Content.RelativeSizeAxes = Axes.None; + Content.Size = new Vector2(size); + Content.CornerRadius = size / 2; - Padding = new MarginPadding { Vertical = 1.5f }; Alpha = 0f; Action += () => @@ -81,39 +71,55 @@ namespace osu.Game.Overlays protected override void LoadComplete() { base.LoadComplete(); - - // avoid unnecessary transforms on first display. - Alpha = currentAlpha; - Background.Colour = currentColour; + updateState(); + FinishTransforms(true); } public LocalisableString TooltipText => "revert to default"; protected override bool OnHover(HoverEvent e) { - hovering = true; UpdateState(); return false; } protected override void OnHoverLost(HoverLostEvent e) { - hovering = false; UpdateState(); } public void UpdateState() => Scheduler.AddOnce(updateState); - private float currentAlpha => current.IsDefault ? 0f : hovering && !current.Disabled ? 1f : 0.65f; - private ColourInfo currentColour => current.Disabled ? Color4.Gray : BackgroundColour; + private const double fade_duration = 200; private void updateState() { if (current == null) return; - this.FadeTo(currentAlpha, 200, Easing.OutQuint); - Background.FadeColour(currentColour, 200, Easing.OutQuint); + Enabled.Value = !Current.Disabled; + + if (!Current.Disabled) + { + this.FadeTo(Current.IsDefault ? 0 : 1, fade_duration, Easing.OutQuint); + Background.FadeColour(IsHovered ? colours.Lime0 : colours.Lime1, fade_duration, Easing.OutQuint); + Content.TweenEdgeEffectTo(new EdgeEffectParameters + { + Colour = (IsHovered ? colours.Lime1 : colours.Lime3).Opacity(0.4f), + Radius = IsHovered ? 8 : 4, + Type = EdgeEffectType.Glow + }, fade_duration, Easing.OutQuint); + } + else + { + Background.FadeColour(colours.Lime3, fade_duration, Easing.OutQuint); + Content.TweenEdgeEffectTo(new EdgeEffectParameters + { + Colour = colours.Lime3.Opacity(0.1f), + Radius = 2, + Type = EdgeEffectType.Glow + }, fade_duration, Easing.OutQuint); + } } } } diff --git a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs index da789db79a..f44f02d0ed 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/KeyBindingRow.cs @@ -82,60 +82,75 @@ namespace osu.Game.Overlays.Settings.Sections.Input { RelativeSizeAxes = Axes.X; AutoSizeAxes = Axes.Y; - Padding = new MarginPadding { Horizontal = SettingsPanel.CONTENT_MARGINS }; + Padding = new MarginPadding { Right = SettingsPanel.CONTENT_MARGINS }; InternalChildren = new Drawable[] { - new RestoreDefaultValueButton + new Container { - Current = isDefault, - Action = RestoreDefaults, - Origin = Anchor.TopRight, + RelativeSizeAxes = Axes.Y, + Width = SettingsPanel.CONTENT_MARGINS, + Child = new RestoreDefaultValueButton + { + Current = isDefault, + Action = RestoreDefaults, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + } }, - content = new Container + new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Masking = true, - CornerRadius = padding, - EdgeEffect = new EdgeEffectParameters - { - Radius = 2, - Colour = colourProvider.Highlight1.Opacity(0), - Type = EdgeEffectType.Shadow, - Hollow = true, - }, + Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS }, Children = new Drawable[] { - new Box + content = new Container { - RelativeSizeAxes = Axes.Both, - Colour = colourProvider.Background5, - }, - text = new OsuSpriteText - { - Text = action.GetLocalisableDescription(), - Margin = new MarginPadding(1.5f * padding), - }, - buttons = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight - }, - cancelAndClearButtons = new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Padding = new MarginPadding(padding) { Top = height + padding * 2 }, - Anchor = Anchor.TopRight, - Origin = Anchor.TopRight, - Alpha = 0, - Spacing = new Vector2(5), + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Masking = true, + CornerRadius = padding, + EdgeEffect = new EdgeEffectParameters + { + Radius = 2, + Colour = colourProvider.Highlight1.Opacity(0), + Type = EdgeEffectType.Shadow, + Hollow = true, + }, Children = new Drawable[] { - new CancelButton { Action = finalise }, - new ClearButton { Action = clear }, - }, + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background5, + }, + text = new OsuSpriteText + { + Text = action.GetLocalisableDescription(), + Margin = new MarginPadding(1.5f * padding), + }, + buttons = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight + }, + cancelAndClearButtons = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Padding = new MarginPadding(padding) { Top = height + padding * 2 }, + Anchor = Anchor.TopRight, + Origin = Anchor.TopRight, + Alpha = 0, + Spacing = new Vector2(5), + Children = new Drawable[] + { + new CancelButton { Action = finalise }, + new ClearButton { Action = clear }, + }, + } + } } } }, diff --git a/osu.Game/Overlays/Settings/SettingsDropdown.cs b/osu.Game/Overlays/Settings/SettingsDropdown.cs index a281d03ee7..1e90222d28 100644 --- a/osu.Game/Overlays/Settings/SettingsDropdown.cs +++ b/osu.Game/Overlays/Settings/SettingsDropdown.cs @@ -6,7 +6,6 @@ using System.Linq; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Game.Graphics.UserInterface; -using osuTK; namespace osu.Game.Overlays.Settings { @@ -28,11 +27,6 @@ namespace osu.Game.Overlays.Settings public override IEnumerable FilterTerms => base.FilterTerms.Concat(Control.Items.Select(i => i.ToString())); - public SettingsDropdown() - { - FlowContent.Spacing = new Vector2(0, 10); - } - protected sealed override Drawable CreateControl() => CreateDropdown(); protected virtual OsuDropdown CreateDropdown() => new DropdownControl(); diff --git a/osu.Game/Overlays/Settings/SettingsItem.cs b/osu.Game/Overlays/Settings/SettingsItem.cs index 5282217013..b593dea576 100644 --- a/osu.Game/Overlays/Settings/SettingsItem.cs +++ b/osu.Game/Overlays/Settings/SettingsItem.cs @@ -14,6 +14,7 @@ using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.Containers; +using osuTK; namespace osu.Game.Overlays.Settings { @@ -34,6 +35,7 @@ namespace osu.Game.Overlays.Settings private OsuTextFlowContainer warningText; public bool ShowsDefaultIndicator = true; + private readonly Container defaultValueIndicatorContainer; public LocalisableString TooltipText { get; set; } @@ -54,6 +56,7 @@ namespace osu.Game.Overlays.Settings } labelText.Text = value; + updateLayout(); } } @@ -108,16 +111,23 @@ namespace osu.Game.Overlays.Settings InternalChildren = new Drawable[] { - FlowContent = new FillFlowContainer + defaultValueIndicatorContainer = new Container + { + Width = SettingsPanel.CONTENT_MARGINS, + }, + new Container { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, Padding = new MarginPadding { Left = SettingsPanel.CONTENT_MARGINS }, - Children = new[] + Child = FlowContent = new FillFlowContainer { - Control = CreateControl(), - }, - }, + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Spacing = new Vector2(0, 10), + Child = Control = CreateControl(), + } + } }; // IMPORTANT: all bindable logic is in constructor intentionally to support "CreateSettingsControls" being used in a context it is @@ -135,13 +145,25 @@ namespace osu.Game.Overlays.Settings // intentionally done before LoadComplete to avoid overhead. if (ShowsDefaultIndicator) { - AddInternal(new RestoreDefaultValueButton + defaultValueIndicatorContainer.Add(new RestoreDefaultValueButton { Current = controlWithCurrent.Current, + Anchor = Anchor.Centre, + Origin = Anchor.Centre }); + updateLayout(); } } + private void updateLayout() + { + bool hasLabel = labelText != null && !string.IsNullOrEmpty(labelText.Text.ToString()); + + // if the settings item is providing a label, the default value indicator should be centred vertically to the left of the label. + // otherwise, it should be centred vertically to the left of the main control of the settings item. + defaultValueIndicatorContainer.Height = hasLabel ? labelText.DrawHeight : Control.DrawHeight; + } + private void updateDisabled() { if (labelText != null) diff --git a/osu.Game/Overlays/Settings/SettingsNumberBox.cs b/osu.Game/Overlays/Settings/SettingsNumberBox.cs index d36aa2bfc2..aca7a210b3 100644 --- a/osu.Game/Overlays/Settings/SettingsNumberBox.cs +++ b/osu.Game/Overlays/Settings/SettingsNumberBox.cs @@ -13,7 +13,6 @@ namespace osu.Game.Overlays.Settings protected override Drawable CreateControl() => new NumberControl { RelativeSizeAxes = Axes.X, - Margin = new MarginPadding { Top = 5 } }; private sealed class NumberControl : CompositeDrawable, IHasCurrentValue diff --git a/osu.Game/Overlays/Settings/SettingsSlider.cs b/osu.Game/Overlays/Settings/SettingsSlider.cs index bb9c0dd4d7..b95b0af11c 100644 --- a/osu.Game/Overlays/Settings/SettingsSlider.cs +++ b/osu.Game/Overlays/Settings/SettingsSlider.cs @@ -19,7 +19,6 @@ namespace osu.Game.Overlays.Settings { protected override Drawable CreateControl() => new TSlider { - Margin = new MarginPadding { Vertical = 10 }, RelativeSizeAxes = Axes.X }; diff --git a/osu.Game/Overlays/Settings/SettingsTextBox.cs b/osu.Game/Overlays/Settings/SettingsTextBox.cs index 68562802cf..a724003183 100644 --- a/osu.Game/Overlays/Settings/SettingsTextBox.cs +++ b/osu.Game/Overlays/Settings/SettingsTextBox.cs @@ -11,7 +11,6 @@ namespace osu.Game.Overlays.Settings { protected override Drawable CreateControl() => new OutlinedTextBox { - Margin = new MarginPadding { Top = 5 }, RelativeSizeAxes = Axes.X, CommitOnFocusLost = true }; diff --git a/osu.Game/Rulesets/RulesetInfo.cs b/osu.Game/Rulesets/RulesetInfo.cs index ca6a083a58..8cd3fa8c63 100644 --- a/osu.Game/Rulesets/RulesetInfo.cs +++ b/osu.Game/Rulesets/RulesetInfo.cs @@ -57,7 +57,7 @@ namespace osu.Game.Rulesets #region Implementation of IHasOnlineID - public int? OnlineID => ID; + public int OnlineID => ID ?? -1; #endregion } diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 8494cdcd22..a9791fba7e 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -25,7 +25,7 @@ using osu.Game.Rulesets.Scoring; namespace osu.Game.Scoring { - public class ScoreManager : IModelManager, IModelFileManager, IModelDownloader, ICanAcceptFiles + public class ScoreManager : IModelManager, IModelFileManager, IModelDownloader { private readonly Scheduler scheduler; private readonly Func difficulties; diff --git a/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs b/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs index e5a5e35897..2901758332 100644 --- a/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs +++ b/osu.Game/Screens/OnlinePlay/Components/BeatmapTitle.cs @@ -86,7 +86,7 @@ namespace osu.Game.Screens.OnlinePlay.Components Text = new RomanisableString(beatmap.Value.Metadata.TitleUnicode, beatmap.Value.Metadata.Title), Font = OsuFont.GetFont(size: TextSize), } - }, LinkAction.OpenBeatmap, beatmap.Value.OnlineBeatmapID.ToString(), "Open beatmap"); + }, LinkAction.OpenBeatmap, beatmap.Value.OnlineID.ToString(), "Open beatmap"); } } } diff --git a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs index 6ce5f6a6db..8b6077b9f2 100644 --- a/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Components/OnlinePlayBackgroundScreen.cs @@ -61,7 +61,7 @@ namespace osu.Game.Screens.OnlinePlay.Components { var beatmap = playlistItem?.Beatmap.Value; - if (background?.BeatmapInfo?.BeatmapSet?.OnlineInfo?.Covers?.Cover == beatmap?.BeatmapSet?.OnlineInfo?.Covers?.Cover) + if (background?.BeatmapInfo?.BeatmapSet?.OnlineInfo?.Covers.Cover == beatmap?.BeatmapSet?.OnlineInfo?.Covers.Cover) return; cancellationSource?.Cancel(); diff --git a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs index 08bdd0487a..62012906a7 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/LoungeSubScreen.cs @@ -17,7 +17,6 @@ using osu.Framework.Input.Events; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Framework.Threading; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Input; @@ -126,7 +125,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge { RelativeSizeAxes = Axes.X, Height = Header.HEIGHT, - Child = searchTextBox = new LoungeSearchTextBox + Child = searchTextBox = new SearchTextBox { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, @@ -362,15 +361,5 @@ namespace osu.Game.Screens.OnlinePlay.Lounge protected abstract RoomSubScreen CreateRoomSubScreen(Room room); protected abstract ListingPollingComponent CreatePollingComponent(); - - private class LoungeSearchTextBox : SearchTextBox - { - [BackgroundDependencyLoader] - private void load() - { - BackgroundUnfocused = OsuColour.Gray(0.06f); - BackgroundFocused = OsuColour.Gray(0.12f); - } - } } } diff --git a/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs index a6cdde14f6..6d14b95aec 100644 --- a/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Match/Components/RoomSettingsOverlay.cs @@ -12,7 +12,6 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Input.Bindings; using osu.Game.Online.Rooms; using osuTK; -using osuTK.Graphics; namespace osu.Game.Screens.OnlinePlay.Match.Components { @@ -91,31 +90,6 @@ namespace osu.Game.Screens.OnlinePlay.Match.Components { } - protected class SettingsTextBox : OsuTextBox - { - [BackgroundDependencyLoader] - private void load() - { - BackgroundUnfocused = Color4.Black; - BackgroundFocused = Color4.Black; - } - } - - protected class SettingsNumberTextBox : SettingsTextBox - { - protected override bool CanAddCharacter(char character) => char.IsNumber(character); - } - - protected class SettingsPasswordTextBox : OsuPasswordTextBox - { - [BackgroundDependencyLoader] - private void load() - { - BackgroundUnfocused = Color4.Black; - BackgroundFocused = Color4.Black; - } - } - protected class SectionContainer : FillFlowContainer
{ public SectionContainer() diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 0edf5dde6d..5bc76a10bc 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -153,7 +153,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { new Section("Room name") { - Child = NameField = new SettingsTextBox + Child = NameField = new OsuTextBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, @@ -202,7 +202,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match new Section("Max participants") { Alpha = disabled_alpha, - Child = MaxParticipantsField = new SettingsNumberTextBox + Child = MaxParticipantsField = new OsuNumberBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, @@ -211,7 +211,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match }, new Section("Password (optional)") { - Child = PasswordTextBox = new SettingsPasswordTextBox + Child = PasswordTextBox = new OsuPasswordTextBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index 6f8c735b6e..79e305b765 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -15,6 +15,7 @@ using osu.Framework.Graphics.UserInterface; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Rulesets; @@ -190,6 +191,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants userStateDisplay.UpdateStatus(User.State, User.BeatmapAvailability); + if ((User.BeatmapAvailability.State == DownloadState.LocallyAvailable) && (User.State != MultiplayerUserState.Spectating)) + userModsDisplay.FadeIn(fade_time); + else + userModsDisplay.FadeOut(fade_time); + if (Client.IsHost && !User.Equals(Client.LocalUser)) kickButton.FadeIn(fade_time); else diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs index 9e000aa712..c2bd7730e9 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs @@ -121,7 +121,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { new Section("Room name") { - Child = NameField = new SettingsTextBox + Child = NameField = new OsuTextBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, @@ -150,7 +150,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists }, new Section("Allowed attempts (across all playlist items)") { - Child = MaxAttemptsField = new SettingsNumberTextBox + Child = MaxAttemptsField = new OsuNumberBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, @@ -168,7 +168,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists new Section("Max participants") { Alpha = disabled_alpha, - Child = MaxParticipantsField = new SettingsNumberTextBox + Child = MaxParticipantsField = new OsuNumberBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, @@ -178,7 +178,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists new Section("Password (optional)") { Alpha = disabled_alpha, - Child = new SettingsPasswordTextBox + Child = new OsuPasswordTextBox { RelativeSizeAxes = Axes.X, TabbableContentContainer = this, diff --git a/osu.Game/Screens/Play/Break/BreakInfo.cs b/osu.Game/Screens/Play/Break/BreakInfo.cs index 6e129b20ea..6349ebd9a7 100644 --- a/osu.Game/Screens/Play/Break/BreakInfo.cs +++ b/osu.Game/Screens/Play/Break/BreakInfo.cs @@ -13,7 +13,9 @@ namespace osu.Game.Screens.Play.Break public class BreakInfo : Container { public PercentageBreakInfoLine AccuracyDisplay; - public BreakInfoLine RankDisplay; + + // Currently unused but may be revisited in a future design update (see https://github.com/ppy/osu/discussions/15185) + // public BreakInfoLine RankDisplay; public BreakInfoLine GradeDisplay; public BreakInfo() @@ -41,7 +43,9 @@ namespace osu.Game.Screens.Play.Break Children = new Drawable[] { AccuracyDisplay = new PercentageBreakInfoLine("Accuracy"), - RankDisplay = new BreakInfoLine("Rank"), + + // See https://github.com/ppy/osu/discussions/15185 + // RankDisplay = new BreakInfoLine("Rank"), GradeDisplay = new BreakInfoLine("Grade"), }, } diff --git a/osu.Game/Screens/Play/FailAnimation.cs b/osu.Game/Screens/Play/FailAnimation.cs index 2a1c4599d5..242d997dd7 100644 --- a/osu.Game/Screens/Play/FailAnimation.cs +++ b/osu.Game/Screens/Play/FailAnimation.cs @@ -16,6 +16,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Utils; using osu.Game.Audio.Effects; using osu.Game.Beatmaps; +using osu.Game.Configuration; using osu.Game.Rulesets.Objects.Drawables; using osuTK; using osuTK.Graphics; @@ -35,7 +36,7 @@ namespace osu.Game.Screens.Play private Container filters; - private Box failFlash; + private Box redFlashLayer; private Track track; @@ -46,6 +47,9 @@ namespace osu.Game.Screens.Play private Sample failSample; + [Resolved] + private OsuConfigManager config { get; set; } + protected override Container Content { get; } = new Container { Anchor = Anchor.Centre, @@ -77,7 +81,7 @@ namespace osu.Game.Screens.Play }, }, Content, - failFlash = new Box + redFlashLayer = new Box { Colour = Color4.Red, RelativeSizeAxes = Axes.Both, @@ -114,7 +118,8 @@ namespace osu.Game.Screens.Play applyToPlayfield(drawableRuleset.Playfield); drawableRuleset.Playfield.HitObjectContainer.FadeOut(duration / 2); - failFlash.FadeOutFromOne(1000); + if (config.Get(OsuSetting.FadePlayfieldWhenHealthLow)) + redFlashLayer.FadeOutFromOne(1000); Content.Masking = true; diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 5398a955b3..1381493fdf 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -220,6 +220,8 @@ namespace osu.Game.Screens.Play // ensure the score is in a consistent state with the current player. Score.ScoreInfo.BeatmapInfo = Beatmap.Value.BeatmapInfo; Score.ScoreInfo.Ruleset = ruleset.RulesetInfo; + if (ruleset.RulesetInfo.ID != null) + Score.ScoreInfo.RulesetID = ruleset.RulesetInfo.ID.Value; Score.ScoreInfo.Mods = gameplayMods; dependencies.CacheAs(GameplayState = new GameplayState(playableBeatmap, ruleset, gameplayMods, Score)); diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index cf5bff57cf..d852ac2940 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -246,6 +246,9 @@ namespace osu.Game.Screens.Play cancelLoad(); contentOut(); + // If the load sequence was interrupted, the epilepsy warning may already be displayed (or in the process of being displayed). + epilepsyWarning?.Hide(); + // Ensure the screen doesn't expire until all the outwards fade operations have completed. this.Delay(content_out_duration).FadeOut(); diff --git a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs index 216e46d429..9903a74043 100644 --- a/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs +++ b/osu.Game/Screens/Play/PlayerSettings/PlayerSliderBar.cs @@ -17,7 +17,6 @@ namespace osu.Game.Screens.Play.PlayerSettings protected override Drawable CreateControl() => new Sliderbar { - Margin = new MarginPadding { Top = 5, Bottom = 5 }, RelativeSizeAxes = Axes.X }; diff --git a/osu.Game/Stores/BeatmapImporter.cs b/osu.Game/Stores/BeatmapImporter.cs new file mode 100644 index 0000000000..254127cc7e --- /dev/null +++ b/osu.Game/Stores/BeatmapImporter.cs @@ -0,0 +1,331 @@ +// 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.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NuGet.Packaging; +using osu.Framework.Audio.Track; +using osu.Framework.Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Graphics.Textures; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Formats; +using osu.Game.Database; +using osu.Game.IO; +using osu.Game.IO.Archives; +using osu.Game.Models; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Objects; +using osu.Game.Skinning; +using Realms; + +#nullable enable + +namespace osu.Game.Stores +{ + /// + /// Handles the storage and retrieval of Beatmaps/WorkingBeatmaps. + /// + [ExcludeFromDynamicCompile] + public class BeatmapImporter : RealmArchiveModelImporter, IDisposable + { + public override IEnumerable HandledExtensions => new[] { ".osz" }; + + protected override string[] HashableFileTypes => new[] { ".osu" }; + + // protected override bool CheckLocalAvailability(RealmBeatmapSet model, System.Linq.IQueryable items) + // => base.CheckLocalAvailability(model, items) || (model.OnlineID > -1)); + + private readonly BeatmapOnlineLookupQueue? onlineLookupQueue; + + public BeatmapImporter(RealmContextFactory contextFactory, Storage storage, BeatmapOnlineLookupQueue? onlineLookupQueue = null) + : base(storage, contextFactory) + { + this.onlineLookupQueue = onlineLookupQueue; + } + + protected override bool ShouldDeleteArchive(string path) => Path.GetExtension(path).ToLowerInvariant() == ".osz"; + + protected override Task Populate(RealmBeatmapSet beatmapSet, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) + { + if (archive != null) + beatmapSet.Beatmaps.AddRange(createBeatmapDifficulties(beatmapSet.Files, realm)); + + foreach (RealmBeatmap b in beatmapSet.Beatmaps) + b.BeatmapSet = beatmapSet; + + validateOnlineIds(beatmapSet, realm); + + bool hadOnlineBeatmapIDs = beatmapSet.Beatmaps.Any(b => b.OnlineID > 0); + + if (onlineLookupQueue != null) + { + // TODO: this required `BeatmapOnlineLookupQueue` to somehow support new types. + // await onlineLookupQueue.UpdateAsync(beatmapSet, cancellationToken).ConfigureAwait(false); + } + + // ensure at least one beatmap was able to retrieve or keep an online ID, else drop the set ID. + if (hadOnlineBeatmapIDs && !beatmapSet.Beatmaps.Any(b => b.OnlineID > 0)) + { + if (beatmapSet.OnlineID > 0) + { + beatmapSet.OnlineID = -1; + LogForModel(beatmapSet, "Disassociating beatmap set ID due to loss of all beatmap IDs"); + } + } + + return Task.CompletedTask; + } + + protected override void PreImport(RealmBeatmapSet beatmapSet, Realm realm) + { + // We are about to import a new beatmap. Before doing so, ensure that no other set shares the online IDs used by the new one. + // Note that this means if the previous beatmap is restored by the user, it will no longer be linked to its online IDs. + // If this is ever an issue, we can consider marking as pending delete but not resetting the IDs (but care will be required for + // beatmaps, which don't have their own `DeletePending` state). + + if (beatmapSet.OnlineID > 0) + { + var existingSetWithSameOnlineID = realm.All().SingleOrDefault(b => b.OnlineID == beatmapSet.OnlineID); + + if (existingSetWithSameOnlineID != null) + { + existingSetWithSameOnlineID.DeletePending = true; + existingSetWithSameOnlineID.OnlineID = -1; + + foreach (var b in existingSetWithSameOnlineID.Beatmaps) + b.OnlineID = -1; + + LogForModel(beatmapSet, $"Found existing beatmap set with same OnlineID ({beatmapSet.OnlineID}). It will be deleted."); + } + } + } + + private void validateOnlineIds(RealmBeatmapSet beatmapSet, Realm realm) + { + var beatmapIds = beatmapSet.Beatmaps.Where(b => b.OnlineID > 0).Select(b => b.OnlineID).ToList(); + + // ensure all IDs are unique + if (beatmapIds.GroupBy(b => b).Any(g => g.Count() > 1)) + { + LogForModel(beatmapSet, "Found non-unique IDs, resetting..."); + resetIds(); + return; + } + + // find any existing beatmaps in the database that have matching online ids + List existingBeatmaps = new List(); + + foreach (var id in beatmapIds) + existingBeatmaps.AddRange(realm.All().Where(b => b.OnlineID == id)); + + if (existingBeatmaps.Any()) + { + // reset the import ids (to force a re-fetch) *unless* they match the candidate CheckForExisting set. + // we can ignore the case where the new ids are contained by the CheckForExisting set as it will either be used (import skipped) or deleted. + + var existing = CheckForExisting(beatmapSet, realm); + + if (existing == null || existingBeatmaps.Any(b => !existing.Beatmaps.Contains(b))) + { + LogForModel(beatmapSet, "Found existing import with online IDs already, resetting..."); + resetIds(); + } + } + + void resetIds() => beatmapSet.Beatmaps.ForEach(b => b.OnlineID = -1); + } + + protected override bool CanSkipImport(RealmBeatmapSet existing, RealmBeatmapSet import) + { + if (!base.CanSkipImport(existing, import)) + return false; + + return existing.Beatmaps.Any(b => b.OnlineID > 0); + } + + protected override bool CanReuseExisting(RealmBeatmapSet existing, RealmBeatmapSet import) + { + if (!base.CanReuseExisting(existing, import)) + return false; + + var existingIds = existing.Beatmaps.Select(b => b.OnlineID).OrderBy(i => i); + var importIds = import.Beatmaps.Select(b => b.OnlineID).OrderBy(i => i); + + // force re-import if we are not in a sane state. + return existing.OnlineID == import.OnlineID && existingIds.SequenceEqual(importIds); + } + + public override string HumanisedModelName => "beatmap"; + + protected override RealmBeatmapSet? CreateModel(ArchiveReader reader) + { + // let's make sure there are actually .osu files to import. + string? mapName = reader.Filenames.FirstOrDefault(f => f.EndsWith(".osu", StringComparison.OrdinalIgnoreCase)); + + if (string.IsNullOrEmpty(mapName)) + { + Logger.Log($"No beatmap files found in the beatmap archive ({reader.Name}).", LoggingTarget.Database); + return null; + } + + Beatmap beatmap; + using (var stream = new LineBufferedReader(reader.GetStream(mapName))) + beatmap = Decoder.GetDecoder(stream).Decode(stream); + + return new RealmBeatmapSet + { + OnlineID = beatmap.BeatmapInfo.BeatmapSet?.OnlineBeatmapSetID ?? -1, + // Metadata = beatmap.Metadata, + DateAdded = DateTimeOffset.UtcNow + }; + } + + /// + /// Create all required s for the provided archive. + /// + private List createBeatmapDifficulties(IList files, Realm realm) + { + var beatmaps = new List(); + + foreach (var file in files.Where(f => f.Filename.EndsWith(".osu", StringComparison.OrdinalIgnoreCase))) + { + using (var memoryStream = new MemoryStream(Files.Store.Get(file.File.StoragePath))) // we need a memory stream so we can seek + { + IBeatmap decoded; + using (var lineReader = new LineBufferedReader(memoryStream, true)) + decoded = Decoder.GetDecoder(lineReader).Decode(lineReader); + + string hash = memoryStream.ComputeSHA2Hash(); + + if (beatmaps.Any(b => b.Hash == hash)) + { + Logger.Log($"Skipping import of {file.Filename} due to duplicate file content.", LoggingTarget.Database); + continue; + } + + var decodedInfo = decoded.BeatmapInfo; + var decodedDifficulty = decodedInfo.BaseDifficulty; + + var ruleset = realm.All().FirstOrDefault(r => r.OnlineID == decodedInfo.RulesetID); + + if (ruleset?.Available != true) + { + Logger.Log($"Skipping import of {file.Filename} due to missing local ruleset {decodedInfo.RulesetID}.", LoggingTarget.Database); + continue; + } + + var difficulty = new RealmBeatmapDifficulty + { + DrainRate = decodedDifficulty.DrainRate, + CircleSize = decodedDifficulty.CircleSize, + OverallDifficulty = decodedDifficulty.OverallDifficulty, + ApproachRate = decodedDifficulty.ApproachRate, + SliderMultiplier = decodedDifficulty.SliderMultiplier, + SliderTickRate = decodedDifficulty.SliderTickRate, + }; + + var metadata = new RealmBeatmapMetadata + { + Title = decoded.Metadata.Title, + TitleUnicode = decoded.Metadata.TitleUnicode, + Artist = decoded.Metadata.Artist, + ArtistUnicode = decoded.Metadata.ArtistUnicode, + Author = decoded.Metadata.AuthorString, + Source = decoded.Metadata.Source, + Tags = decoded.Metadata.Tags, + PreviewTime = decoded.Metadata.PreviewTime, + AudioFile = decoded.Metadata.AudioFile, + BackgroundFile = decoded.Metadata.BackgroundFile, + }; + + var beatmap = new RealmBeatmap(ruleset, difficulty, metadata) + { + Hash = hash, + DifficultyName = decodedInfo.Version, + OnlineID = decodedInfo.OnlineBeatmapID ?? -1, + AudioLeadIn = decodedInfo.AudioLeadIn, + StackLeniency = decodedInfo.StackLeniency, + SpecialStyle = decodedInfo.SpecialStyle, + LetterboxInBreaks = decodedInfo.LetterboxInBreaks, + WidescreenStoryboard = decodedInfo.WidescreenStoryboard, + EpilepsyWarning = decodedInfo.EpilepsyWarning, + SamplesMatchPlaybackRate = decodedInfo.SamplesMatchPlaybackRate, + DistanceSpacing = decodedInfo.DistanceSpacing, + BeatDivisor = decodedInfo.BeatDivisor, + GridSize = decodedInfo.GridSize, + TimelineZoom = decodedInfo.TimelineZoom, + MD5Hash = memoryStream.ComputeMD5Hash(), + }; + + updateBeatmapStatistics(beatmap, decoded); + + beatmaps.Add(beatmap); + } + } + + return beatmaps; + } + + private void updateBeatmapStatistics(RealmBeatmap beatmap, IBeatmap decoded) + { + var rulesetInstance = ((IRulesetInfo)beatmap.Ruleset).CreateInstance(); + + if (rulesetInstance == null) + return; + + decoded.BeatmapInfo.Ruleset = rulesetInstance.RulesetInfo; + + // TODO: this should be done in a better place once we actually need to dynamically update it. + beatmap.StarRating = rulesetInstance.CreateDifficultyCalculator(new DummyConversionBeatmap(decoded)).Calculate().StarRating; + beatmap.Length = calculateLength(decoded); + beatmap.BPM = 60000 / decoded.GetMostCommonBeatLength(); + } + + private double calculateLength(IBeatmap b) + { + if (!b.HitObjects.Any()) + return 0; + + var lastObject = b.HitObjects.Last(); + + //TODO: this isn't always correct (consider mania where a non-last object may last for longer than the last in the list). + double endTime = lastObject.GetEndTime(); + double startTime = b.HitObjects.First().StartTime; + + return endTime - startTime; + } + + public void Dispose() + { + onlineLookupQueue?.Dispose(); + } + + /// + /// A dummy WorkingBeatmap for the purpose of retrieving a beatmap for star difficulty calculation. + /// + private class DummyConversionBeatmap : WorkingBeatmap + { + private readonly IBeatmap beatmap; + + public DummyConversionBeatmap(IBeatmap beatmap) + : base(beatmap.BeatmapInfo, null) + { + this.beatmap = beatmap; + } + + protected override IBeatmap GetBeatmap() => beatmap; + protected override Texture? GetBackground() => null; + protected override Track? GetBeatmapTrack() => null; + protected internal override ISkin? GetSkin() => null; + public override Stream? GetStream(string storagePath) => null; + } + } +} diff --git a/osu.Game/Stores/RealmArchiveModelImporter.cs b/osu.Game/Stores/RealmArchiveModelImporter.cs new file mode 100644 index 0000000000..ec454d25fa --- /dev/null +++ b/osu.Game/Stores/RealmArchiveModelImporter.cs @@ -0,0 +1,550 @@ +// 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.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Humanizer; +using NuGet.Packaging; +using osu.Framework.Extensions; +using osu.Framework.Extensions.IEnumerableExtensions; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Framework.Threading; +using osu.Game.Database; +using osu.Game.IO.Archives; +using osu.Game.Models; +using osu.Game.Overlays.Notifications; +using Realms; + +#nullable enable + +namespace osu.Game.Stores +{ + /// + /// Encapsulates a model store class to give it import functionality. + /// Adds cross-functionality with to give access to the central file store for the provided model. + /// + /// The model type. + public abstract class RealmArchiveModelImporter : IModelImporter + where TModel : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey, ISoftDelete + { + private const int import_queue_request_concurrency = 1; + + /// + /// The size of a batch import operation before considering it a lower priority operation. + /// + private const int low_priority_import_batch_size = 1; + + /// + /// A singleton scheduler shared by all . + /// + /// + /// This scheduler generally performs IO and CPU intensive work so concurrency is limited harshly. + /// It is mainly being used as a queue mechanism for large imports. + /// + private static readonly ThreadedTaskScheduler import_scheduler = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(RealmArchiveModelImporter)); + + /// + /// A second scheduler for lower priority imports. + /// For simplicity, these will just run in parallel with normal priority imports, but a future refactor would see this implemented via a custom scheduler/queue. + /// See https://gist.github.com/peppy/f0e118a14751fc832ca30dd48ba3876b for an incomplete version of this. + /// + private static readonly ThreadedTaskScheduler import_scheduler_low_priority = new ThreadedTaskScheduler(import_queue_request_concurrency, nameof(RealmArchiveModelImporter)); + + public virtual IEnumerable HandledExtensions => new[] { @".zip" }; + + protected readonly RealmFileStore Files; + + protected readonly RealmContextFactory ContextFactory; + + /// + /// Fired when the user requests to view the resulting import. + /// + public Action>>? PostImport { get; set; } + + /// + /// Set an endpoint for notifications to be posted to. + /// + public Action? PostNotification { protected get; set; } + + protected RealmArchiveModelImporter(Storage storage, RealmContextFactory contextFactory) + { + ContextFactory = contextFactory; + + Files = new RealmFileStore(contextFactory, storage); + } + + /// + /// Import one or more items from filesystem . + /// + /// + /// This will be treated as a low priority import if more than one path is specified; use to always import at standard priority. + /// This will post notifications tracking progress. + /// + /// One or more archive locations on disk. + public Task Import(params string[] paths) + { + var notification = new ProgressNotification { State = ProgressNotificationState.Active }; + + PostNotification?.Invoke(notification); + + return Import(notification, paths.Select(p => new ImportTask(p)).ToArray()); + } + + public Task Import(params ImportTask[] tasks) + { + var notification = new ProgressNotification { State = ProgressNotificationState.Active }; + + PostNotification?.Invoke(notification); + + return Import(notification, tasks); + } + + public async Task>> Import(ProgressNotification notification, params ImportTask[] tasks) + { + if (tasks.Length == 0) + { + notification.CompletionText = $"No {HumanisedModelName}s were found to import!"; + notification.State = ProgressNotificationState.Completed; + return Enumerable.Empty>(); + } + + notification.Progress = 0; + notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import is initialising..."; + + int current = 0; + + var imported = new List>(); + + bool isLowPriorityImport = tasks.Length > low_priority_import_batch_size; + + try + { + await Task.WhenAll(tasks.Select(async task => + { + notification.CancellationToken.ThrowIfCancellationRequested(); + + try + { + var model = await Import(task, isLowPriorityImport, notification.CancellationToken).ConfigureAwait(false); + + lock (imported) + { + if (model != null) + imported.Add(model); + current++; + + notification.Text = $"Imported {current} of {tasks.Length} {HumanisedModelName}s"; + notification.Progress = (float)current / tasks.Length; + } + } + catch (TaskCanceledException) + { + throw; + } + catch (Exception e) + { + Logger.Error(e, $@"Could not import ({task})", LoggingTarget.Database); + } + })).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + if (imported.Count == 0) + { + notification.State = ProgressNotificationState.Cancelled; + return imported; + } + } + + if (imported.Count == 0) + { + notification.Text = $"{HumanisedModelName.Humanize(LetterCasing.Title)} import failed!"; + notification.State = ProgressNotificationState.Cancelled; + } + else + { + notification.CompletionText = imported.Count == 1 + ? $"Imported {imported.First()}!" + : $"Imported {imported.Count} {HumanisedModelName}s!"; + + if (imported.Count > 0 && PostImport != null) + { + notification.CompletionText += " Click to view."; + notification.CompletionClickAction = () => + { + PostImport?.Invoke(imported); + return true; + }; + } + + notification.State = ProgressNotificationState.Completed; + } + + return imported; + } + + /// + /// Import one from the filesystem and delete the file on success. + /// Note that this bypasses the UI flow and should only be used for special cases or testing. + /// + /// The containing data about the to import. + /// Whether this is a low priority import. + /// An optional cancellation token. + /// The imported model, if successful. + public async Task?> Import(ImportTask task, bool lowPriority = false, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + ILive? import; + using (ArchiveReader reader = task.GetReader()) + import = await Import(reader, lowPriority, cancellationToken).ConfigureAwait(false); + + // We may or may not want to delete the file depending on where it is stored. + // e.g. reconstructing/repairing database with items from default storage. + // Also, not always a single file, i.e. for LegacyFilesystemReader + // TODO: Add a check to prevent files from storage to be deleted. + try + { + if (import != null && File.Exists(task.Path) && ShouldDeleteArchive(task.Path)) + File.Delete(task.Path); + } + catch (Exception e) + { + Logger.Error(e, $@"Could not delete original file after import ({task})"); + } + + return import; + } + + /// + /// Silently import an item from an . + /// + /// The archive to be imported. + /// Whether this is a low priority import. + /// An optional cancellation token. + public async Task?> Import(ArchiveReader archive, bool lowPriority = false, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + TModel? model = null; + + try + { + model = CreateModel(archive); + + if (model == null) + return null; + } + catch (TaskCanceledException) + { + throw; + } + catch (Exception e) + { + LogForModel(model, @$"Model creation of {archive.Name} failed.", e); + return null; + } + + var scheduledImport = Task.Factory.StartNew(async () => await Import(model, archive, lowPriority, cancellationToken).ConfigureAwait(false), + cancellationToken, TaskCreationOptions.HideScheduler, lowPriority ? import_scheduler_low_priority : import_scheduler).Unwrap(); + + return await scheduledImport.ConfigureAwait(true); + } + + /// + /// Any file extensions which should be included in hash creation. + /// Generally should include all file types which determine the file's uniqueness. + /// Large files should be avoided if possible. + /// + /// + /// This is only used by the default hash implementation. If is overridden, it will not be used. + /// + protected abstract string[] HashableFileTypes { get; } + + internal static void LogForModel(TModel? model, string message, Exception? e = null) + { + string trimmedHash; + if (model == null || !model.IsValid || string.IsNullOrEmpty(model.Hash)) + trimmedHash = "?????"; + else + trimmedHash = model.Hash.Substring(0, 5); + + string prefix = $"[{trimmedHash}]"; + + if (e != null) + Logger.Error(e, $"{prefix} {message}", LoggingTarget.Database); + else + Logger.Log($"{prefix} {message}", LoggingTarget.Database); + } + + /// + /// Whether the implementation overrides with a custom implementation. + /// Custom hash implementations must bypass the early exit in the import flow (see usage). + /// + protected virtual bool HasCustomHashFunction => false; + + /// + /// Create a SHA-2 hash from the provided archive based on file content of all files matching . + /// + /// + /// In the case of no matching files, a hash will be generated from the passed archive's . + /// + protected virtual string ComputeHash(TModel item, ArchiveReader? reader = null) + { + if (reader != null) + // fast hashing for cases where the item's files may not be populated. + return computeHashFast(reader); + + // for now, concatenate all hashable files in the set to create a unique hash. + MemoryStream hashable = new MemoryStream(); + + foreach (RealmNamedFileUsage file in item.Files.Where(f => HashableFileTypes.Any(ext => f.Filename.EndsWith(ext, StringComparison.OrdinalIgnoreCase))).OrderBy(f => f.Filename)) + { + using (Stream s = Files.Store.GetStream(file.File.StoragePath)) + s.CopyTo(hashable); + } + + if (hashable.Length > 0) + return hashable.ComputeSHA2Hash(); + + return item.Hash; + } + + /// + /// Silently import an item from a . + /// + /// The model to be imported. + /// An optional archive to use for model population. + /// Whether this is a low priority import. + /// An optional cancellation token. + public virtual async Task?> Import(TModel item, ArchiveReader? archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) + { + using (var realm = ContextFactory.CreateContext()) + { + cancellationToken.ThrowIfCancellationRequested(); + + bool checkedExisting = false; + TModel? existing = null; + + if (archive != null && !HasCustomHashFunction) + { + // this is a fast bail condition to improve large import performance. + item.Hash = computeHashFast(archive); + + checkedExisting = true; + existing = CheckForExisting(item, realm); + + if (existing != null) + { + // bare minimum comparisons + // + // note that this should really be checking filesizes on disk (of existing files) for some degree of sanity. + // or alternatively doing a faster hash check. either of these require database changes and reprocessing of existing files. + if (CanSkipImport(existing, item) && + getFilenames(existing.Files).SequenceEqual(getShortenedFilenames(archive).Select(p => p.shortened).OrderBy(f => f))) + { + LogForModel(item, @$"Found existing (optimised) {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); + + using (var transaction = realm.BeginWrite()) + { + existing.DeletePending = false; + transaction.Commit(); + } + + return existing.ToLive(); + } + + LogForModel(item, @"Found existing (optimised) but failed pre-check."); + } + } + + try + { + LogForModel(item, @"Beginning import..."); + + // TODO: do we want to make the transaction this local? not 100% sure, will need further investigation. + using (var transaction = realm.BeginWrite()) + { + if (archive != null) + // TODO: look into rollback of file additions (or delayed commit). + item.Files.AddRange(createFileInfos(archive, Files, realm)); + + item.Hash = ComputeHash(item, archive); + + // TODO: we may want to run this outside of the transaction. + await Populate(item, archive, realm, cancellationToken).ConfigureAwait(false); + + if (!checkedExisting) + existing = CheckForExisting(item, realm); + + if (existing != null) + { + if (CanReuseExisting(existing, item)) + { + LogForModel(item, @$"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); + existing.DeletePending = false; + + return existing.ToLive(); + } + + LogForModel(item, @"Found existing but failed re-use check."); + + existing.DeletePending = true; + + // todo: actually delete? i don't think this is required... + // ModelStore.PurgeDeletable(s => s.ID == existing.ID); + } + + PreImport(item, realm); + + // import to store + realm.Add(item); + + transaction.Commit(); + } + + LogForModel(item, @"Import successfully completed!"); + } + catch (Exception e) + { + if (!(e is TaskCanceledException)) + LogForModel(item, @"Database import or population failed and has been rolled back.", e); + + throw; + } + + return item.ToLive(); + } + } + + private string computeHashFast(ArchiveReader reader) + { + MemoryStream hashable = new MemoryStream(); + + foreach (var file in reader.Filenames.Where(f => HashableFileTypes.Any(ext => f.EndsWith(ext, StringComparison.OrdinalIgnoreCase))).OrderBy(f => f)) + { + using (Stream s = reader.GetStream(file)) + s.CopyTo(hashable); + } + + if (hashable.Length > 0) + return hashable.ComputeSHA2Hash(); + + return reader.Name.ComputeSHA2Hash(); + } + + /// + /// Create all required s for the provided archive, adding them to the global file store. + /// + private List createFileInfos(ArchiveReader reader, RealmFileStore files, Realm realm) + { + var fileInfos = new List(); + + // import files to manager + foreach (var filenames in getShortenedFilenames(reader)) + { + using (Stream s = reader.GetStream(filenames.original)) + { + var item = new RealmNamedFileUsage(files.Add(s, realm), filenames.shortened); + fileInfos.Add(item); + } + } + + return fileInfos; + } + + private IEnumerable<(string original, string shortened)> getShortenedFilenames(ArchiveReader reader) + { + string prefix = reader.Filenames.GetCommonPrefix(); + if (!(prefix.EndsWith('/') || prefix.EndsWith('\\'))) + prefix = string.Empty; + + // import files to manager + foreach (string file in reader.Filenames) + yield return (file, file.Substring(prefix.Length).ToStandardisedPath()); + } + + /// + /// Create a barebones model from the provided archive. + /// Actual expensive population should be done in ; this should just prepare for duplicate checking. + /// + /// The archive to create the model for. + /// A model populated with minimal information. Returning a null will abort importing silently. + protected abstract TModel? CreateModel(ArchiveReader archive); + + /// + /// Populate the provided model completely from the given archive. + /// After this method, the model should be in a state ready to commit to a store. + /// + /// The model to populate. + /// The archive to use as a reference for population. May be null. + /// The current realm context. + /// An optional cancellation token. + protected abstract Task Populate(TModel model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default); + + /// + /// Perform any final actions before the import to database executes. + /// + /// The model prepared for import. + /// The current realm context. + protected virtual void PreImport(TModel model, Realm realm) + { + } + + /// + /// Check whether an existing model already exists for a new import item. + /// + /// The new model proposed for import. + /// The current realm context. + /// An existing model which matches the criteria to skip importing, else null. + protected TModel? CheckForExisting(TModel model, Realm realm) => string.IsNullOrEmpty(model.Hash) ? null : realm.All().FirstOrDefault(b => b.Hash == model.Hash); + + /// + /// Whether import can be skipped after finding an existing import early in the process. + /// Only valid when is not overridden. + /// + /// The existing model. + /// The newly imported model. + /// Whether to skip this import completely. + protected virtual bool CanSkipImport(TModel existing, TModel import) => true; + + /// + /// After an existing is found during an import process, the default behaviour is to use/restore the existing + /// item and skip the import. This method allows changing that behaviour. + /// + /// The existing model. + /// The newly imported model. + /// Whether the existing model should be restored and used. Returning false will delete the existing and force a re-import. + protected virtual bool CanReuseExisting(TModel existing, TModel import) => + // for the best or worst, we copy and import files of a new import before checking whether + // it is a duplicate. so to check if anything has changed, we can just compare all File IDs. + getIDs(existing.Files).SequenceEqual(getIDs(import.Files)) && + getFilenames(existing.Files).SequenceEqual(getFilenames(import.Files)); + + /// + /// Whether this specified path should be removed after successful import. + /// + /// The path for consideration. May be a file or a directory. + /// Whether to perform deletion. + protected virtual bool ShouldDeleteArchive(string path) => false; + + private IEnumerable getIDs(IEnumerable files) + { + foreach (var f in files.OrderBy(f => f.Filename)) + yield return f.File.Hash; + } + + private IEnumerable getFilenames(IEnumerable files) + { + foreach (var f in files.OrderBy(f => f.Filename)) + yield return f.Filename; + } + + public virtual string HumanisedModelName => $"{typeof(TModel).Name.Replace(@"Info", "").ToLower()}"; + } +} diff --git a/osu.Game/Tests/Beatmaps/TestBeatmap.cs b/osu.Game/Tests/Beatmaps/TestBeatmap.cs index 2717146c99..d8e72d31a7 100644 --- a/osu.Game/Tests/Beatmaps/TestBeatmap.cs +++ b/osu.Game/Tests/Beatmaps/TestBeatmap.cs @@ -8,6 +8,7 @@ using System.Text; using osu.Framework.Extensions; using osu.Game.Beatmaps; using osu.Game.IO; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using Decoder = osu.Game.Beatmaps.Formats.Decoder; @@ -32,7 +33,7 @@ namespace osu.Game.Tests.Beatmaps BeatmapInfo.BeatmapSet.Beatmaps = new List { BeatmapInfo }; BeatmapInfo.Length = 75000; BeatmapInfo.OnlineInfo = new BeatmapOnlineInfo(); - BeatmapInfo.BeatmapSet.OnlineInfo = new BeatmapSetOnlineInfo + BeatmapInfo.BeatmapSet.OnlineInfo = new APIBeatmapSet { Status = BeatmapSetOnlineStatus.Ranked, Covers = new BeatmapSetOnlineCovers diff --git a/osu.sln.DotSettings b/osu.sln.DotSettings index e42b30e944..3af986543e 100644 --- a/osu.sln.DotSettings +++ b/osu.sln.DotSettings @@ -73,6 +73,7 @@ HINT WARNING HINT + DO_NOT_SHOW WARNING DO_NOT_SHOW WARNING