diff --git a/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs b/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs index 363a189f6e..d99bcc092d 100644 --- a/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs +++ b/osu.Game.Tests/Database/RealmSubscriptionRegistrationTests.cs @@ -1,25 +1,80 @@ // 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; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Extensions; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Rulesets; using osu.Game.Tests.Resources; using Realms; -#nullable enable - namespace osu.Game.Tests.Database { [TestFixture] public class RealmSubscriptionRegistrationTests : RealmTest { + [Test] + public void TestSubscriptionCollectionAndPropertyChanges() + { + int collectionChanges = 0; + int propertyChanges = 0; + + ChangeSet? lastChanges = null; + + RunTestWithRealm((realm, _) => + { + var registration = realm.RegisterForNotifications(r => r.All(), onChanged); + + realm.Run(r => r.Refresh()); + + realm.Write(r => r.Add(TestResources.CreateTestBeatmapSetInfo())); + realm.Run(r => r.Refresh()); + + Assert.That(collectionChanges, Is.EqualTo(1)); + Assert.That(propertyChanges, Is.EqualTo(0)); + Assert.That(lastChanges?.InsertedIndices, Has.One.Items); + Assert.That(lastChanges?.ModifiedIndices, Is.Empty); + Assert.That(lastChanges?.NewModifiedIndices, Is.Empty); + + realm.Write(r => r.All().First().Beatmaps.First().CountdownOffset = 5); + realm.Run(r => r.Refresh()); + + Assert.That(collectionChanges, Is.EqualTo(1)); + Assert.That(propertyChanges, Is.EqualTo(1)); + Assert.That(lastChanges?.InsertedIndices, Is.Empty); + Assert.That(lastChanges?.ModifiedIndices, Has.One.Items); + Assert.That(lastChanges?.NewModifiedIndices, Has.One.Items); + + registration.Dispose(); + }); + + void onChanged(IRealmCollection sender, ChangeSet? changes, Exception error) + { + lastChanges = changes; + + if (changes == null) + return; + + if (changes.HasCollectionChanges()) + { + Interlocked.Increment(ref collectionChanges); + } + else + { + Interlocked.Increment(ref propertyChanges); + } + } + } + [Test] public void TestSubscriptionWithAsyncWrite() { diff --git a/osu.Game.Tests/Online/TestSubmittableScoreJsonSerialization.cs b/osu.Game.Tests/Online/TestSubmittableScoreJsonSerialization.cs new file mode 100644 index 0000000000..662660bce4 --- /dev/null +++ b/osu.Game.Tests/Online/TestSubmittableScoreJsonSerialization.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using Newtonsoft.Json; +using NUnit.Framework; +using osu.Game.IO.Serialization; +using osu.Game.Online.Solo; +using osu.Game.Tests.Resources; + +namespace osu.Game.Tests.Online +{ + /// + /// Basic testing to ensure our attribute-based naming is correctly working. + /// + [TestFixture] + public class TestSubmittableScoreJsonSerialization + { + [Test] + public void TestScoreSerialisationViaExtensionMethod() + { + var score = new SubmittableScore(TestResources.CreateTestScoreInfo()); + + string serialised = score.Serialize(); + + Assert.That(serialised, Contains.Substring("large_tick_hit")); + Assert.That(serialised, Contains.Substring("\"rank\": \"S\"")); + } + + [Test] + public void TestScoreSerialisationWithoutSettings() + { + var score = new SubmittableScore(TestResources.CreateTestScoreInfo()); + + string serialised = JsonConvert.SerializeObject(score); + + Assert.That(serialised, Contains.Substring("large_tick_hit")); + Assert.That(serialised, Contains.Substring("\"rank\":\"S\"")); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs index 35992a0b38..c86d5e482a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs @@ -43,8 +43,6 @@ namespace osu.Game.Tests.Visual.Multiplayer Dependencies.Cache(rulesets = new RealmRulesetStore(Realm)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, rulesets, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(Realm); - - beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); } [SetUp] @@ -52,8 +50,10 @@ namespace osu.Game.Tests.Visual.Multiplayer { AvailabilityTracker.SelectedItem.BindTo(selectedItem); + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).WaitSafely(); importedSet = beatmaps.GetAllUsableBeatmapSets().First(); Beatmap.Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First()); + selectedItem.Value = new PlaylistItem(Beatmap.Value.BeatmapInfo) { RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID @@ -92,16 +92,16 @@ namespace osu.Game.Tests.Visual.Multiplayer { OsuButton readyButton = null; - AddAssert("ensure ready button enabled", () => + AddUntilStep("ensure ready button enabled", () => { readyButton = button.ChildrenOfType().Single(); return readyButton.Enabled.Value; }); AddStep("delete beatmap", () => beatmaps.Delete(importedSet)); - AddAssert("ready button disabled", () => !readyButton.Enabled.Value); + AddUntilStep("ready button disabled", () => !readyButton.Enabled.Value); AddStep("undelete beatmap", () => beatmaps.Undelete(importedSet)); - AddAssert("ready button enabled back", () => readyButton.Enabled.Value); + AddUntilStep("ready button enabled back", () => readyButton.Enabled.Value); } [Test] diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapDownloadButton.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapDownloadButton.cs index d9f01622da..8d8879490d 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapDownloadButton.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapDownloadButton.cs @@ -41,7 +41,7 @@ namespace osu.Game.Tests.Visual.Online AddUntilStep("ensure manager loaded", () => beatmaps != null); ensureSoleilyRemoved(); createButtonWithBeatmap(createSoleily()); - AddAssert("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded); + AddUntilStep("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded); AddStep("import soleily", () => beatmaps.Import(TestResources.GetQuickTestBeatmapForImport())); AddUntilStep("wait for beatmap import", () => beatmaps.GetAllUsableBeatmapSets().Any(b => b.OnlineID == 241526)); @@ -50,7 +50,7 @@ namespace osu.Game.Tests.Visual.Online createButtonWithBeatmap(createSoleily()); AddUntilStep("button state downloaded", () => downloadButton.DownloadState == DownloadState.LocallyAvailable); ensureSoleilyRemoved(); - AddAssert("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded); + AddUntilStep("button state not downloaded", () => downloadButton.DownloadState == DownloadState.NotDownloaded); } private void ensureSoleilyRemoved() diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index 161624413d..67894bab38 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -33,16 +33,25 @@ namespace osu.Game.Tests.Visual.Playlists private TestResultsScreen resultsScreen; - private int currentScoreId; + private int lowestScoreId; // Score ID of the lowest score in the list. + private int highestScoreId; // Score ID of the highest score in the list. + private bool requestComplete; private int totalCount; + private ScoreInfo userScore; [SetUp] public void Setup() => Schedule(() => { - currentScoreId = 1; + lowestScoreId = 1; + highestScoreId = 1; requestComplete = false; totalCount = 0; + + userScore = TestResources.CreateTestScoreInfo(); + userScore.TotalScore = 0; + userScore.Statistics = new Dictionary(); + bindHandler(); // beatmap is required to be an actual beatmap so the scores can get their scores correctly calculated for standardised scoring. @@ -53,15 +62,7 @@ namespace osu.Game.Tests.Visual.Playlists [Test] public void TestShowWithUserScore() { - ScoreInfo userScore = null; - - AddStep("bind user score info handler", () => - { - userScore = TestResources.CreateTestScoreInfo(); - userScore.OnlineID = currentScoreId++; - - bindHandler(userScore: userScore); - }); + AddStep("bind user score info handler", () => bindHandler(userScore: userScore)); createResults(() => userScore); @@ -81,15 +82,7 @@ namespace osu.Game.Tests.Visual.Playlists [Test] public void TestShowUserScoreWithDelay() { - ScoreInfo userScore = null; - - AddStep("bind user score info handler", () => - { - userScore = TestResources.CreateTestScoreInfo(); - userScore.OnlineID = currentScoreId++; - - bindHandler(true, userScore); - }); + AddStep("bind user score info handler", () => bindHandler(true, userScore)); createResults(() => userScore); @@ -124,7 +117,7 @@ namespace osu.Game.Tests.Visual.Playlists AddAssert("right loading spinner shown", () => resultsScreen.RightSpinner.State.Value == Visibility.Visible); waitForDisplay(); - AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); + AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() >= beforePanelCount + scores_per_result); AddAssert("right loading spinner hidden", () => resultsScreen.RightSpinner.State.Value == Visibility.Hidden); } } @@ -132,15 +125,7 @@ namespace osu.Game.Tests.Visual.Playlists [Test] public void TestFetchWhenScrolledToTheLeft() { - ScoreInfo userScore = null; - - AddStep("bind user score info handler", () => - { - userScore = TestResources.CreateTestScoreInfo(); - userScore.OnlineID = currentScoreId++; - - bindHandler(userScore: userScore); - }); + AddStep("bind user score info handler", () => bindHandler(userScore: userScore)); createResults(() => userScore); @@ -156,7 +141,7 @@ namespace osu.Game.Tests.Visual.Playlists AddAssert("left loading spinner shown", () => resultsScreen.LeftSpinner.State.Value == Visibility.Visible); waitForDisplay(); - AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() == beforePanelCount + scores_per_result); + AddAssert($"count increased by {scores_per_result}", () => this.ChildrenOfType().Count() >= beforePanelCount + scores_per_result); AddAssert("left loading spinner hidden", () => resultsScreen.LeftSpinner.State.Value == Visibility.Hidden); } } @@ -245,16 +230,13 @@ namespace osu.Game.Tests.Visual.Playlists { var multiplayerUserScore = new MultiplayerScore { - ID = (int)(userScore.OnlineID > 0 ? userScore.OnlineID : currentScoreId++), + ID = highestScoreId, Accuracy = userScore.Accuracy, - EndedAt = userScore.Date, Passed = userScore.Passed, Rank = userScore.Rank, Position = real_user_position, MaxCombo = userScore.MaxCombo, - TotalScore = userScore.TotalScore, User = userScore.User, - Statistics = userScore.Statistics, ScoresAround = new MultiplayerScoresAround { Higher = new MultiplayerScores(), @@ -268,38 +250,32 @@ namespace osu.Game.Tests.Visual.Playlists { multiplayerUserScore.ScoresAround.Lower.Scores.Add(new MultiplayerScore { - ID = currentScoreId++, + ID = --highestScoreId, Accuracy = userScore.Accuracy, - EndedAt = userScore.Date, Passed = true, Rank = userScore.Rank, MaxCombo = userScore.MaxCombo, - TotalScore = userScore.TotalScore - i, User = new APIUser { Id = 2, Username = $"peppy{i}", CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", }, - Statistics = userScore.Statistics }); multiplayerUserScore.ScoresAround.Higher.Scores.Add(new MultiplayerScore { - ID = currentScoreId++, + ID = ++lowestScoreId, Accuracy = userScore.Accuracy, - EndedAt = userScore.Date, Passed = true, Rank = userScore.Rank, MaxCombo = userScore.MaxCombo, - TotalScore = userScore.TotalScore + i, User = new APIUser { Id = 2, Username = $"peppy{i}", CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", }, - Statistics = userScore.Statistics }); totalCount += 2; @@ -315,33 +291,23 @@ namespace osu.Game.Tests.Visual.Playlists { var result = new IndexedMultiplayerScores(); - long startTotalScore = req.Cursor?.Properties["total_score"].ToObject() ?? 1000000; string sort = req.IndexParams?.Properties["sort"].ToObject() ?? "score_desc"; for (int i = 1; i <= scores_per_result; i++) { result.Scores.Add(new MultiplayerScore { - ID = currentScoreId++, + ID = sort == "score_asc" ? --highestScoreId : ++lowestScoreId, Accuracy = 1, - EndedAt = DateTimeOffset.Now, Passed = true, Rank = ScoreRank.X, MaxCombo = 1000, - TotalScore = startTotalScore + (sort == "score_asc" ? i : -i), User = new APIUser { Id = 2, Username = $"peppy{i}", CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", }, - Statistics = new Dictionary - { - { HitResult.Miss, 1 }, - { HitResult.Meh, 50 }, - { HitResult.Good, 100 }, - { HitResult.Great, 300 } - } }); totalCount++; @@ -367,7 +333,7 @@ namespace osu.Game.Tests.Visual.Playlists { Properties = new Dictionary { - { "sort", JToken.FromObject(scores.Scores[^1].TotalScore > scores.Scores[^2].TotalScore ? "score_asc" : "score_desc") } + { "sort", JToken.FromObject(scores.Scores[^1].ID > scores.Scores[^2].ID ? "score_asc" : "score_desc") } } }; } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs index e786b85f78..c65587d433 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs @@ -2,12 +2,14 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using System.Linq; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Models; +using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Ranking; using osu.Game.Tests.Resources; @@ -208,13 +210,19 @@ namespace osu.Game.Tests.Visual.Ranking public void TestKeyboardNavigation() { var lowestScore = TestResources.CreateTestScoreInfo(); - lowestScore.MaxCombo = 100; + lowestScore.OnlineID = 3; + lowestScore.TotalScore = 0; + lowestScore.Statistics = new Dictionary(); var middleScore = TestResources.CreateTestScoreInfo(); - middleScore.MaxCombo = 200; + middleScore.OnlineID = 2; + middleScore.TotalScore = 0; + middleScore.Statistics = new Dictionary(); var highestScore = TestResources.CreateTestScoreInfo(); - highestScore.MaxCombo = 300; + highestScore.OnlineID = 1; + highestScore.TotalScore = 0; + highestScore.Statistics = new Dictionary(); createListStep(() => new ScorePanelList()); diff --git a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index e2b50e38c2..c0c1e6b7a4 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -284,14 +284,13 @@ namespace osu.Game.Tests.Visual.SongSelect public void TestDummy() { createSongSelect(); - AddAssert("dummy selected", () => songSelect.CurrentBeatmap == defaultBeatmap); + AddUntilStep("dummy selected", () => songSelect.CurrentBeatmap == defaultBeatmap); AddUntilStep("dummy shown on wedge", () => songSelect.CurrentBeatmapDetailsBeatmap == defaultBeatmap); addManyTestMaps(); - AddWaitStep("wait for select", 3); - AddAssert("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap); + AddUntilStep("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap); } [Test] @@ -299,9 +298,8 @@ namespace osu.Game.Tests.Visual.SongSelect { createSongSelect(); addManyTestMaps(); - AddWaitStep("wait for add", 3); - AddAssert("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap); + AddUntilStep("random map selected", () => songSelect.CurrentBeatmap != defaultBeatmap); AddStep(@"Sort by Artist", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Artist)); AddStep(@"Sort by Title", () => config.SetValue(OsuSetting.SongSelectSortingMode, SortMode.Title)); @@ -571,6 +569,8 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); + AddUntilStep("wait for selection", () => !Beatmap.IsDefault); + AddStep("press ctrl+enter", () => { InputManager.PressKey(Key.ControlLeft); @@ -605,6 +605,8 @@ namespace osu.Game.Tests.Visual.SongSelect addRulesetImportStep(0); createSongSelect(); + AddUntilStep("wait for selection", () => !Beatmap.IsDefault); + DrawableCarouselBeatmapSet set = null; AddStep("Find the DrawableCarouselBeatmapSet", () => { @@ -844,6 +846,8 @@ namespace osu.Game.Tests.Visual.SongSelect createSongSelect(); + AddUntilStep("wait for selection", () => !Beatmap.IsDefault); + AddStep("present score", () => { // this beatmap change should be overridden by the present. diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs index 39680d157b..7bef7c8fce 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneTopLocalRank.cs @@ -124,7 +124,7 @@ namespace osu.Game.Tests.Visual.SongSelect }); AddUntilStep("Became present", () => topLocalRank.IsPresent); - AddAssert("Correct rank", () => topLocalRank.Rank == ScoreRank.B); + AddUntilStep("Correct rank", () => topLocalRank.Rank == ScoreRank.B); AddStep("Add higher score for current user", () => { @@ -137,7 +137,7 @@ namespace osu.Game.Tests.Visual.SongSelect scoreManager.Import(testScoreInfo2); }); - AddAssert("Correct rank", () => topLocalRank.Rank == ScoreRank.S); + AddUntilStep("Correct rank", () => topLocalRank.Rank == ScoreRank.S); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDifficultyMultiplierDisplay.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDifficultyMultiplierDisplay.cs new file mode 100644 index 0000000000..cd84f8b380 --- /dev/null +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDifficultyMultiplierDisplay.cs @@ -0,0 +1,40 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Game.Overlays; +using osu.Game.Overlays.Mods; + +namespace osu.Game.Tests.Visual.UserInterface +{ + [TestFixture] + public class TestSceneDifficultyMultiplierDisplay : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); + + [Test] + public void TestDifficultyMultiplierDisplay() + { + DifficultyMultiplierDisplay multiplierDisplay = null; + + AddStep("create content", () => Child = multiplierDisplay = new DifficultyMultiplierDisplay + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre + }); + + AddStep("set multiplier below 1", () => multiplierDisplay.Current.Value = 0.5); + AddStep("set multiplier to 1", () => multiplierDisplay.Current.Value = 1); + AddStep("set multiplier above 1", () => multiplierDisplay.Current.Value = 1.5); + + AddSliderStep("set multiplier", 0, 2, 1d, multiplier => + { + if (multiplierDisplay != null) + multiplierDisplay.Current.Value = multiplier; + }); + } + } +} diff --git a/osu.Game/Database/RealmAccess.cs b/osu.Game/Database/RealmAccess.cs index af7c485c57..f0d4011ab8 100644 --- a/osu.Game/Database/RealmAccess.cs +++ b/osu.Game/Database/RealmAccess.cs @@ -105,6 +105,8 @@ namespace osu.Game.Database public Realm Realm => ensureUpdateRealm(); + private const string realm_extension = @".realm"; + private Realm ensureUpdateRealm() { if (isSendingNotificationResetEvents) @@ -149,11 +151,18 @@ namespace osu.Game.Database Filename = filename; - const string realm_extension = @".realm"; - if (!Filename.EndsWith(realm_extension, StringComparison.Ordinal)) Filename += realm_extension; + string newerVersionFilename = $"{Filename.Replace(realm_extension, string.Empty)}_newer_version{realm_extension}"; + + // Attempt to recover a newer database version if available. + if (storage.Exists(newerVersionFilename)) + { + Logger.Log(@"A newer realm database has been found, attempting recovery...", LoggingTarget.Database); + attemptRecoverFromFile(newerVersionFilename); + } + try { // This method triggers the first `getRealmInstance` call, which will implicitly run realm migrations and bring the schema up-to-date. @@ -161,15 +170,78 @@ namespace osu.Game.Database } catch (Exception e) { - Logger.Error(e, "Realm startup failed with unrecoverable error; starting with a fresh database. A backup of your database has been made."); + // See https://github.com/realm/realm-core/blob/master/src%2Frealm%2Fobject-store%2Fobject_store.cpp#L1016-L1022 + // This is the best way we can detect a schema version downgrade. + if (e.Message.StartsWith(@"Provided schema version", StringComparison.Ordinal)) + { + Logger.Error(e, "Your local database is too new to work with this version of osu!. Please close osu! and install the latest release to recover your data."); - CreateBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}"); - storage.Delete(Filename); + // If a newer version database already exists, don't backup again. We can presume that the first backup is the one we care about. + if (!storage.Exists(newerVersionFilename)) + CreateBackup(newerVersionFilename); + + storage.Delete(Filename); + } + else + { + Logger.Error(e, "Realm startup failed with unrecoverable error; starting with a fresh database. A backup of your database has been made."); + CreateBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_corrupt{realm_extension}"); + storage.Delete(Filename); + } cleanupPendingDeletions(); } } + private void attemptRecoverFromFile(string recoveryFilename) + { + Logger.Log($@"Performing recovery from {recoveryFilename}", LoggingTarget.Database); + + // First check the user hasn't started to use the database that is in place.. + try + { + using (var realm = Realm.GetInstance(getConfiguration())) + { + if (realm.All().Any()) + { + Logger.Log(@"Recovery aborted as the existing database has scores set already.", LoggingTarget.Database); + Logger.Log(@"To perform recovery, delete client.realm while osu! is not running.", LoggingTarget.Database); + return; + } + } + } + catch + { + // Even if reading the in place database fails, still attempt to recover. + } + + // Then check that the database we are about to attempt recovery can actually be recovered on this version.. + try + { + using (Realm.GetInstance(getConfiguration(recoveryFilename))) + { + // Don't need to do anything, just check that opening the realm works correctly. + } + } + catch + { + Logger.Log(@"Recovery aborted as the newer version could not be loaded by this osu! version.", LoggingTarget.Database); + return; + } + + // For extra safety, also store the temporarily-used database which we are about to replace. + CreateBackup($"{Filename.Replace(realm_extension, string.Empty)}_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}_newer_version_before_recovery{realm_extension}"); + + storage.Delete(Filename); + + using (var inputStream = storage.GetStream(recoveryFilename)) + using (var outputStream = storage.GetStream(Filename, FileAccess.Write, FileMode.Create)) + inputStream.CopyTo(outputStream); + + storage.Delete(recoveryFilename); + Logger.Log(@"Recovery complete!", LoggingTarget.Database); + } + private void cleanupPendingDeletions() { using (var realm = getRealmInstance()) @@ -476,7 +548,7 @@ namespace osu.Game.Database } } - private RealmConfiguration getConfiguration() + private RealmConfiguration getConfiguration(string? filename = null) { // This is currently the only usage of temporary files at the osu! side. // If we use the temporary folder in more situations in the future, this should be moved to a higher level (helper method or OsuGameBase). @@ -484,7 +556,7 @@ namespace osu.Game.Database if (!Directory.Exists(tempPathLocation)) Directory.CreateDirectory(tempPathLocation); - return new RealmConfiguration(storage.GetFullPath(Filename, true)) + return new RealmConfiguration(storage.GetFullPath(filename ?? Filename, true)) { SchemaVersion = schema_version, MigrationCallback = onMigration, diff --git a/osu.Game/Database/RealmExtensions.cs b/osu.Game/Database/RealmExtensions.cs index e6f3dba39f..551b84f7b6 100644 --- a/osu.Game/Database/RealmExtensions.cs +++ b/osu.Game/Database/RealmExtensions.cs @@ -4,6 +4,8 @@ using System; using Realms; +#nullable enable + namespace osu.Game.Database { public static class RealmExtensions @@ -22,5 +24,14 @@ namespace osu.Game.Database transaction.Commit(); return result; } + + /// + /// Whether the provided change set has changes to the top level collection. + /// + /// + /// Realm subscriptions fire on both collection and property changes (including *all* nested properties). + /// Quite often we only care about changes at a collection level. This can be used to guard and early-return when no such changes are in a callback. + /// + public static bool HasCollectionChanges(this ChangeSet changes) => changes.InsertedIndices.Length > 0 || changes.DeletedIndices.Length > 0 || changes.Moves.Length > 0; } } diff --git a/osu.Game/Online/Solo/SubmittableScore.cs b/osu.Game/Online/Solo/SubmittableScore.cs index 4e4dae5157..9b6da1844a 100644 --- a/osu.Game/Online/Solo/SubmittableScore.cs +++ b/osu.Game/Online/Solo/SubmittableScore.cs @@ -46,9 +46,6 @@ namespace osu.Game.Online.Solo [JsonProperty("mods")] public APIMod[] Mods { get; set; } - [JsonProperty("user")] - public APIUser User { get; set; } - [JsonProperty("statistics")] public Dictionary Statistics { get; set; } @@ -67,7 +64,6 @@ namespace osu.Game.Online.Solo RulesetID = score.RulesetID; Passed = score.Passed; Mods = score.APIMods; - User = score.User; Statistics = score.Statistics; } } diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index 00dedc892b..6f07b20049 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -79,7 +79,8 @@ namespace osu.Game.Overlays.BeatmapSet.Scores var beatmapInfo = new BeatmapInfo { MaxCombo = apiBeatmap.MaxCombo, - Status = apiBeatmap.Status + Status = apiBeatmap.Status, + MD5Hash = apiBeatmap.MD5Hash }; scoreManager.OrderByTotalScoreAsync(value.Scores.Select(s => s.CreateScoreInfo(rulesets, beatmapInfo)).ToArray(), loadCancellationSource.Token) diff --git a/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs b/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs new file mode 100644 index 0000000000..4fc3a904fa --- /dev/null +++ b/osu.Game/Overlays/Mods/DifficultyMultiplierDisplay.cs @@ -0,0 +1,185 @@ +// 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.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Extensions.LocalisationExtensions; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Graphics.Sprites; +using osu.Framework.Graphics.UserInterface; +using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Mods; +using osuTK; + +namespace osu.Game.Overlays.Mods +{ + public class DifficultyMultiplierDisplay : CompositeDrawable, IHasCurrentValue + { + public Bindable Current + { + get => current.Current; + set => current.Current = value; + } + + private readonly BindableNumberWithCurrent current = new BindableNumberWithCurrent(1) + { + Precision = 0.01 + }; + + private readonly Box underlayBackground; + private readonly Box contentBackground; + private readonly FillFlowContainer multiplierFlow; + private readonly MultiplierCounter multiplierCounter; + + [Resolved] + private OsuColour colours { get; set; } + + [Resolved] + private OverlayColourProvider colourProvider { get; set; } + + private const float height = 42; + private const float multiplier_value_area_width = 56; + private const float transition_duration = 200; + + public DifficultyMultiplierDisplay() + { + Height = height; + AutoSizeAxes = Axes.X; + + InternalChild = new Container + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Masking = true, + CornerRadius = ModPanel.CORNER_RADIUS, + Shear = new Vector2(ModPanel.SHEAR_X, 0), + Children = new Drawable[] + { + underlayBackground = new Box + { + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + RelativeSizeAxes = Axes.Y, + Width = multiplier_value_area_width + ModPanel.CORNER_RADIUS + }, + new GridContainer + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + ColumnDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize), + new Dimension(GridSizeMode.Absolute, multiplier_value_area_width) + }, + Content = new[] + { + new Drawable[] + { + new Container + { + RelativeSizeAxes = Axes.Y, + AutoSizeAxes = Axes.X, + Masking = true, + CornerRadius = ModPanel.CORNER_RADIUS, + Children = new Drawable[] + { + contentBackground = new Box + { + RelativeSizeAxes = Axes.Both + }, + new OsuSpriteText + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Margin = new MarginPadding { Horizontal = 18 }, + Shear = new Vector2(-ModPanel.SHEAR_X, 0), + Text = "Difficulty Multiplier", + Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold) + } + } + }, + multiplierFlow = new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Shear = new Vector2(-ModPanel.SHEAR_X, 0), + Direction = FillDirection.Horizontal, + Spacing = new Vector2(2, 0), + Children = new Drawable[] + { + multiplierCounter = new MultiplierCounter + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Current = { BindTarget = Current } + }, + new SpriteIcon + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Icon = FontAwesome.Solid.Times, + Size = new Vector2(7), + Margin = new MarginPadding { Top = 1 } + } + } + } + } + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load() + { + contentBackground.Colour = colourProvider.Background4; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + current.BindValueChanged(_ => updateState(), true); + FinishTransforms(true); + // required to prevent the counter initially rolling up from 0 to 1 + // due to `Current.Value` having a nonstandard default value of 1. + multiplierCounter.SetCountWithoutRolling(Current.Value); + } + + private void updateState() + { + if (Current.IsDefault) + { + underlayBackground.FadeColour(colourProvider.Background3, transition_duration, Easing.OutQuint); + multiplierFlow.FadeColour(Colour4.White, transition_duration, Easing.OutQuint); + } + else + { + var backgroundColour = Current.Value < 1 + ? colours.ForModType(ModType.DifficultyReduction) + : colours.ForModType(ModType.DifficultyIncrease); + + underlayBackground.FadeColour(backgroundColour, transition_duration, Easing.OutQuint); + multiplierFlow.FadeColour(colourProvider.Background5, transition_duration, Easing.OutQuint); + } + } + + private class MultiplierCounter : RollingCounter + { + protected override double RollingDuration => 500; + + protected override LocalisableString FormatCount(double count) => count.ToLocalisableString(@"N2"); + + protected override OsuSpriteText CreateSpriteText() => new OsuSpriteText + { + Font = OsuFont.Default.With(size: 17, weight: FontWeight.SemiBold) + }; + } + } +} diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 1dfe49945f..475c4bff8d 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -134,6 +134,9 @@ namespace osu.Game.Overlays.Settings.Sections private void updateSelectedSkinFromConfig() { + if (!skinDropdown.Items.Any()) + return; + Live skin = null; if (Guid.TryParse(configBindable.Value, out var configId)) diff --git a/osu.Game/Rulesets/Scoring/HitResult.cs b/osu.Game/Rulesets/Scoring/HitResult.cs index a254f9b760..514232db69 100644 --- a/osu.Game/Rulesets/Scoring/HitResult.cs +++ b/osu.Game/Rulesets/Scoring/HitResult.cs @@ -5,6 +5,7 @@ using System; using System.ComponentModel; using System.Diagnostics; using System.Linq; +using System.Runtime.Serialization; using osu.Framework.Utils; namespace osu.Game.Rulesets.Scoring @@ -16,6 +17,7 @@ namespace osu.Game.Rulesets.Scoring /// Indicates that the object has not been judged yet. /// [Description(@"")] + [EnumMember(Value = "none")] [Order(14)] None, @@ -27,32 +29,39 @@ namespace osu.Game.Rulesets.Scoring /// "too far in the future). It should also define when a forced miss should be triggered (as a result of no user input in time). /// [Description(@"Miss")] + [EnumMember(Value = "miss")] [Order(5)] Miss, [Description(@"Meh")] + [EnumMember(Value = "meh")] [Order(4)] Meh, [Description(@"OK")] + [EnumMember(Value = "ok")] [Order(3)] Ok, [Description(@"Good")] + [EnumMember(Value = "good")] [Order(2)] Good, [Description(@"Great")] + [EnumMember(Value = "great")] [Order(1)] Great, [Description(@"Perfect")] + [EnumMember(Value = "perfect")] [Order(0)] Perfect, /// /// Indicates small tick miss. /// + [EnumMember(Value = "small_tick_miss")] [Order(11)] SmallTickMiss, @@ -60,12 +69,14 @@ namespace osu.Game.Rulesets.Scoring /// Indicates a small tick hit. /// [Description(@"S Tick")] + [EnumMember(Value = "small_tick_hit")] [Order(7)] SmallTickHit, /// /// Indicates a large tick miss. /// + [EnumMember(Value = "large_tick_miss")] [Order(10)] LargeTickMiss, @@ -73,6 +84,7 @@ namespace osu.Game.Rulesets.Scoring /// Indicates a large tick hit. /// [Description(@"L Tick")] + [EnumMember(Value = "large_tick_hit")] [Order(6)] LargeTickHit, @@ -80,6 +92,7 @@ namespace osu.Game.Rulesets.Scoring /// Indicates a small bonus. /// [Description("S Bonus")] + [EnumMember(Value = "small_bonus")] [Order(9)] SmallBonus, @@ -87,18 +100,21 @@ namespace osu.Game.Rulesets.Scoring /// Indicates a large bonus. /// [Description("L Bonus")] + [EnumMember(Value = "large_bonus")] [Order(8)] LargeBonus, /// /// Indicates a miss that should be ignored for scoring purposes. /// + [EnumMember(Value = "ignore_miss")] [Order(13)] IgnoreMiss, /// /// Indicates a hit that should be ignored for scoring purposes. /// + [EnumMember(Value = "ignore_hit")] [Order(12)] IgnoreHit, } @@ -133,6 +149,30 @@ namespace osu.Game.Rulesets.Scoring public static bool AffectsAccuracy(this HitResult result) => IsScorable(result) && !IsBonus(result); + /// + /// Whether a is a non-tick and non-bonus result. + /// + public static bool IsBasic(this HitResult result) + => IsScorable(result) && !IsTick(result) && !IsBonus(result); + + /// + /// Whether a should be counted as a tick. + /// + public static bool IsTick(this HitResult result) + { + switch (result) + { + case HitResult.LargeTickHit: + case HitResult.LargeTickMiss: + case HitResult.SmallTickHit: + case HitResult.SmallTickMiss: + return true; + + default: + return false; + } + } + /// /// Whether a should be counted as bonus score. /// diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index 963c4a77ca..87345a06fb 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -132,7 +132,7 @@ namespace osu.Game.Scoring public async Task GetTotalScoreAsync([NotNull] ScoreInfo score, ScoringMode mode = ScoringMode.Standardised, CancellationToken cancellationToken = default) { // TODO: This is required for playlist aggregate scores. They should likely not be getting here in the first place. - if (string.IsNullOrEmpty(score.BeatmapInfo.Hash)) + if (string.IsNullOrEmpty(score.BeatmapInfo.MD5Hash)) return score.TotalScore; int beatmapMaxCombo; diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index eb0addd377..8d1654eb1d 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -191,6 +191,11 @@ namespace osu.Game.Screens.Select.Leaderboards if (cancellationToken.IsCancellationRequested) return; + // This subscription may fire from changes to linked beatmaps, which we don't care about. + // It's currently not possible for a score to be modified after insertion, so we can safely ignore callbacks with only modifications. + if (changes?.HasCollectionChanges() == false) + return; + var scores = sender.AsEnumerable(); if (filterMods && !mods.Value.Any()) diff --git a/osu.Game/Users/Drawables/ClickableAvatar.cs b/osu.Game/Users/Drawables/ClickableAvatar.cs index e3cfaf1d14..34c87568a1 100644 --- a/osu.Game/Users/Drawables/ClickableAvatar.cs +++ b/osu.Game/Users/Drawables/ClickableAvatar.cs @@ -64,7 +64,7 @@ namespace osu.Game.Users.Drawables private void openProfile() { - if (user?.Id > 1) + if (user?.Id > 1 || !string.IsNullOrEmpty(user?.Username)) game?.ShowUser(user); }