diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 68f8ef51ef..3c52802cf6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,10 +77,6 @@ jobs: run: msbuild osu.Android/osu.Android.csproj /restore /p:Configuration=Debug build-only-ios: - # While this workflow technically *can* run, it fails as iOS builds are blocked by multiple issues. - # See https://github.com/ppy/osu-framework/issues/4677 for the details. - # The job can be unblocked once those issues are resolved and game deployments can happen again. - if: false name: Build only (iOS) runs-on: macos-latest timeout-minutes: 60 diff --git a/CodeAnalysis/BannedSymbols.txt b/CodeAnalysis/BannedSymbols.txt index b72803482d..c567adc0ae 100644 --- a/CodeAnalysis/BannedSymbols.txt +++ b/CodeAnalysis/BannedSymbols.txt @@ -10,3 +10,6 @@ T:Microsoft.EntityFrameworkCore.Internal.EnumerableExtensions;Don't use internal T:Microsoft.EntityFrameworkCore.Internal.TypeExtensions;Don't use internal extension methods. T:NuGet.Packaging.CollectionExtensions;Don't use internal extension methods. M:System.Enum.HasFlag(System.Enum);Use osu.Framework.Extensions.EnumExtensions.HasFlagFast() instead. +M:Realms.IRealmCollection`1.SubscribeForNotifications`1(Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IRealmCollection,NotificationCallbackDelegate) instead. +M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Linq.IQueryable{``0},Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IQueryable,NotificationCallbackDelegate) instead. +M:Realms.CollectionExtensions.SubscribeForNotifications`1(System.Collections.Generic.IList{``0},Realms.NotificationCallbackDelegate{``0});Use osu.Game.Database.RealmObjectExtensions.QueryAsyncWithNotifications(IList,NotificationCallbackDelegate) instead. diff --git a/osu.Android.props b/osu.Android.props index eff0eed278..17b5cb67e9 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,8 +51,8 @@ - - + + diff --git a/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs index d832411104..8b323eefa6 100644 --- a/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs +++ b/osu.Game.Rulesets.Osu/Mods/OsuModApproachDifferent.cs @@ -1,4 +1,4 @@ -// 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; @@ -26,12 +26,12 @@ public class OsuModApproachDifferent : Mod, IApplicableToDrawableHitObject, IReq public BindableFloat Scale { get; } = new BindableFloat(4) { Precision = 0.1f, - MinValue = 2, + MinValue = 1.5f, MaxValue = 10, }; [SettingSource("Style", "Change the animation style of the approach circles.", 1)] - public Bindable Style { get; } = new Bindable(); + public Bindable Style { get; } = new Bindable(AnimationStyle.Gravity); public void ApplyToDrawableHitObject(DrawableHitObject drawable) { @@ -52,9 +52,18 @@ private Easing getEasing(AnimationStyle style) { switch (style) { - default: + case AnimationStyle.Linear: return Easing.None; + case AnimationStyle.Gravity: + return Easing.InBack; + + case AnimationStyle.InOut1: + return Easing.InOutCubic; + + case AnimationStyle.InOut2: + return Easing.InOutQuint; + case AnimationStyle.Accelerate1: return Easing.In; @@ -64,9 +73,6 @@ private Easing getEasing(AnimationStyle style) case AnimationStyle.Accelerate3: return Easing.InQuint; - case AnimationStyle.Gravity: - return Easing.InBack; - case AnimationStyle.Decelerate1: return Easing.Out; @@ -76,16 +82,14 @@ private Easing getEasing(AnimationStyle style) case AnimationStyle.Decelerate3: return Easing.OutQuint; - case AnimationStyle.InOut1: - return Easing.InOutCubic; - - case AnimationStyle.InOut2: - return Easing.InOutQuint; + default: + throw new ArgumentOutOfRangeException(nameof(style), style, @"Unsupported animation style"); } } public enum AnimationStyle { + Linear, Gravity, InOut1, InOut2, diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index 9ee237cfd9..a6edd6cb5f 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -12,6 +12,7 @@ using osu.Framework.Extensions; using osu.Framework.Extensions.ObjectExtensions; using osu.Framework.Logging; +using osu.Framework.Platform; using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Extensions; @@ -474,7 +475,7 @@ public void TestRollbackOnFailure() } [Test] - public void TestImportThenDeleteThenImport() + public void TestImportThenDeleteThenImportOptimisedPath() { RunTestWithRealmAsync(async (realmFactory, storage) => { @@ -485,11 +486,39 @@ public void TestImportThenDeleteThenImport() deleteBeatmapSet(imported, realmFactory.Context); + Assert.IsTrue(imported.DeletePending); + 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); + Assert.IsFalse(imported.DeletePending); + Assert.IsFalse(importedSecondTime.DeletePending); + }); + } + + [Test] + public void TestImportThenDeleteThenImportNonOptimisedPath() + { + RunTestWithRealmAsync(async (realmFactory, storage) => + { + using var importer = new NonOptimisedBeatmapImporter(realmFactory, storage); + using var store = new RealmRulesetStore(realmFactory, storage); + + var imported = await LoadOszIntoStore(importer, realmFactory.Context); + + deleteBeatmapSet(imported, realmFactory.Context); + + Assert.IsTrue(imported.DeletePending); + + 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); + Assert.IsFalse(imported.DeletePending); + Assert.IsFalse(importedSecondTime.DeletePending); }); } @@ -823,7 +852,11 @@ private static void ensureLoaded(Realm realm, int timeout = 60000) { IQueryable? resultSets = null; - waitForOrAssert(() => (resultSets = realm.All().Where(s => !s.DeletePending && s.OnlineID == 241526)).Any(), + waitForOrAssert(() => + { + realm.Refresh(); + return (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... @@ -836,16 +869,16 @@ private static void ensureLoaded(Realm realm, int timeout = 60000) // 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); + Assert.AreEqual(12, queryBeatmaps().Count(), @"Beatmap count was not correct"); + Assert.AreEqual(1, queryBeatmapSets().Count(), @"Beatmapset count was not correct"); - int countBeatmapSetBeatmaps = 0; - int countBeatmaps = 0; + int countBeatmapSetBeatmaps; + int countBeatmaps; - waitForOrAssert(() => - (countBeatmapSetBeatmaps = queryBeatmapSets().First().Beatmaps.Count) == - (countBeatmaps = queryBeatmaps().Count()), - $@"Incorrect database beatmap count post-import ({countBeatmaps} but should be {countBeatmapSetBeatmaps}).", timeout); + Assert.AreEqual( + countBeatmapSetBeatmaps = queryBeatmapSets().First().Beatmaps.Count, + countBeatmaps = queryBeatmaps().Count(), + $@"Incorrect database beatmap count post-import ({countBeatmaps} but should be {countBeatmapSetBeatmaps})."); foreach (RealmBeatmap b in set.Beatmaps) Assert.IsTrue(set.Beatmaps.Any(c => c.OnlineID == b.OnlineID)); @@ -867,5 +900,15 @@ private static void waitForOrAssert(Func result, string failureMessage, in Assert.Fail(failureMessage); } + + public class NonOptimisedBeatmapImporter : BeatmapImporter + { + public NonOptimisedBeatmapImporter(RealmContextFactory realmFactory, Storage storage) + : base(realmFactory, storage) + { + } + + protected override bool HasCustomHashFunction => true; + } } } diff --git a/osu.Game.Tests/Database/GeneralUsageTests.cs b/osu.Game.Tests/Database/GeneralUsageTests.cs index 3e8b6091fd..2285b22a3a 100644 --- a/osu.Game.Tests/Database/GeneralUsageTests.cs +++ b/osu.Game.Tests/Database/GeneralUsageTests.cs @@ -5,6 +5,8 @@ using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using osu.Game.Database; +using osu.Game.Models; #nullable enable @@ -33,6 +35,39 @@ public void TestBlockOperations() }); } + /// + /// Test to ensure that a `CreateContext` call nested inside a subscription doesn't cause any deadlocks + /// due to context fetching semaphores. + /// + [Test] + public void TestNestedContextCreationWithSubscription() + { + RunTestWithRealm((realmFactory, _) => + { + bool callbackRan = false; + + using (var context = realmFactory.CreateContext()) + { + var subscription = context.All().QueryAsyncWithNotifications((sender, changes, error) => + { + using (realmFactory.CreateContext()) + { + callbackRan = true; + } + }); + + // Force the callback above to run. + using (realmFactory.CreateContext()) + { + } + + subscription?.Dispose(); + } + + Assert.IsTrue(callbackRan); + }); + } + [Test] public void TestBlockOperationsWithContention() { diff --git a/osu.Game.Tests/Database/RealmLiveTests.cs b/osu.Game.Tests/Database/RealmLiveTests.cs index f86761fdc8..9b6769b788 100644 --- a/osu.Game.Tests/Database/RealmLiveTests.cs +++ b/osu.Game.Tests/Database/RealmLiveTests.cs @@ -29,6 +29,22 @@ public void TestLiveEquality() }); } + [Test] + public void TestAccessAfterAttach() + { + RunTestWithRealm((realmFactory, _) => + { + var beatmap = new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()); + + var liveBeatmap = beatmap.ToLive(); + + using (var context = realmFactory.CreateContext()) + context.Write(r => r.Add(beatmap)); + + Assert.IsFalse(liveBeatmap.PerformRead(l => l.Hidden)); + }); + } + [Test] public void TestAccessNonManaged() { @@ -46,49 +62,12 @@ public void TestAccessNonManaged() Assert.IsFalse(liveBeatmap.PerformRead(l => l.Hidden)); } - [Test] - public void TestValueAccessWithOpenContext() - { - RunTestWithRealm((realmFactory, _) => - { - RealmLive? liveBeatmap = null; - Task.Factory.StartNew(() => - { - using (var threadContext = realmFactory.CreateContext()) - { - var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); - - liveBeatmap = beatmap.ToLive(); - } - }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); - - Debug.Assert(liveBeatmap != null); - - Task.Factory.StartNew(() => - { - Assert.DoesNotThrow(() => - { - using (realmFactory.CreateContext()) - { - var resolved = liveBeatmap.Value; - - Assert.IsTrue(resolved.Realm.IsClosed); - Assert.IsTrue(resolved.IsValid); - - // can access properties without a crash. - Assert.IsFalse(resolved.Hidden); - } - }); - }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); - }); - } - [Test] public void TestScopedReadWithoutContext() { RunTestWithRealm((realmFactory, _) => { - RealmLive? liveBeatmap = null; + ILive? liveBeatmap = null; Task.Factory.StartNew(() => { using (var threadContext = realmFactory.CreateContext()) @@ -117,7 +96,7 @@ public void TestScopedWriteWithoutContext() { RunTestWithRealm((realmFactory, _) => { - RealmLive? liveBeatmap = null; + ILive? liveBeatmap = null; Task.Factory.StartNew(() => { using (var threadContext = realmFactory.CreateContext()) @@ -138,12 +117,66 @@ public void TestScopedWriteWithoutContext() }); } + [Test] + public void TestValueAccessNonManaged() + { + RunTestWithRealm((realmFactory, _) => + { + var beatmap = new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()); + var liveBeatmap = beatmap.ToLive(); + + Assert.DoesNotThrow(() => + { + var __ = liveBeatmap.Value; + }); + }); + } + + [Test] + public void TestValueAccessWithOpenContextFails() + { + RunTestWithRealm((realmFactory, _) => + { + ILive? liveBeatmap = null; + + Task.Factory.StartNew(() => + { + using (var threadContext = realmFactory.CreateContext()) + { + var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); + + liveBeatmap = beatmap.ToLive(); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + + Debug.Assert(liveBeatmap != null); + + Task.Factory.StartNew(() => + { + // Can't be used, without a valid context. + Assert.Throws(() => + { + var __ = liveBeatmap.Value; + }); + + // Can't be used, even from within a valid context. + using (realmFactory.CreateContext()) + { + Assert.Throws(() => + { + var __ = liveBeatmap.Value; + }); + } + }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); + }); + } + [Test] public void TestValueAccessWithoutOpenContextFails() { RunTestWithRealm((realmFactory, _) => { - RealmLive? liveBeatmap = null; + ILive? liveBeatmap = null; Task.Factory.StartNew(() => { using (var threadContext = realmFactory.CreateContext()) @@ -175,8 +208,8 @@ public void TestLiveAssumptions() using (var updateThreadContext = realmFactory.CreateContext()) { - updateThreadContext.All().SubscribeForNotifications(gotChange); - RealmLive? liveBeatmap = null; + updateThreadContext.All().QueryAsyncWithNotifications(gotChange); + ILive? liveBeatmap = null; Task.Factory.StartNew(() => { @@ -199,23 +232,22 @@ public void TestLiveAssumptions() Assert.AreEqual(0, updateThreadContext.All().Count()); Assert.AreEqual(0, changesTriggered); - var resolved = liveBeatmap.Value; - - // retrieval causes an implicit refresh. even changes that aren't related to the retrieval are fired at this point. - Assert.AreEqual(2, updateThreadContext.All().Count()); - Assert.AreEqual(1, changesTriggered); - - // even though the realm that this instance was resolved for was closed, it's still valid. - Assert.IsTrue(resolved.Realm.IsClosed); - Assert.IsTrue(resolved.IsValid); - - // can access properties without a crash. - Assert.IsFalse(resolved.Hidden); - - updateThreadContext.Write(r => + liveBeatmap.PerformRead(resolved => { - // can use with the main context. - r.Remove(resolved); + // retrieval causes an implicit refresh. even changes that aren't related to the retrieval are fired at this point. + // ReSharper disable once AccessToDisposedClosure + Assert.AreEqual(2, updateThreadContext.All().Count()); + Assert.AreEqual(1, changesTriggered); + + // can access properties without a crash. + Assert.IsFalse(resolved.Hidden); + + // ReSharper disable once AccessToDisposedClosure + updateThreadContext.Write(r => + { + // can use with the main context. + r.Remove(resolved); + }); }); } diff --git a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs index e458e66ab7..ae8eec2629 100644 --- a/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs +++ b/osu.Game.Tests/NonVisual/DifficultyAdjustmentModCombinationsTest.cs @@ -3,12 +3,14 @@ using System; using System.Collections.Generic; +using System.Linq; using NUnit.Framework; using osu.Game.Beatmaps; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Mods; +using osu.Game.Utils; namespace osu.Game.Tests.NonVisual { @@ -20,8 +22,10 @@ public void TestNoMods() { var combinations = new TestLegacyDifficultyCalculator().CreateDifficultyAdjustmentModCombinations(); - Assert.AreEqual(1, combinations.Length); - Assert.IsTrue(combinations[0] is ModNoMod); + assertCombinations(new[] + { + new[] { typeof(ModNoMod) } + }, combinations); } [Test] @@ -29,9 +33,11 @@ public void TestSingleMod() { var combinations = new TestLegacyDifficultyCalculator(new ModA()).CreateDifficultyAdjustmentModCombinations(); - Assert.AreEqual(2, combinations.Length); - Assert.IsTrue(combinations[0] is ModNoMod); - Assert.IsTrue(combinations[1] is ModA); + assertCombinations(new[] + { + new[] { typeof(ModNoMod) }, + new[] { typeof(ModA) } + }, combinations); } [Test] @@ -39,14 +45,13 @@ public void TestDoubleMod() { var combinations = new TestLegacyDifficultyCalculator(new ModA(), new ModB()).CreateDifficultyAdjustmentModCombinations(); - Assert.AreEqual(4, combinations.Length); - Assert.IsTrue(combinations[0] is ModNoMod); - Assert.IsTrue(combinations[1] is ModA); - Assert.IsTrue(combinations[2] is MultiMod); - Assert.IsTrue(combinations[3] is ModB); - - Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModA); - Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModB); + assertCombinations(new[] + { + new[] { typeof(ModNoMod) }, + new[] { typeof(ModA) }, + new[] { typeof(ModA), typeof(ModB) }, + new[] { typeof(ModB) } + }, combinations); } [Test] @@ -54,10 +59,12 @@ public void TestIncompatibleMods() { var combinations = new TestLegacyDifficultyCalculator(new ModA(), new ModIncompatibleWithA()).CreateDifficultyAdjustmentModCombinations(); - Assert.AreEqual(3, combinations.Length); - Assert.IsTrue(combinations[0] is ModNoMod); - Assert.IsTrue(combinations[1] is ModA); - Assert.IsTrue(combinations[2] is ModIncompatibleWithA); + assertCombinations(new[] + { + new[] { typeof(ModNoMod) }, + new[] { typeof(ModA) }, + new[] { typeof(ModIncompatibleWithA) } + }, combinations); } [Test] @@ -65,22 +72,17 @@ public void TestDoubleIncompatibleMods() { var combinations = new TestLegacyDifficultyCalculator(new ModA(), new ModB(), new ModIncompatibleWithA(), new ModIncompatibleWithAAndB()).CreateDifficultyAdjustmentModCombinations(); - Assert.AreEqual(8, combinations.Length); - Assert.IsTrue(combinations[0] is ModNoMod); - Assert.IsTrue(combinations[1] is ModA); - Assert.IsTrue(combinations[2] is MultiMod); - Assert.IsTrue(combinations[3] is ModB); - Assert.IsTrue(combinations[4] is MultiMod); - Assert.IsTrue(combinations[5] is ModIncompatibleWithA); - Assert.IsTrue(combinations[6] is MultiMod); - Assert.IsTrue(combinations[7] is ModIncompatibleWithAAndB); - - Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModA); - Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModB); - Assert.IsTrue(((MultiMod)combinations[4]).Mods[0] is ModB); - Assert.IsTrue(((MultiMod)combinations[4]).Mods[1] is ModIncompatibleWithA); - Assert.IsTrue(((MultiMod)combinations[6]).Mods[0] is ModIncompatibleWithA); - Assert.IsTrue(((MultiMod)combinations[6]).Mods[1] is ModIncompatibleWithAAndB); + assertCombinations(new[] + { + new[] { typeof(ModNoMod) }, + new[] { typeof(ModA) }, + new[] { typeof(ModA), typeof(ModB) }, + new[] { typeof(ModB) }, + new[] { typeof(ModB), typeof(ModIncompatibleWithA) }, + new[] { typeof(ModIncompatibleWithA) }, + new[] { typeof(ModIncompatibleWithA), typeof(ModIncompatibleWithAAndB) }, + new[] { typeof(ModIncompatibleWithAAndB) }, + }, combinations); } [Test] @@ -88,10 +90,12 @@ public void TestIncompatibleThroughBaseType() { var combinations = new TestLegacyDifficultyCalculator(new ModAofA(), new ModIncompatibleWithAofA()).CreateDifficultyAdjustmentModCombinations(); - Assert.AreEqual(3, combinations.Length); - Assert.IsTrue(combinations[0] is ModNoMod); - Assert.IsTrue(combinations[1] is ModAofA); - Assert.IsTrue(combinations[2] is ModIncompatibleWithAofA); + assertCombinations(new[] + { + new[] { typeof(ModNoMod) }, + new[] { typeof(ModAofA) }, + new[] { typeof(ModIncompatibleWithAofA) } + }, combinations); } [Test] @@ -99,17 +103,13 @@ public void TestMultiModFlattening() { var combinations = new TestLegacyDifficultyCalculator(new ModA(), new MultiMod(new ModB(), new ModC())).CreateDifficultyAdjustmentModCombinations(); - Assert.AreEqual(4, combinations.Length); - Assert.IsTrue(combinations[0] is ModNoMod); - Assert.IsTrue(combinations[1] is ModA); - Assert.IsTrue(combinations[2] is MultiMod); - Assert.IsTrue(combinations[3] is MultiMod); - - Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModA); - Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModB); - Assert.IsTrue(((MultiMod)combinations[2]).Mods[2] is ModC); - Assert.IsTrue(((MultiMod)combinations[3]).Mods[0] is ModB); - Assert.IsTrue(((MultiMod)combinations[3]).Mods[1] is ModC); + assertCombinations(new[] + { + new[] { typeof(ModNoMod) }, + new[] { typeof(ModA) }, + new[] { typeof(ModA), typeof(ModB), typeof(ModC) }, + new[] { typeof(ModB), typeof(ModC) } + }, combinations); } [Test] @@ -117,13 +117,12 @@ public void TestIncompatibleThroughMultiMod() { var combinations = new TestLegacyDifficultyCalculator(new ModA(), new MultiMod(new ModB(), new ModIncompatibleWithA())).CreateDifficultyAdjustmentModCombinations(); - Assert.AreEqual(3, combinations.Length); - Assert.IsTrue(combinations[0] is ModNoMod); - Assert.IsTrue(combinations[1] is ModA); - Assert.IsTrue(combinations[2] is MultiMod); - - Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModB); - Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModIncompatibleWithA); + assertCombinations(new[] + { + new[] { typeof(ModNoMod) }, + new[] { typeof(ModA) }, + new[] { typeof(ModB), typeof(ModIncompatibleWithA) } + }, combinations); } [Test] @@ -131,13 +130,28 @@ public void TestIncompatibleWithSameInstanceViaMultiMod() { var combinations = new TestLegacyDifficultyCalculator(new ModA(), new MultiMod(new ModA(), new ModB())).CreateDifficultyAdjustmentModCombinations(); - Assert.AreEqual(3, combinations.Length); - Assert.IsTrue(combinations[0] is ModNoMod); - Assert.IsTrue(combinations[1] is ModA); - Assert.IsTrue(combinations[2] is MultiMod); + assertCombinations(new[] + { + new[] { typeof(ModNoMod) }, + new[] { typeof(ModA) }, + new[] { typeof(ModA), typeof(ModB) } + }, combinations); + } - Assert.IsTrue(((MultiMod)combinations[2]).Mods[0] is ModA); - Assert.IsTrue(((MultiMod)combinations[2]).Mods[1] is ModB); + private void assertCombinations(Type[][] expectedCombinations, Mod[] actualCombinations) + { + Assert.AreEqual(expectedCombinations.Length, actualCombinations.Length); + + Assert.Multiple(() => + { + for (int i = 0; i < expectedCombinations.Length; ++i) + { + Type[] expectedTypes = expectedCombinations[i]; + Type[] actualTypes = ModUtils.FlattenMod(actualCombinations[i]).Select(m => m.GetType()).ToArray(); + + Assert.That(expectedTypes, Is.EquivalentTo(actualTypes)); + } + }); } private class ModA : Mod diff --git a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs index 840ff20a83..42305ccd81 100644 --- a/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs +++ b/osu.Game.Tests/NonVisual/Multiplayer/StatefulMultiplayerClientTest.cs @@ -16,6 +16,27 @@ namespace osu.Game.Tests.NonVisual.Multiplayer [HeadlessTest] public class StatefulMultiplayerClientTest : MultiplayerTestScene { + [Test] + public void TestUserAddedOnJoin() + { + var user = new APIUser { Id = 33 }; + + AddRepeatStep("add user multiple times", () => Client.AddUser(user), 3); + AddAssert("room has 2 users", () => Client.Room?.Users.Count == 2); + } + + [Test] + public void TestUserRemovedOnLeave() + { + var user = new APIUser { Id = 44 }; + + AddStep("add user", () => Client.AddUser(user)); + AddAssert("room has 2 users", () => Client.Room?.Users.Count == 2); + + AddRepeatStep("remove user multiple times", () => Client.RemoveUser(user), 3); + AddAssert("room has 1 user", () => Client.Room?.Users.Count == 1); + } + [Test] public void TestPlayingUserTracking() { diff --git a/osu.Game.Tests/OnlinePlay/StatefulMultiplayerClientTest.cs b/osu.Game.Tests/OnlinePlay/StatefulMultiplayerClientTest.cs deleted file mode 100644 index 5a621ecf84..0000000000 --- a/osu.Game.Tests/OnlinePlay/StatefulMultiplayerClientTest.cs +++ /dev/null @@ -1,35 +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 NUnit.Framework; -using osu.Framework.Testing; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Tests.Visual.Multiplayer; - -namespace osu.Game.Tests.OnlinePlay -{ - [HeadlessTest] - public class StatefulMultiplayerClientTest : MultiplayerTestScene - { - [Test] - public void TestUserAddedOnJoin() - { - var user = new APIUser { Id = 33 }; - - AddRepeatStep("add user multiple times", () => Client.AddUser(user), 3); - AddAssert("room has 2 users", () => Client.Room?.Users.Count == 2); - } - - [Test] - public void TestUserRemovedOnLeave() - { - var user = new APIUser { Id = 44 }; - - AddStep("add user", () => Client.AddUser(user)); - AddAssert("room has 2 users", () => Client.Room?.Users.Count == 2); - - AddRepeatStep("remove user multiple times", () => Client.RemoveUser(user), 3); - AddAssert("room has 1 user", () => Client.Room?.Users.Count == 1); - } - } -} diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDifficultyList.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDifficultyList.cs new file mode 100644 index 0000000000..aec75884d6 --- /dev/null +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCardDifficultyList.cs @@ -0,0 +1,71 @@ +// 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.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Shapes; +using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Graphics; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays; + +namespace osu.Game.Tests.Visual.Beatmaps +{ + public class TestSceneBeatmapCardDifficultyList : OsuTestScene + { + [Cached] + private OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Blue); + + [BackgroundDependencyLoader] + private void load(OsuColour colours) + { + var beatmapSet = new APIBeatmapSet + { + Beatmaps = new[] + { + new APIBeatmap { RulesetID = 1, StarRating = 5.76, DifficultyName = "Oni" }, + new APIBeatmap { RulesetID = 1, StarRating = 3.20, DifficultyName = "Muzukashii" }, + new APIBeatmap { RulesetID = 1, StarRating = 2.45, DifficultyName = "Futsuu" }, + + new APIBeatmap { RulesetID = 0, StarRating = 2.04, DifficultyName = "Normal" }, + new APIBeatmap { RulesetID = 0, StarRating = 3.51, DifficultyName = "Hard" }, + new APIBeatmap { RulesetID = 0, StarRating = 5.25, DifficultyName = "Insane" }, + + new APIBeatmap { RulesetID = 2, StarRating = 2.64, DifficultyName = "Salad" }, + new APIBeatmap { RulesetID = 2, StarRating = 3.56, DifficultyName = "Platter" }, + new APIBeatmap { RulesetID = 2, StarRating = 4.65, DifficultyName = "Rain" }, + + new APIBeatmap { RulesetID = 3, StarRating = 1.93, DifficultyName = "[7K] Normal" }, + new APIBeatmap { RulesetID = 3, StarRating = 3.18, DifficultyName = "[7K] Hyper" }, + new APIBeatmap { RulesetID = 3, StarRating = 4.82, DifficultyName = "[7K] Another" }, + + new APIBeatmap { RulesetID = 4, StarRating = 9.99, DifficultyName = "Unknown?!" }, + } + }; + + Child = new Container + { + Width = 300, + AutoSizeAxes = Axes.Y, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Children = new Drawable[] + { + new Box + { + RelativeSizeAxes = Axes.Both, + Colour = colourProvider.Background2 + }, + new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding(10), + Child = new BeatmapCardDifficultyList(beatmapSet) + } + } + }; + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index 84b24ba3a1..a5229702a8 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -144,7 +144,8 @@ public void TestBeatmapConfirmed() AddStep("set mods", () => SelectedMods.Value = new[] { new TaikoModDoubleTime() }); AddStep("confirm selection", () => songSelect.FinaliseSelection()); - AddStep("exit song select", () => songSelect.Exit()); + + AddUntilStep("song select exited", () => !songSelect.IsCurrentScreen()); AddAssert("beatmap not changed", () => Beatmap.Value.BeatmapInfo.Equals(selectedBeatmap)); AddAssert("ruleset not changed", () => Ruleset.Value.Equals(new TaikoRuleset().RulesetInfo)); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs index c70906927e..981989c28a 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs @@ -11,6 +11,7 @@ using osu.Game.Beatmaps; using osu.Game.Database; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; using osu.Game.Online.Rooms; using osu.Game.Rulesets; @@ -118,6 +119,33 @@ public void TestChangeTeamsViaButton() AddAssert("user still on team 0", () => (client.Room?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 0); } + [Test] + public void TestSettingsUpdatedWhenChangingMatchType() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Type = { Value = MatchType.HeadToHead }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddUntilStep("match type head to head", () => client.APIRoom?.Type.Value == MatchType.HeadToHead); + + AddStep("change match type", () => client.ChangeSettings(new MultiplayerRoomSettings + { + MatchType = MatchType.TeamVersus + })); + + AddUntilStep("api room updated to team versus", () => client.APIRoom?.Type.Value == MatchType.TeamVersus); + } + [Test] public void TestChangeTypeViaMatchSettings() { @@ -152,6 +180,7 @@ private void createRoom(Func room) AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); AddWaitStep("wait for transition", 2); + AddUntilStep("create room button enabled", () => this.ChildrenOfType().Single().Enabled.Value); AddStep("create room", () => { InputManager.MoveMouseTo(this.ChildrenOfType().Single()); diff --git a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs index e7c54efa8c..9ad5242df4 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestSceneBeatmapInfoWedge.cs @@ -82,7 +82,7 @@ protected override void LoadComplete() beatmaps.Add(testBeatmap); - AddStep("set ruleset", () => Ruleset.Value = rulesetInfo); + setRuleset(rulesetInfo); selectBeatmap(testBeatmap); @@ -167,6 +167,22 @@ void checkDisplayedBPM(float target) => label => label.Statistic.Name == "BPM" && label.Statistic.Content == target.ToString(CultureInfo.InvariantCulture))); } + private void setRuleset(RulesetInfo rulesetInfo) + { + Container containerBefore = null; + + AddStep("set ruleset", () => + { + // wedge content is only refreshed if the ruleset changes, so only wait for load in that case. + if (!rulesetInfo.Equals(Ruleset.Value)) + containerBefore = infoWedge.DisplayedContent; + + Ruleset.Value = rulesetInfo; + }); + + AddUntilStep("wait for async load", () => infoWedge.DisplayedContent != containerBefore); + } + private void selectBeatmap([CanBeNull] IBeatmap b) { Container containerBefore = null; diff --git a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs index 94472a8bc7..d149ec145b 100644 --- a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs @@ -87,9 +87,9 @@ public void TestMigration() // Recreate the old setup that uses "tournament" as the base path. string oldPath = Path.Combine(osuRoot, "tournament"); - string videosPath = Path.Combine(oldPath, "videos"); - string modsPath = Path.Combine(oldPath, "mods"); - string flagsPath = Path.Combine(oldPath, "flags"); + string videosPath = Path.Combine(oldPath, "Videos"); + string modsPath = Path.Combine(oldPath, "Mods"); + string flagsPath = Path.Combine(oldPath, "Flags"); Directory.CreateDirectory(videosPath); Directory.CreateDirectory(modsPath); @@ -123,9 +123,9 @@ public void TestMigration() string migratedPath = Path.Combine(host.Storage.GetFullPath("."), "tournaments", "default"); - videosPath = Path.Combine(migratedPath, "videos"); - modsPath = Path.Combine(migratedPath, "mods"); - flagsPath = Path.Combine(migratedPath, "flags"); + videosPath = Path.Combine(migratedPath, "Videos"); + modsPath = Path.Combine(migratedPath, "Mods"); + flagsPath = Path.Combine(migratedPath, "Flags"); videoFile = Path.Combine(videosPath, "video.mp4"); modFile = Path.Combine(modsPath, "mod.png"); diff --git a/osu.Game.Tournament/Components/TournamentModIcon.cs b/osu.Game.Tournament/Components/TournamentModIcon.cs index 7c4e9c69a2..0fde263bc8 100644 --- a/osu.Game.Tournament/Components/TournamentModIcon.cs +++ b/osu.Game.Tournament/Components/TournamentModIcon.cs @@ -31,7 +31,7 @@ public TournamentModIcon(string modAcronym) [BackgroundDependencyLoader] private void load(TextureStore textures, LadderInfo ladderInfo) { - var customTexture = textures.Get($"mods/{modAcronym}"); + var customTexture = textures.Get($"Mods/{modAcronym}"); if (customTexture != null) { diff --git a/osu.Game.Tournament/IO/TournamentStorage.cs b/osu.Game.Tournament/IO/TournamentStorage.cs index 044b60bbd5..02cf567837 100644 --- a/osu.Game.Tournament/IO/TournamentStorage.cs +++ b/osu.Game.Tournament/IO/TournamentStorage.cs @@ -51,6 +51,23 @@ private void updateTournament(ValueChangedEvent newTournament) Logger.Log("Changing tournament storage: " + GetFullPath(string.Empty)); } + protected override void ChangeTargetStorage(Storage newStorage) + { + // due to an unfortunate oversight, on OSes that are sensitive to pathname casing + // the custom flags directory needed to be named `Flags` (uppercase), + // while custom mods and videos directories needed to be named `mods` and `videos` respectively (lowercase). + // to unify handling to uppercase, move any non-compliant directories automatically for the user to migrate. + // can be removed 20220528 + if (newStorage.ExistsDirectory("flags")) + AttemptOperation(() => Directory.Move(newStorage.GetFullPath("flags"), newStorage.GetFullPath("Flags"))); + if (newStorage.ExistsDirectory("mods")) + AttemptOperation(() => Directory.Move(newStorage.GetFullPath("mods"), newStorage.GetFullPath("Mods"))); + if (newStorage.ExistsDirectory("videos")) + AttemptOperation(() => Directory.Move(newStorage.GetFullPath("videos"), newStorage.GetFullPath("Videos"))); + + base.ChangeTargetStorage(newStorage); + } + public IEnumerable ListTournaments() => AllTournaments.GetDirectories(string.Empty); public override void Migrate(Storage newStorage) diff --git a/osu.Game.Tournament/IO/TournamentVideoResourceStore.cs b/osu.Game.Tournament/IO/TournamentVideoResourceStore.cs index 4b26840b79..964d03220d 100644 --- a/osu.Game.Tournament/IO/TournamentVideoResourceStore.cs +++ b/osu.Game.Tournament/IO/TournamentVideoResourceStore.cs @@ -9,7 +9,7 @@ namespace osu.Game.Tournament.IO public class TournamentVideoResourceStore : NamespacedResourceStore { public TournamentVideoResourceStore(Storage storage) - : base(new StorageBackedResourceStore(storage), "videos") + : base(new StorageBackedResourceStore(storage), "Videos") { AddExtension("m4v"); AddExtension("avi"); diff --git a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs index d34a8583b9..cd74a75b10 100644 --- a/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs +++ b/osu.Game.Tournament/Screens/TeamIntro/SeedingScreen.cs @@ -197,7 +197,7 @@ private void load(TextureStore textures) { row.Add(new Sprite { - Texture = textures.Get($"mods/{mods.ToLower()}"), + Texture = textures.Get($"Mods/{mods.ToLower()}"), Scale = new Vector2(0.5f) }); } diff --git a/osu.Game/Beatmaps/BeatmapManager.cs b/osu.Game/Beatmaps/BeatmapManager.cs index d920b194b5..8502b91096 100644 --- a/osu.Game/Beatmaps/BeatmapManager.cs +++ b/osu.Game/Beatmaps/BeatmapManager.cs @@ -288,9 +288,9 @@ public Task> Import(BeatmapSetInfo item, ArchiveReader arc #region Implementation of IModelFileManager - public void ReplaceFile(BeatmapSetInfo model, BeatmapSetFileInfo file, Stream contents, string filename = null) + public void ReplaceFile(BeatmapSetInfo model, BeatmapSetFileInfo file, Stream contents) { - beatmapModelManager.ReplaceFile(model, file, contents, filename); + beatmapModelManager.ReplaceFile(model, file, contents); } public void DeleteFile(BeatmapSetInfo model, BeatmapSetFileInfo file) diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardDifficultyList.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardDifficultyList.cs new file mode 100644 index 0000000000..7753d8480a --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardDifficultyList.cs @@ -0,0 +1,103 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Sprites; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Online.Chat; +using osu.Game.Rulesets; +using osuTK; + +namespace osu.Game.Beatmaps.Drawables.Cards +{ + public class BeatmapCardDifficultyList : CompositeDrawable + { + public BeatmapCardDifficultyList(IBeatmapSetInfo beatmapSetInfo) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + FillFlowContainer flow; + + InternalChild = flow = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 3) + }; + + bool firstGroup = true; + + foreach (var group in beatmapSetInfo.Beatmaps.GroupBy(beatmap => beatmap.Ruleset.OnlineID).OrderBy(group => group.Key)) + { + if (!firstGroup) + { + flow.Add(Empty().With(s => + { + s.RelativeSizeAxes = Axes.X; + s.Height = 4; + })); + } + + foreach (var difficulty in group.OrderBy(b => b.StarRating)) + flow.Add(new BeatmapCardDifficultyRow(difficulty)); + + firstGroup = false; + } + } + + private class BeatmapCardDifficultyRow : CompositeDrawable + { + private readonly IBeatmapInfo beatmapInfo; + + public BeatmapCardDifficultyRow(IBeatmapInfo beatmapInfo) + { + this.beatmapInfo = beatmapInfo; + } + + [BackgroundDependencyLoader] + private void load(RulesetStore rulesets) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + InternalChild = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4, 0), + Children = new[] + { + (rulesets.GetRuleset(beatmapInfo.Ruleset.OnlineID)?.CreateInstance().CreateIcon() ?? new SpriteIcon { Icon = FontAwesome.Regular.QuestionCircle }).With(icon => + { + icon.Anchor = icon.Origin = Anchor.CentreLeft; + icon.Size = new Vector2(16); + }), + new StarRatingDisplay(new StarDifficulty(beatmapInfo.StarRating, 0), StarRatingDisplaySize.Small) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + new LinkFlowContainer(s => + { + s.Font = OsuFont.Default.With(size: 14, weight: FontWeight.SemiBold); + }).With(d => + { + d.AutoSizeAxes = Axes.Both; + d.Anchor = Anchor.CentreLeft; + d.Origin = Anchor.CentreLeft; + d.Padding = new MarginPadding { Bottom = 2 }; + d.AddLink(beatmapInfo.DifficultyName, LinkAction.OpenBeatmap, beatmapInfo.OnlineID.ToString()); + }) + } + }; + } + } + } +} diff --git a/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs b/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs index f4501f0633..5b211084ab 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultySpectrumDisplay.cs @@ -62,7 +62,7 @@ public DifficultySpectrumDisplay(IBeatmapSetInfo beatmapSet) // matching web: https://github.com/ppy/osu-web/blob/d06d8c5e735eb1f48799b1654b528e9a7afb0a35/resources/assets/lib/beatmapset-panel.tsx#L127 bool collapsed = beatmapSet.Beatmaps.Count() > 12; - foreach (var rulesetGrouping in beatmapSet.Beatmaps.GroupBy(beatmap => beatmap.Ruleset.OnlineID)) + foreach (var rulesetGrouping in beatmapSet.Beatmaps.GroupBy(beatmap => beatmap.Ruleset.OnlineID).OrderBy(group => group.Key)) { flow.Add(new RulesetDifficultyGroup(rulesetGrouping.Key, rulesetGrouping, collapsed)); } diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index e73f4a7f6e..8cabb55cc3 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -453,13 +453,12 @@ void rollback() /// The item to operate on. /// The existing file to be replaced. /// The new file contents. - /// An optional filename for the new file. Will use the previous filename if not specified. - public void ReplaceFile(TModel model, TFileModel file, Stream contents, string filename = null) + public void ReplaceFile(TModel model, TFileModel file, Stream contents) { using (ContextFactory.GetForWrite()) { DeleteFile(model, file); - AddFile(model, contents, filename ?? file.Filename); + AddFile(model, contents, file.Filename); } } diff --git a/osu.Game/Database/BeatmapLookupCache.cs b/osu.Game/Database/BeatmapLookupCache.cs new file mode 100644 index 0000000000..c6f8244494 --- /dev/null +++ b/osu.Game/Database/BeatmapLookupCache.cs @@ -0,0 +1,149 @@ +// 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 System.Threading; +using System.Threading.Tasks; +using JetBrains.Annotations; +using osu.Framework.Allocation; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Database +{ + // This class is based on `UserLookupCache` which is well tested. + // If modifications are to be made here, a base abstract implementation should likely be created and shared between the two. + public class BeatmapLookupCache : MemoryCachingComponent + { + [Resolved] + private IAPIProvider api { get; set; } + + /// + /// Perform an API lookup on the specified beatmap, populating a model. + /// + /// The beatmap to lookup. + /// An optional cancellation token. + /// The populated beatmap, or null if the beatmap does not exist or the request could not be satisfied. + [ItemCanBeNull] + public Task GetBeatmapAsync(int beatmapId, CancellationToken token = default) => GetAsync(beatmapId, token); + + /// + /// Perform an API lookup on the specified beatmaps, populating a model. + /// + /// The beatmaps to lookup. + /// An optional cancellation token. + /// The populated beatmaps. May include null results for failed retrievals. + public Task GetBeatmapsAsync(int[] beatmapIds, CancellationToken token = default) + { + var beatmapLookupTasks = new List>(); + + foreach (int u in beatmapIds) + { + beatmapLookupTasks.Add(GetBeatmapAsync(u, token).ContinueWith(task => + { + if (!task.IsCompletedSuccessfully) + return null; + + return task.Result; + }, token)); + } + + return Task.WhenAll(beatmapLookupTasks); + } + + protected override async Task ComputeValueAsync(int lookup, CancellationToken token = default) + => await queryBeatmap(lookup).ConfigureAwait(false); + + private readonly Queue<(int id, TaskCompletionSource)> pendingBeatmapTasks = new Queue<(int, TaskCompletionSource)>(); + private Task pendingRequestTask; + private readonly object taskAssignmentLock = new object(); + + private Task queryBeatmap(int beatmapId) + { + lock (taskAssignmentLock) + { + var tcs = new TaskCompletionSource(); + + // Add to the queue. + pendingBeatmapTasks.Enqueue((beatmapId, tcs)); + + // Create a request task if there's not already one. + if (pendingRequestTask == null) + createNewTask(); + + return tcs.Task; + } + } + + private void performLookup() + { + // contains at most 50 unique beatmap IDs from beatmapTasks, which is used to perform the lookup. + var beatmapTasks = new Dictionary>>(); + + // Grab at most 50 unique beatmap IDs from the queue. + lock (taskAssignmentLock) + { + while (pendingBeatmapTasks.Count > 0 && beatmapTasks.Count < 50) + { + (int id, TaskCompletionSource task) next = pendingBeatmapTasks.Dequeue(); + + // Perform a secondary check for existence, in case the beatmap was queried in a previous batch. + if (CheckExists(next.id, out var existing)) + next.task.SetResult(existing); + else + { + if (beatmapTasks.TryGetValue(next.id, out var tasks)) + tasks.Add(next.task); + else + beatmapTasks[next.id] = new List> { next.task }; + } + } + } + + if (beatmapTasks.Count == 0) + return; + + // Query the beatmaps. + var request = new GetBeatmapsRequest(beatmapTasks.Keys.ToArray()); + + // rather than queueing, we maintain our own single-threaded request stream. + // todo: we probably want retry logic here. + api.Perform(request); + + // Create a new request task if there's still more beatmaps to query. + lock (taskAssignmentLock) + { + pendingRequestTask = null; + if (pendingBeatmapTasks.Count > 0) + createNewTask(); + } + + List foundBeatmaps = request.Response?.Beatmaps; + + if (foundBeatmaps != null) + { + foreach (var beatmap in foundBeatmaps) + { + if (beatmapTasks.TryGetValue(beatmap.OnlineID, out var tasks)) + { + foreach (var task in tasks) + task.SetResult(beatmap); + + beatmapTasks.Remove(beatmap.OnlineID); + } + } + } + + // if any tasks remain which were not satisfied, return null. + foreach (var tasks in beatmapTasks.Values) + { + foreach (var task in tasks) + task.SetResult(null); + } + } + + private void createNewTask() => pendingRequestTask = Task.Run(performLookup); + } +} diff --git a/osu.Game/Database/ILive.cs b/osu.Game/Database/ILive.cs index a863339f11..3011754bc1 100644 --- a/osu.Game/Database/ILive.cs +++ b/osu.Game/Database/ILive.cs @@ -38,10 +38,10 @@ public interface ILive : IEquatable> bool IsManaged { get; } /// - /// Resolve the value of this instance on the current thread's context. + /// Resolve the value of this instance on the update thread. /// /// - /// After resolving the data should not be passed between threads. + /// After resolving, the data should not be passed between threads. /// T Value { get; } } diff --git a/osu.Game/Database/IModelFileManager.cs b/osu.Game/Database/IModelFileManager.cs index c74b945eb7..4bc1e2d29b 100644 --- a/osu.Game/Database/IModelFileManager.cs +++ b/osu.Game/Database/IModelFileManager.cs @@ -15,8 +15,7 @@ public interface IModelFileManager /// The item to operate on. /// The existing file to be replaced. /// The new file contents. - /// An optional filename for the new file. Will use the previous filename if not specified. - void ReplaceFile(TModel model, TFileModel file, Stream contents, string filename = null); + void ReplaceFile(TModel model, TFileModel file, Stream contents); /// /// Delete an existing file. diff --git a/osu.Game/Database/RealmContextFactory.cs b/osu.Game/Database/RealmContextFactory.cs index 3c5dfeafe8..a20139e830 100644 --- a/osu.Game/Database/RealmContextFactory.cs +++ b/osu.Game/Database/RealmContextFactory.cs @@ -52,6 +52,8 @@ public class RealmContextFactory : IDisposable, IRealmFactory /// private readonly SemaphoreSlim contextCreationLock = new SemaphoreSlim(1); + private readonly ThreadLocal currentThreadCanCreateContexts = new ThreadLocal(); + private static readonly GlobalStatistic refreshes = GlobalStatistics.Get(@"Realm", @"Dirty Refreshes"); private static readonly GlobalStatistic contexts_created = GlobalStatistics.Get(@"Realm", @"Contexts (Created)"); @@ -151,9 +153,22 @@ public Realm CreateContext() if (isDisposed) throw new ObjectDisposedException(nameof(RealmContextFactory)); + bool tookSemaphoreLock = false; + try { - contextCreationLock.Wait(); + if (!currentThreadCanCreateContexts.Value) + { + contextCreationLock.Wait(); + currentThreadCanCreateContexts.Value = true; + tookSemaphoreLock = true; + } + else + { + // the semaphore is used to handle blocking of all context creation during certain periods. + // once the semaphore has been taken by this code section, it is safe to create further contexts on the same thread. + // this can happen if a realm subscription is active and triggers a callback which has user code that calls `CreateContext`. + } contexts_created.Value++; @@ -161,7 +176,11 @@ public Realm CreateContext() } finally { - contextCreationLock.Release(); + if (tookSemaphoreLock) + { + contextCreationLock.Release(); + currentThreadCanCreateContexts.Value = false; + } } } @@ -307,6 +326,9 @@ void convertOnlineIDs() where T : RealmObject case 10: string rulesetSettingClassName = getMappedOrOriginalName(typeof(RealmRulesetSetting)); + if (!migration.OldRealm.Schema.TryFindObjectSchema(rulesetSettingClassName, out _)) + return; + var oldSettings = migration.OldRealm.DynamicApi.All(rulesetSettingClassName); var newSettings = migration.NewRealm.All().ToList(); @@ -329,6 +351,9 @@ void convertOnlineIDs() where T : RealmObject case 11: string keyBindingClassName = getMappedOrOriginalName(typeof(RealmKeyBinding)); + if (!migration.OldRealm.Schema.TryFindObjectSchema(keyBindingClassName, out _)) + return; + var oldKeyBindings = migration.OldRealm.DynamicApi.All(keyBindingClassName); var newKeyBindings = migration.NewRealm.All().ToList(); diff --git a/osu.Game/Database/RealmLive.cs b/osu.Game/Database/RealmLive.cs index 73e6715aaa..2accea305a 100644 --- a/osu.Game/Database/RealmLive.cs +++ b/osu.Game/Database/RealmLive.cs @@ -2,7 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; -using System.Threading; +using osu.Framework.Development; using Realms; #nullable enable @@ -17,10 +17,7 @@ public class RealmLive : ILive where T : RealmObject, IHasGuidPrimaryKey { public Guid ID { get; } - public bool IsManaged { get; } - - private readonly SynchronizationContext? fetchedContext; - private readonly int fetchedThreadId; + public bool IsManaged => data.IsManaged; /// /// The original live data used to create this instance. @@ -35,14 +32,6 @@ public RealmLive(T data) { this.data = data; - if (data.IsManaged) - { - IsManaged = true; - - fetchedContext = SynchronizationContext.Current; - fetchedThreadId = Thread.CurrentThread.ManagedThreadId; - } - ID = data.ID; } @@ -52,7 +41,7 @@ public RealmLive(T data) /// The action to perform. public void PerformRead(Action perform) { - if (originalDataValid) + if (!IsManaged) { perform(data); return; @@ -71,7 +60,7 @@ public TReturn PerformRead(Func perform) if (typeof(RealmObjectBase).IsAssignableFrom(typeof(TReturn))) throw new InvalidOperationException($"Realm live objects should not exit the scope of {nameof(PerformRead)}."); - if (originalDataValid) + if (!IsManaged) return perform(data); using (var realm = Realm.GetInstance(data.Realm.Config)) @@ -99,27 +88,20 @@ public T Value { get { - if (originalDataValid) + if (!IsManaged) return data; - T retrieved; + if (!ThreadSafety.IsUpdateThread) + throw new InvalidOperationException($"Can't use {nameof(Value)} on managed objects from non-update threads"); - using (var realm = Realm.GetInstance(data.Realm.Config)) - retrieved = realm.Find(ID); + // When using Value, we rely on garbage collection for the realm instance used to retrieve the instance. + // As we are sure that this is on the update thread, there should always be an open and constantly refreshing realm instance to ensure file size growth is a non-issue. + var realm = Realm.GetInstance(data.Realm.Config); - if (!retrieved.IsValid) - throw new InvalidOperationException("Attempted to access value without an open context"); - - return retrieved; + return realm.Find(ID); } } - private bool originalDataValid => !IsManaged || (isCorrectThread && data.IsValid); - - // this matches realm's internal thread validation (see https://github.com/realm/realm-dotnet/blob/903b4d0b304f887e37e2d905384fb572a6496e70/Realm/Realm/Native/SynchronizationContextScheduler.cs#L72) - private bool isCorrectThread - => (fetchedContext != null && SynchronizationContext.Current == fetchedContext) || fetchedThreadId == Thread.CurrentThread.ManagedThreadId; - public bool Equals(ILive? other) => ID == other?.ID; } } diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index 18a926fa8c..b38e21453c 100644 --- a/osu.Game/Database/RealmObjectExtensions.cs +++ b/osu.Game/Database/RealmObjectExtensions.cs @@ -1,12 +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 System; using System.Collections.Generic; using System.Linq; using AutoMapper; +using osu.Framework.Development; using osu.Game.Input.Bindings; using Realms; +#nullable enable + namespace osu.Game.Database { public static class RealmObjectExtensions @@ -49,16 +53,120 @@ public static T Detach(this T item) where T : RealmObject return mapper.Map(item); } - public static List> ToLive(this IEnumerable realmList) + public static List> ToLive(this IEnumerable realmList) where T : RealmObject, IHasGuidPrimaryKey { - return realmList.Select(l => new RealmLive(l)).ToList(); + return realmList.Select(l => new RealmLive(l)).Cast>().ToList(); } - public static RealmLive ToLive(this T realmObject) + public static ILive ToLive(this T realmObject) where T : RealmObject, IHasGuidPrimaryKey { return new RealmLive(realmObject); } + + /// + /// Register a callback to be invoked each time this changes. + /// + /// + /// + /// This adds osu! specific thread and managed state safety checks on top of . + /// + /// + /// The first callback will be invoked with the initial after the asynchronous query completes, + /// and then called again after each write transaction which changes either any of the objects in the collection, or + /// which objects are in the collection. The changes parameter will + /// be null the first time the callback is invoked with the initial results. For each call after that, + /// it will contain information about which rows in the results were added, removed or modified. + /// + /// + /// If a write transaction did not modify any objects in this , the callback is not invoked at all. + /// If an error occurs the callback will be invoked with null for the sender parameter and a non-null error. + /// Currently the only errors that can occur are when opening the on the background worker thread. + /// + /// + /// At the time when the block is called, the object will be fully evaluated + /// and up-to-date, and as long as you do not perform a write transaction on the same thread + /// or explicitly call , accessing it will never perform blocking work. + /// + /// + /// Notifications are delivered via the standard event loop, and so can't be delivered while the event loop is blocked by other activity. + /// When notifications can't be delivered instantly, multiple notifications may be coalesced into a single notification. + /// This can include the notification with the initial collection. + /// + /// + /// The to observe for changes. + /// The callback to be invoked with the updated . + /// + /// A subscription token. It must be kept alive for as long as you want to receive change notifications. + /// To stop receiving notifications, call . + /// + /// May be null in the case the provided collection is not managed. + /// + /// + /// + public static IDisposable? QueryAsyncWithNotifications(this IRealmCollection collection, NotificationCallbackDelegate callback) + where T : RealmObjectBase + { + // Subscriptions can only work on the main thread. + if (!ThreadSafety.IsUpdateThread) + throw new InvalidOperationException("Cannot subscribe for realm notifications from a non-update thread."); + + return collection.SubscribeForNotifications(callback); + } + + /// + /// A convenience method that casts to and subscribes for change notifications. + /// + /// + /// This adds osu! specific thread and managed state safety checks on top of . + /// + /// The to observe for changes. + /// Type of the elements in the list. + /// + /// The callback to be invoked with the updated . + /// + /// A subscription token. It must be kept alive for as long as you want to receive change notifications. + /// To stop receiving notifications, call . + /// + /// May be null in the case the provided collection is not managed. + /// + public static IDisposable? QueryAsyncWithNotifications(this IQueryable list, NotificationCallbackDelegate callback) + where T : RealmObjectBase + { + // Subscribing to non-managed instances doesn't work. + // In this usage, the instance may be non-managed in tests. + if (!(list is IRealmCollection realmCollection)) + return null; + + return QueryAsyncWithNotifications(realmCollection, callback); + } + + /// + /// A convenience method that casts to and subscribes for change notifications. + /// + /// + /// This adds osu! specific thread and managed state safety checks on top of . + /// + /// The to observe for changes. + /// Type of the elements in the list. + /// + /// The callback to be invoked with the updated . + /// + /// A subscription token. It must be kept alive for as long as you want to receive change notifications. + /// To stop receiving notifications, call . + /// + /// May be null in the case the provided collection is not managed. + /// + public static IDisposable? QueryAsyncWithNotifications(this IList list, NotificationCallbackDelegate callback) + where T : RealmObjectBase + { + // Subscribing to non-managed instances doesn't work. + // In this usage, the instance may be non-managed in tests. + if (!(list is IRealmCollection realmCollection)) + return null; + + return QueryAsyncWithNotifications(realmCollection, callback); + } } } diff --git a/osu.Game/Database/UserLookupCache.cs b/osu.Game/Database/UserLookupCache.cs index dae2d2549c..26f4e9fb3b 100644 --- a/osu.Game/Database/UserLookupCache.cs +++ b/osu.Game/Database/UserLookupCache.cs @@ -100,6 +100,9 @@ private void performLookup() } } + if (userTasks.Count == 0) + return; + // Query the users. var request = new GetUsersRequest(userTasks.Keys.ToArray()); diff --git a/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs b/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs index 68ffc6bf4e..b7e25ae4e7 100644 --- a/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs +++ b/osu.Game/Graphics/UserInterface/OsuTabDropdown.cs @@ -66,7 +66,7 @@ public OsuTabDropdownMenu() Origin = Anchor.TopRight; BackgroundColour = Color4.Black.Opacity(0.7f); - MaxHeight = 400; + MaxHeight = 200; } protected override DrawableDropdownMenuItem CreateDrawableDropdownMenuItem(MenuItem item) => new DrawableOsuTabDropdownMenuItem(item); diff --git a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs index baa5b9ff9c..f95c884fe5 100644 --- a/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs +++ b/osu.Game/Input/Bindings/DatabasedKeyBindingContainer.cs @@ -8,7 +8,6 @@ using osu.Framework.Input.Bindings; using osu.Game.Database; using osu.Game.Rulesets; -using Realms; namespace osu.Game.Input.Bindings { @@ -56,7 +55,7 @@ protected override void LoadComplete() .Where(b => b.RulesetName == rulesetName && b.Variant == variant); realmSubscription = realmKeyBindings - .SubscribeForNotifications((sender, changes, error) => + .QueryAsyncWithNotifications((sender, changes, error) => { // first subscription ignored as we are handling this in LoadComplete. if (changes == null) diff --git a/osu.Game/Models/RealmNamedFileUsage.cs b/osu.Game/Models/RealmNamedFileUsage.cs index ba12d51d0b..17e32510a8 100644 --- a/osu.Game/Models/RealmNamedFileUsage.cs +++ b/osu.Game/Models/RealmNamedFileUsage.cs @@ -16,6 +16,7 @@ public class RealmNamedFileUsage : EmbeddedObject, INamedFile, INamedFileUsage { public RealmFile File { get; set; } = null!; + // [Indexed] cannot be used on `EmbeddedObject`s as it only applies to top-level queries. May need to reconsider this if performance becomes a concern. public string Filename { get; set; } = null!; public RealmNamedFileUsage(RealmFile file, string filename) diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index 43195811dc..efb0b102d0 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -38,7 +38,12 @@ protected APIRequest() protected override void PostProcess() { base.PostProcess(); - Response = ((OsuJsonWebRequest)WebRequest)?.ResponseObject; + + if (WebRequest != null) + { + Response = ((OsuJsonWebRequest)WebRequest).ResponseObject; + Logger.Log($"{GetType()} finished with response size of {WebRequest.ResponseStream.Length:#,0} bytes"); + } } internal void TriggerSuccess(T result) diff --git a/osu.Game/Online/API/Requests/GetBeatmapsRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapsRequest.cs new file mode 100644 index 0000000000..1d71e22b77 --- /dev/null +++ b/osu.Game/Online/API/Requests/GetBeatmapsRequest.cs @@ -0,0 +1,24 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; + +namespace osu.Game.Online.API.Requests +{ + public class GetBeatmapsRequest : APIRequest + { + private readonly int[] beatmapIds; + + private const int max_ids_per_request = 50; + + public GetBeatmapsRequest(int[] beatmapIds) + { + if (beatmapIds.Length > max_ids_per_request) + throw new ArgumentException($"{nameof(GetBeatmapsRequest)} calls only support up to {max_ids_per_request} IDs at once"); + + this.beatmapIds = beatmapIds; + } + + protected override string Target => "beatmaps/?ids[]=" + string.Join("&ids[]=", beatmapIds); + } +} diff --git a/osu.Game/Online/API/Requests/GetBeatmapsResponse.cs b/osu.Game/Online/API/Requests/GetBeatmapsResponse.cs new file mode 100644 index 0000000000..c450c3269c --- /dev/null +++ b/osu.Game/Online/API/Requests/GetBeatmapsResponse.cs @@ -0,0 +1,15 @@ +// 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 Newtonsoft.Json; +using osu.Game.Online.API.Requests.Responses; + +namespace osu.Game.Online.API.Requests +{ + public class GetBeatmapsResponse : ResponseWithCursor + { + [JsonProperty("beatmaps")] + public List Beatmaps; + } +} diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 4c472164d6..0822c29376 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -694,6 +694,7 @@ private void updateLocalRoomSettings(MultiplayerRoomSettings settings) Room.Settings = settings; APIRoom.Name.Value = Room.Settings.Name; APIRoom.Password.Value = Room.Settings.Password; + APIRoom.Type.Value = Room.Settings.MatchType; APIRoom.QueueMode.Value = Room.Settings.QueueMode; RoomUpdated?.Invoke(); @@ -702,15 +703,7 @@ private void updateLocalRoomSettings(MultiplayerRoomSettings settings) private async Task createPlaylistItem(MultiplayerPlaylistItem item) { - var set = await GetOnlineBeatmapSet(item.BeatmapID).ConfigureAwait(false); - - // The incoming response is deserialised without circular reference handling currently. - // Because we require using metadata from this instance, populate the nested beatmaps' sets manually here. - foreach (var b in set.Beatmaps) - b.BeatmapSet = set; - - var beatmap = set.Beatmaps.Single(b => b.OnlineID == item.BeatmapID); - beatmap.Checksum = item.BeatmapChecksum; + var apiBeatmap = await GetAPIBeatmap(item.BeatmapID).ConfigureAwait(false); var ruleset = Rulesets.GetRuleset(item.RulesetID); var rulesetInstance = ruleset.CreateInstance(); @@ -719,7 +712,7 @@ private async Task createPlaylistItem(MultiplayerPlaylistItem item { ID = item.ID, OwnerID = item.OwnerID, - Beatmap = { Value = beatmap }, + Beatmap = { Value = apiBeatmap }, Ruleset = { Value = ruleset }, Expired = item.Expired }; @@ -731,12 +724,12 @@ private async Task createPlaylistItem(MultiplayerPlaylistItem item } /// - /// Retrieves a from an online source. + /// Retrieves a from an online source. /// - /// The beatmap set ID. + /// The beatmap ID. /// A token to cancel the request. - /// The retrieval task. - protected abstract Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default); + /// The retrieval task. + protected abstract Task GetAPIBeatmap(int beatmapId, CancellationToken cancellationToken = default); /// /// For the provided user ID, update whether the user is included in . diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index 7308c03ec3..41687b54b0 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -9,8 +9,8 @@ using Microsoft.AspNetCore.SignalR.Client; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Game.Database; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; @@ -29,6 +29,9 @@ public class OnlineMultiplayerClient : MultiplayerClient private HubConnection? connection => connector?.CurrentConnection; + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + public OnlineMultiplayerClient(EndpointConfiguration endpoints) { endpoint = endpoints.MultiplayerEndpointUrl; @@ -159,27 +162,9 @@ public override Task AddPlaylistItem(MultiplayerPlaylistItem item) return connection.InvokeAsync(nameof(IMultiplayerServer.AddPlaylistItem), item); } - protected override Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default) + protected override Task GetAPIBeatmap(int beatmapId, CancellationToken cancellationToken = default) { - var tcs = new TaskCompletionSource(); - var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId); - - req.Success += res => - { - if (cancellationToken.IsCancellationRequested) - { - tcs.SetCanceled(); - return; - } - - tcs.SetResult(res); - }; - - req.Failure += e => tcs.SetException(e); - - API.Queue(req); - - return tcs.Task; + return beatmapLookupCache.GetBeatmapAsync(beatmapId, cancellationToken); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 88c9ab370c..34344f8022 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -142,6 +142,7 @@ public virtual string Version private BeatmapDifficultyCache difficultyCache; private UserLookupCache userCache; + private BeatmapLookupCache beatmapCache; private FileStore fileStore; @@ -265,6 +266,9 @@ List getBeatmapScores(BeatmapSetInfo set) dependencies.Cache(userCache = new UserLookupCache()); AddInternal(userCache); + dependencies.Cache(beatmapCache = new BeatmapLookupCache()); + AddInternal(beatmapCache); + var scorePerformanceManager = new ScorePerformanceCache(); dependencies.Cache(scorePerformanceManager); AddInternal(scorePerformanceManager); @@ -377,6 +381,13 @@ protected override void LoadComplete() FrameStatistics.ValueChanged += e => fpsDisplayVisible.Value = e.NewValue != FrameStatisticsMode.None; } + protected override void Update() + { + base.Update(); + + realmFactory.Refresh(); + } + protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) => dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); @@ -442,10 +453,6 @@ public void Migrate(string path) protected override Storage CreateStorage(GameHost host, Storage defaultStorage) => new OsuStorage(host, defaultStorage); - private void migrateDataToRealm() - { - } - private void onRulesetChanged(ValueChangedEvent r) { if (r.NewValue?.Available != true) diff --git a/osu.Game/Overlays/OnScreenDisplay.cs b/osu.Game/Overlays/OnScreenDisplay.cs index af6d24fc65..be9d3cd794 100644 --- a/osu.Game/Overlays/OnScreenDisplay.cs +++ b/osu.Game/Overlays/OnScreenDisplay.cs @@ -95,13 +95,13 @@ public void StopTracking(object source, ITrackableConfigManager configManager) /// Displays the provided temporarily. /// /// - public void Display(Toast toast) + public void Display(Toast toast) => Schedule(() => { box.Child = toast; DisplayTemporarily(box); - } + }); - private void displayTrackedSettingChange(SettingDescription description) => Schedule(() => Display(new TrackedSettingToast(description))); + private void displayTrackedSettingChange(SettingDescription description) => Display(new TrackedSettingToast(description)); private TransformSequence fadeIn; private ScheduledDelegate fadeOut; diff --git a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs index 8e739a786f..1e97218074 100644 --- a/osu.Game/Screens/Edit/Setup/ResourcesSection.cs +++ b/osu.Game/Screens/Edit/Setup/ResourcesSection.cs @@ -78,9 +78,9 @@ public bool ChangeBackgroundImage(string path) using (var stream = info.OpenRead()) { if (oldFile != null) - beatmaps.ReplaceFile(set, oldFile, stream, info.Name); - else - beatmaps.AddFile(set, stream, info.Name); + beatmaps.DeleteFile(set, oldFile); + + beatmaps.AddFile(set, stream, info.Name); } working.Value.Metadata.BackgroundFile = info.Name; @@ -105,9 +105,8 @@ public bool ChangeAudioTrack(string path) using (var stream = info.OpenRead()) { if (oldFile != null) - beatmaps.ReplaceFile(set, oldFile, stream, info.Name); - else - beatmaps.AddFile(set, stream, info.Name); + beatmaps.DeleteFile(set, oldFile); + beatmaps.AddFile(set, stream, info.Name); } working.Value.Metadata.AudioFile = info.Name; diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 8d07dd046a..26ff4457af 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -171,7 +171,7 @@ public void Save(Skin skin) var oldFile = skin.SkinInfo.Files.FirstOrDefault(f => f.Filename == filename); if (oldFile != null) - skinModelManager.ReplaceFile(skin.SkinInfo, oldFile, streamContent, oldFile.Filename); + skinModelManager.ReplaceFile(skin.SkinInfo, oldFile, streamContent); else skinModelManager.AddFile(skin.SkinInfo, streamContent, filename); } diff --git a/osu.Game/Stores/RealmArchiveModelImporter.cs b/osu.Game/Stores/RealmArchiveModelImporter.cs index b74670e722..1681dad750 100644 --- a/osu.Game/Stores/RealmArchiveModelImporter.cs +++ b/osu.Game/Stores/RealmArchiveModelImporter.cs @@ -294,12 +294,8 @@ internal static void LogForModel(TModel? model, string message, Exception? e = n /// /// 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) + protected virtual string ComputeHash(TModel item) { - 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(); @@ -374,7 +370,7 @@ protected virtual string ComputeHash(TModel item, ArchiveReader? reader = null) // TODO: look into rollback of file additions (or delayed commit). item.Files.AddRange(createFileInfos(archive, Files, realm)); - item.Hash = ComputeHash(item, archive); + item.Hash = ComputeHash(item); // TODO: we may want to run this outside of the transaction. await Populate(item, archive, realm, cancellationToken).ConfigureAwait(false); @@ -387,7 +383,9 @@ protected virtual string ComputeHash(TModel item, ArchiveReader? reader = null) if (CanReuseExisting(existing, item)) { LogForModel(item, @$"Found existing {HumanisedModelName} for {item} (ID {existing.ID}) – skipping import."); + existing.DeletePending = false; + transaction.Commit(); return existing.ToLive(); } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 2d77e17513..05b7c11e34 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -336,7 +336,7 @@ public async Task AddUserPlaylistItem(int userId, MultiplayerPlaylistItem item) public override Task AddPlaylistItem(MultiplayerPlaylistItem item) => AddUserPlaylistItem(api.LocalUser.Value.OnlineID, item); - protected override Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default) + protected override Task GetAPIBeatmap(int beatmapId, CancellationToken cancellationToken = default) { IBeatmapSetInfo? set = roomManager.ServerSideRooms.SelectMany(r => r.Playlist) .FirstOrDefault(p => p.BeatmapID == beatmapId)?.Beatmap.Value.BeatmapSet @@ -345,13 +345,12 @@ protected override Task GetOnlineBeatmapSet(int beatmapId, Cancel if (set == null) throw new InvalidOperationException("Beatmap not found."); - var apiSet = new APIBeatmapSet + return Task.FromResult(new APIBeatmap { - OnlineID = set.OnlineID, - Beatmaps = set.Beatmaps.Select(b => new APIBeatmap { OnlineID = b.OnlineID }).ToArray(), - }; - - return Task.FromResult(apiSet); + BeatmapSet = new APIBeatmapSet { OnlineID = set.OnlineID }, + OnlineID = beatmapId, + Checksum = set.Beatmaps.First(b => b.OnlineID == beatmapId).MD5Hash + }); } private async Task changeMatchType(MatchType type) diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs index abcf31c007..a4586dea12 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs @@ -25,9 +25,9 @@ public class TestRoomRequestsHandler private readonly List serverSideRooms = new List(); - private int currentRoomId; - private int currentPlaylistItemId; - private int currentScoreId; + private int currentRoomId = 1; + private int currentPlaylistItemId = 1; + private int currentScoreId = 1; /// /// Handles an API request, while also updating the local state to match diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 7cc8893d8d..53a3337c9d 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,8 +36,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/osu.iOS.props b/osu.iOS.props index 9c21f76617..f3dc163a67 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -60,8 +60,8 @@ - - + + @@ -83,7 +83,7 @@ - +