diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 6444127594..985fc09df3 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -27,10 +27,10 @@ ] }, "ppy.localisationanalyser.tools": { - "version": "2021.725.0", + "version": "2021.1210.0", "commands": [ "localisation" ] } } -} \ No newline at end of file +} 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/CONTRIBUTING.md b/CONTRIBUTING.md index e14be20642..ae2bdd2e82 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing Guidelines -Thank you for showing interest in the development of osu!lazer! We aim to provide a good collaborating environment for everyone involved, and as such have decided to list some of the most important things to keep in mind in the process. The guidelines below have been chosen based on past experience. +Thank you for showing interest in the development of osu!. We aim to provide a good collaborating environment for everyone involved, and as such have decided to list some of the most important things to keep in mind in the process. The guidelines below have been chosen based on past experience. These are not "official rules" *per se*, but following them will help everyone deal with things in the most efficient manner. @@ -32,7 +32,7 @@ Issues, bug reports and feature suggestions are welcomed, though please keep in * **Provide more information when asked to do so.** - Sometimes when a bug is more elusive or complicated, none of the information listed above will pinpoint a concrete cause of the problem. In this case we will most likely ask you for additional info, such as a Windows Event Log dump or a copy of your local lazer database (`client.db`). Providing that information is beneficial to both parties - we can track down the problem better, and hopefully fix it for you at some point once we know where it is! + Sometimes when a bug is more elusive or complicated, none of the information listed above will pinpoint a concrete cause of the problem. In this case we will most likely ask you for additional info, such as a Windows Event Log dump or a copy of your local osu! database (`client.db`). Providing that information is beneficial to both parties - we can track down the problem better, and hopefully fix it for you at some point once we know where it is! * **When submitting a feature proposal, please describe it in the most understandable way you can.** @@ -54,7 +54,7 @@ Issues, bug reports and feature suggestions are welcomed, though please keep in We also welcome pull requests from unaffiliated contributors. The [issue tracker](https://github.com/ppy/osu/issues) should provide plenty of issues that you can work on; we also mark issues that we think would be good for newcomers with the [`good-first-issue`](https://github.com/ppy/osu/issues?q=is%3Aissue+is%3Aopen+label%3Agood-first-issue) label. -However, do keep in mind that the core team is committed to bringing osu!lazer up to par with stable first and foremost, so depending on what your contribution concerns, it might not be merged and released right away. Our approach to managing issues and their priorities is described [in the wiki](https://github.com/ppy/osu/wiki/Project-management). +However, do keep in mind that the core team is committed to bringing osu!(lazer) up to par with osu!(stable) first and foremost, so depending on what your contribution concerns, it might not be merged and released right away. Our approach to managing issues and their priorities is described [in the wiki](https://github.com/ppy/osu/wiki/Project-management). Here are some key things to note before jumping in: @@ -128,7 +128,7 @@ Here are some key things to note before jumping in: * **Don't mistake criticism of code for criticism of your person.** - As mentioned before, we are highly committed to quality when it comes to the lazer project. This means that contributions from less experienced community members can take multiple rounds of review to get to a mergeable state. We try our utmost best to never conflate a person with the code they authored, and to keep the discussion focused on the code at all times. Please consider our comments and requests a learning experience, and don't treat it as a personal attack. + As mentioned before, we are highly committed to quality when it comes to the osu! project. This means that contributions from less experienced community members can take multiple rounds of review to get to a mergeable state. We try our utmost best to never conflate a person with the code they authored, and to keep the discussion focused on the code at all times. Please consider our comments and requests a learning experience, and don't treat it as a personal attack. * **Feel free to reach out for help.** 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/README.md b/README.md index 786ce2589d..f18c5e76f9 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ A free-to-win rhythm game. Rhythm is just a *click* away! -The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! Currently known by and released under the codename "*lazer*". As in sharper than cutting-edge. +The future of [osu!](https://osu.ppy.sh) and the beginning of an open era! Currently known by and released under the release codename "*lazer*". As in sharper than cutting-edge. ## Status @@ -48,7 +48,7 @@ You can see some examples of custom rulesets by visiting the [custom ruleset dir Please make sure you have the following prerequisites: -- A desktop platform with the [.NET 5.0 SDK](https://dotnet.microsoft.com/download) or higher installed. +- A desktop platform with the [.NET 5.0 SDK](https://dotnet.microsoft.com/download) installed. - When developing with mobile, [Xamarin](https://docs.microsoft.com/en-us/xamarin/) is required, which is shipped together with Visual Studio or [Visual Studio for Mac](https://visualstudio.microsoft.com/vs/mac/). - When working with the codebase, we recommend using an IDE with intelligent code completion and syntax highlighting, such as [Visual Studio 2019+](https://visualstudio.microsoft.com/vs/), [JetBrains Rider](https://www.jetbrains.com/rider/) or [Visual Studio Code](https://code.visualstudio.com/). - When running on Linux, please have a system-wide FFmpeg installation available to support video decoding. diff --git a/osu.Android.props b/osu.Android.props index eff0eed278..1532d4ce23 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,8 +51,8 @@ - - + + diff --git a/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationRequest.cs b/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationRequest.cs new file mode 100644 index 0000000000..d6ef390a8f --- /dev/null +++ b/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationRequest.cs @@ -0,0 +1,18 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Desktop.LegacyIpc +{ + /// + /// A difficulty calculation request from the legacy client. + /// + /// + /// Synchronise any changes with osu!stable. + /// + public class LegacyIpcDifficultyCalculationRequest + { + public string BeatmapFile { get; set; } + public int RulesetId { get; set; } + public int Mods { get; set; } + } +} diff --git a/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationResponse.cs b/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationResponse.cs new file mode 100644 index 0000000000..7b9fae5797 --- /dev/null +++ b/osu.Desktop/LegacyIpc/LegacyIpcDifficultyCalculationResponse.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Desktop.LegacyIpc +{ + /// + /// A difficulty calculation response returned to the legacy client. + /// + /// + /// Synchronise any changes with osu!stable. + /// + public class LegacyIpcDifficultyCalculationResponse + { + public double StarRating { get; set; } + } +} diff --git a/osu.Desktop/LegacyIpc/LegacyIpcMessage.cs b/osu.Desktop/LegacyIpc/LegacyIpcMessage.cs new file mode 100644 index 0000000000..0fa60e2068 --- /dev/null +++ b/osu.Desktop/LegacyIpc/LegacyIpcMessage.cs @@ -0,0 +1,53 @@ +// 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.Platform; +using Newtonsoft.Json.Linq; + +namespace osu.Desktop.LegacyIpc +{ + /// + /// An that can be used to communicate to and from legacy clients. + /// + /// In order to deserialise types at either end, types must be serialised as their , + /// however this cannot be done since osu!stable and osu!lazer live in two different assemblies. + ///
+ /// To get around this, this class exists which serialises a payload () as an type, + /// which can be deserialised at either end because it is part of the core library (mscorlib / System.Private.CorLib). + /// The payload contains the data to be sent over the IPC channel. + ///
+ /// At either end, Json.NET deserialises the payload into a which is manually converted back into the expected type, + /// which then further contains another representing the data sent over the IPC channel whose type can likewise be lazily matched through + /// . + ///
+ ///
+ /// + /// Synchronise any changes with osu-stable. + /// + public class LegacyIpcMessage : IpcMessage + { + public LegacyIpcMessage() + { + // Types/assemblies are not inter-compatible, so always serialise/deserialise into objects. + base.Type = typeof(object).FullName; + } + + public new string Type => base.Type; // Hide setter. + + public new object Value + { + get => base.Value; + set => base.Value = new Data + { + MessageType = value.GetType().Name, + MessageData = value + }; + } + + public class Data + { + public string MessageType { get; set; } + public object MessageData { get; set; } + } + } +} diff --git a/osu.Desktop/LegacyIpc/LegacyTcpIpcProvider.cs b/osu.Desktop/LegacyIpc/LegacyTcpIpcProvider.cs new file mode 100644 index 0000000000..97a4c57bf0 --- /dev/null +++ b/osu.Desktop/LegacyIpc/LegacyTcpIpcProvider.cs @@ -0,0 +1,121 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using Newtonsoft.Json.Linq; +using osu.Framework.Logging; +using osu.Framework.Platform; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Legacy; +using osu.Game.Rulesets; +using osu.Game.Rulesets.Catch; +using osu.Game.Rulesets.Mania; +using osu.Game.Rulesets.Mods; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Taiko; + +#nullable enable + +namespace osu.Desktop.LegacyIpc +{ + /// + /// Provides IPC to legacy osu! clients. + /// + public class LegacyTcpIpcProvider : TcpIpcProvider + { + private static readonly Logger logger = Logger.GetLogger("legacy-ipc"); + + public LegacyTcpIpcProvider() + : base(45357) + { + MessageReceived += msg => + { + try + { + logger.Add("Processing legacy IPC message..."); + logger.Add($" {msg.Value}", LogLevel.Debug); + + // See explanation in LegacyIpcMessage for why this is done this way. + var legacyData = ((JObject)msg.Value).ToObject(); + object value = parseObject((JObject)legacyData!.MessageData, legacyData.MessageType); + + return new LegacyIpcMessage + { + Value = onLegacyIpcMessageReceived(value) + }; + } + catch (Exception ex) + { + logger.Add($"Processing IPC message failed: {msg.Value}", exception: ex); + return null; + } + }; + } + + private object parseObject(JObject value, string type) + { + switch (type) + { + case nameof(LegacyIpcDifficultyCalculationRequest): + return value.ToObject() + ?? throw new InvalidOperationException($"Failed to parse request {value}"); + + case nameof(LegacyIpcDifficultyCalculationResponse): + return value.ToObject() + ?? throw new InvalidOperationException($"Failed to parse request {value}"); + + default: + throw new ArgumentException($"Unsupported object type {type}"); + } + } + + private object onLegacyIpcMessageReceived(object message) + { + switch (message) + { + case LegacyIpcDifficultyCalculationRequest req: + try + { + var ruleset = getLegacyRulesetFromID(req.RulesetId); + + Mod[] mods = ruleset.ConvertFromLegacyMods((LegacyMods)req.Mods).ToArray(); + WorkingBeatmap beatmap = new FlatFileWorkingBeatmap(req.BeatmapFile, _ => ruleset); + + return new LegacyIpcDifficultyCalculationResponse + { + StarRating = ruleset.CreateDifficultyCalculator(beatmap).Calculate(mods).StarRating + }; + } + catch + { + return new LegacyIpcDifficultyCalculationResponse(); + } + + default: + throw new ArgumentException($"Unsupported message type {message}"); + } + } + + private static Ruleset getLegacyRulesetFromID(int rulesetId) + { + switch (rulesetId) + { + case 0: + return new OsuRuleset(); + + case 1: + return new TaikoRuleset(); + + case 2: + return new CatchRuleset(); + + case 3: + return new ManiaRuleset(); + + default: + throw new ArgumentException("Invalid ruleset id"); + } + } + } +} diff --git a/osu.Desktop/OsuGameDesktop.cs b/osu.Desktop/OsuGameDesktop.cs index 645ea66654..b234207848 100644 --- a/osu.Desktop/OsuGameDesktop.cs +++ b/osu.Desktop/OsuGameDesktop.cs @@ -70,7 +70,9 @@ namespace osu.Desktop if (!string.IsNullOrEmpty(stableInstallPath) && checkExists(stableInstallPath)) return stableInstallPath; } - catch { } + catch + { + } } stableInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"osu!"); @@ -113,7 +115,7 @@ namespace osu.Desktop base.LoadComplete(); if (!noVersionOverlay) - LoadComponentAsync(versionManager = new VersionManager { Depth = int.MinValue }, Add); + LoadComponentAsync(versionManager = new VersionManager { Depth = int.MinValue }, ScreenContainer.Add); LoadComponentAsync(new DiscordRichPresence(), Add); diff --git a/osu.Desktop/Program.cs b/osu.Desktop/Program.cs index 898f7d5105..7ec7d53a7e 100644 --- a/osu.Desktop/Program.cs +++ b/osu.Desktop/Program.cs @@ -5,6 +5,7 @@ using System; using System.IO; using System.Threading; using System.Threading.Tasks; +using osu.Desktop.LegacyIpc; using osu.Framework; using osu.Framework.Development; using osu.Framework.Logging; @@ -18,8 +19,10 @@ namespace osu.Desktop { private const string base_game_name = @"osu"; + private static LegacyTcpIpcProvider legacyIpc; + [STAThread] - public static int Main(string[] args) + public static void Main(string[] args) { // Back up the cwd before DesktopGameHost changes it string cwd = Environment.CurrentDirectory; @@ -69,14 +72,28 @@ namespace osu.Desktop throw new TimeoutException(@"IPC took too long to send"); } - return 0; + return; } // we want to allow multiple instances to be started when in debug. if (!DebugUtils.IsDebugBuild) { Logger.Log(@"osu! does not support multiple running instances.", LoggingTarget.Runtime, LogLevel.Error); - return 0; + return; + } + } + + if (host.IsPrimaryInstance) + { + try + { + Logger.Log("Starting legacy IPC provider..."); + legacyIpc = new LegacyTcpIpcProvider(); + legacyIpc.Bind(); + } + catch (Exception ex) + { + Logger.Error(ex, "Failed to start legacy IPC provider"); } } @@ -84,8 +101,6 @@ namespace osu.Desktop host.Run(new TournamentGame()); else host.Run(new OsuGameDesktop(args)); - - return 0; } } diff --git a/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist b/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist index 5115746cbb..3ba1886d98 100644 --- a/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Catch.Tests.iOS/Info.plist @@ -32,5 +32,7 @@ XSAppIconAssets Assets.xcassets/AppIcon.appiconset + CADisableMinimumFrameDurationOnPhone + diff --git a/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs b/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs index 33fdcdaf1e..be1885cfa6 100644 --- a/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs +++ b/osu.Game.Rulesets.Catch.Tests/CatchBeatmapConversionTest.cs @@ -27,6 +27,7 @@ namespace osu.Game.Rulesets.Catch.Tests [TestCase("hardrock-repeat-slider", new[] { typeof(CatchModHardRock) })] [TestCase("hardrock-spinner", new[] { typeof(CatchModHardRock) })] [TestCase("right-bound-hr-offset", new[] { typeof(CatchModHardRock) })] + [TestCase("basic-hyperdash")] public new void Test(string name, params Type[] mods) => base.Test(name, mods); protected override IEnumerable CreateConvertValue(HitObject hitObject) @@ -70,6 +71,7 @@ namespace osu.Game.Rulesets.Catch.Tests HitObject = hitObject; startTime = 0; position = 0; + hyperDash = false; } private double startTime; @@ -88,8 +90,17 @@ namespace osu.Game.Rulesets.Catch.Tests set => position = value; } + private bool hyperDash; + + public bool HyperDash + { + get => (HitObject as PalpableCatchHitObject)?.HyperDash ?? hyperDash; + set => hyperDash = value; + } + public bool Equals(ConvertValue other) => Precision.AlmostEquals(StartTime, other.StartTime, conversion_lenience) - && Precision.AlmostEquals(Position, other.Position, conversion_lenience); + && Precision.AlmostEquals(Position, other.Position, conversion_lenience) + && HyperDash == other.HyperDash; } } diff --git a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs index 70b2c8c82a..14a4d02396 100644 --- a/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs +++ b/osu.Game.Rulesets.Catch.Tests/TestSceneHyperDashColouring.cs @@ -174,7 +174,7 @@ namespace osu.Game.Rulesets.Catch.Tests private Drawable setupSkinHierarchy(Drawable child, ISkin skin) { - var legacySkinProvider = new SkinProvidingContainer(skins.GetSkin(DefaultLegacySkin.Info)); + var legacySkinProvider = new SkinProvidingContainer(skins.GetSkin(DefaultLegacySkin.CreateInfo())); var testSkinProvider = new SkinProvidingContainer(skin); var legacySkinTransformer = new SkinProvidingContainer(new CatchLegacySkinTransformer(testSkinProvider)); diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-expected-conversion.json index b65d54a565..07ceb199bd 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-expected-conversion.json @@ -3,135 +3,168 @@ "StartTime": 500, "Objects": [{ "StartTime": 500, - "Position": 96 + "Position": 96, + "HyperDash": false }, { "StartTime": 562, - "Position": 100.84 + "Position": 100.84, + "HyperDash": false }, { "StartTime": 625, - "Position": 125 + "Position": 125, + "HyperDash": false }, { "StartTime": 687, - "Position": 152.84 + "Position": 152.84, + "HyperDash": false }, { "StartTime": 750, - "Position": 191 + "Position": 191, + "HyperDash": false }, { "StartTime": 812, - "Position": 212.84 + "Position": 212.84, + "HyperDash": false }, { "StartTime": 875, - "Position": 217 + "Position": 217, + "HyperDash": false }, { "StartTime": 937, - "Position": 234.84 + "Position": 234.84, + "HyperDash": false }, { "StartTime": 1000, - "Position": 256 + "Position": 256, + "HyperDash": false }, { "StartTime": 1062, - "Position": 267.84 + "Position": 267.84, + "HyperDash": false }, { "StartTime": 1125, - "Position": 284 + "Position": 284, + "HyperDash": false }, { "StartTime": 1187, - "Position": 311.84 + "Position": 311.84, + "HyperDash": false }, { "StartTime": 1250, - "Position": 350 + "Position": 350, + "HyperDash": false }, { "StartTime": 1312, - "Position": 359.84 + "Position": 359.84, + "HyperDash": false }, { "StartTime": 1375, - "Position": 367 + "Position": 367, + "HyperDash": false }, { "StartTime": 1437, - "Position": 400.84 + "Position": 400.84, + "HyperDash": false }, { "StartTime": 1500, - "Position": 416 + "Position": 416, + "HyperDash": false }, { "StartTime": 1562, - "Position": 377.159973 + "Position": 377.159973, + "HyperDash": false }, { "StartTime": 1625, - "Position": 367 + "Position": 367, + "HyperDash": false }, { "StartTime": 1687, - "Position": 374.159973 + "Position": 374.159973, + "HyperDash": false }, { "StartTime": 1750, - "Position": 353 + "Position": 353, + "HyperDash": false }, { "StartTime": 1812, - "Position": 329.159973 + "Position": 329.159973, + "HyperDash": false }, { "StartTime": 1875, - "Position": 288 + "Position": 288, + "HyperDash": false }, { "StartTime": 1937, - "Position": 259.159973 + "Position": 259.159973, + "HyperDash": false }, { "StartTime": 2000, - "Position": 256 + "Position": 256, + "HyperDash": false }, { "StartTime": 2058, - "Position": 232.44 + "Position": 232.44, + "HyperDash": false }, { "StartTime": 2116, - "Position": 222.879974 + "Position": 222.879974, + "HyperDash": false }, { "StartTime": 2174, - "Position": 185.319992 + "Position": 185.319992, + "HyperDash": false }, { "StartTime": 2232, - "Position": 177.76001 + "Position": 177.76001, + "HyperDash": false }, { "StartTime": 2290, - "Position": 162.200012 + "Position": 162.200012, + "HyperDash": false }, { "StartTime": 2348, - "Position": 158.639984 + "Position": 158.639984, + "HyperDash": false }, { "StartTime": 2406, - "Position": 111.079994 + "Position": 111.079994, + "HyperDash": false }, { "StartTime": 2500, - "Position": 96 + "Position": 96, + "HyperDash": false } ] }, @@ -139,71 +172,88 @@ "StartTime": 3000, "Objects": [{ "StartTime": 3000, - "Position": 18 + "Position": 18, + "HyperDash": false }, { "StartTime": 3062, - "Position": 249 + "Position": 249, + "HyperDash": false }, { "StartTime": 3125, - "Position": 184 + "Position": 184, + "HyperDash": false }, { "StartTime": 3187, - "Position": 477 + "Position": 477, + "HyperDash": false }, { "StartTime": 3250, - "Position": 43 + "Position": 43, + "HyperDash": false }, { "StartTime": 3312, - "Position": 494 + "Position": 494, + "HyperDash": false }, { "StartTime": 3375, - "Position": 135 + "Position": 135, + "HyperDash": false }, { "StartTime": 3437, - "Position": 30 + "Position": 30, + "HyperDash": false }, { "StartTime": 3500, - "Position": 11 + "Position": 11, + "HyperDash": false }, { "StartTime": 3562, - "Position": 239 + "Position": 239, + "HyperDash": false }, { "StartTime": 3625, - "Position": 505 + "Position": 505, + "HyperDash": false }, { "StartTime": 3687, - "Position": 353 + "Position": 353, + "HyperDash": false }, { "StartTime": 3750, - "Position": 136 + "Position": 136, + "HyperDash": false }, { "StartTime": 3812, - "Position": 135 + "Position": 135, + "HyperDash": false }, { "StartTime": 3875, - "Position": 346 + "Position": 346, + "HyperDash": false }, { "StartTime": 3937, - "Position": 39 + "Position": 39, + "HyperDash": false }, { "StartTime": 4000, - "Position": 300 + "Position": 300, + "HyperDash": false } ] }, @@ -211,71 +261,88 @@ "StartTime": 4500, "Objects": [{ "StartTime": 4500, - "Position": 398 + "Position": 398, + "HyperDash": false }, { "StartTime": 4562, - "Position": 151 + "Position": 151, + "HyperDash": false }, { "StartTime": 4625, - "Position": 73 + "Position": 73, + "HyperDash": false }, { "StartTime": 4687, - "Position": 311 + "Position": 311, + "HyperDash": false }, { "StartTime": 4750, - "Position": 90 + "Position": 90, + "HyperDash": false }, { "StartTime": 4812, - "Position": 264 + "Position": 264, + "HyperDash": false }, { "StartTime": 4875, - "Position": 477 + "Position": 477, + "HyperDash": false }, { "StartTime": 4937, - "Position": 473 + "Position": 473, + "HyperDash": false }, { "StartTime": 5000, - "Position": 120 + "Position": 120, + "HyperDash": false }, { "StartTime": 5062, - "Position": 115 + "Position": 115, + "HyperDash": false }, { "StartTime": 5125, - "Position": 163 + "Position": 163, + "HyperDash": false }, { "StartTime": 5187, - "Position": 447 + "Position": 447, + "HyperDash": false }, { "StartTime": 5250, - "Position": 72 + "Position": 72, + "HyperDash": false }, { "StartTime": 5312, - "Position": 257 + "Position": 257, + "HyperDash": false }, { "StartTime": 5375, - "Position": 153 + "Position": 153, + "HyperDash": false }, { "StartTime": 5437, - "Position": 388 + "Position": 388, + "HyperDash": false }, { "StartTime": 5500, - "Position": 336 + "Position": 336, + "HyperDash": false } ] }, @@ -283,39 +350,48 @@ "StartTime": 6000, "Objects": [{ "StartTime": 6000, - "Position": 13 + "Position": 13, + "HyperDash": false }, { "StartTime": 6062, - "Position": 429 + "Position": 429, + "HyperDash": false }, { "StartTime": 6125, - "Position": 381 + "Position": 381, + "HyperDash": false }, { "StartTime": 6187, - "Position": 186 + "Position": 186, + "HyperDash": false }, { "StartTime": 6250, - "Position": 267 + "Position": 267, + "HyperDash": false }, { "StartTime": 6312, - "Position": 305 + "Position": 305, + "HyperDash": false }, { "StartTime": 6375, - "Position": 456 + "Position": 456, + "HyperDash": false }, { "StartTime": 6437, - "Position": 26 + "Position": 26, + "HyperDash": false }, { "StartTime": 6500, - "Position": 238 + "Position": 238, + "HyperDash": false } ] }, @@ -323,71 +399,88 @@ "StartTime": 7000, "Objects": [{ "StartTime": 7000, - "Position": 256 + "Position": 256, + "HyperDash": false }, { "StartTime": 7062, - "Position": 262.84 + "Position": 262.84, + "HyperDash": false }, { "StartTime": 7125, - "Position": 295 + "Position": 295, + "HyperDash": false }, { "StartTime": 7187, - "Position": 303.84 + "Position": 303.84, + "HyperDash": false }, { "StartTime": 7250, - "Position": 336 + "Position": 336, + "HyperDash": false }, { "StartTime": 7312, - "Position": 319.16 + "Position": 319.16, + "HyperDash": false }, { "StartTime": 7375, - "Position": 306 + "Position": 306, + "HyperDash": false }, { "StartTime": 7437, - "Position": 272.16 + "Position": 272.16, + "HyperDash": false }, { "StartTime": 7500, - "Position": 256 + "Position": 256, + "HyperDash": false }, { "StartTime": 7562, - "Position": 255.84 + "Position": 255.84, + "HyperDash": false }, { "StartTime": 7625, - "Position": 300 + "Position": 300, + "HyperDash": false }, { "StartTime": 7687, - "Position": 320.84 + "Position": 320.84, + "HyperDash": false }, { "StartTime": 7750, - "Position": 336 + "Position": 336, + "HyperDash": false }, { "StartTime": 7803, - "Position": 319.04 + "Position": 319.04, + "HyperDash": false }, { "StartTime": 7857, - "Position": 283.76 + "Position": 283.76, + "HyperDash": false }, { "StartTime": 7910, - "Position": 265.8 + "Position": 265.8, + "HyperDash": false }, { "StartTime": 8000, - "Position": 256 + "Position": 256, + "HyperDash": false } ] }, @@ -395,167 +488,208 @@ "StartTime": 8500, "Objects": [{ "StartTime": 8500, - "Position": 32 + "Position": 32, + "HyperDash": false }, { "StartTime": 8562, - "Position": 21.8515015 + "Position": 21.8515015, + "HyperDash": false }, { "StartTime": 8625, - "Position": 44.5659637 + "Position": 44.5659637, + "HyperDash": false }, { "StartTime": 8687, - "Position": 33.3433228 + "Position": 33.3433228, + "HyperDash": false }, { "StartTime": 8750, - "Position": 63.58974 + "Position": 63.58974, + "HyperDash": false }, { "StartTime": 8812, - "Position": 71.23422 + "Position": 71.23422, + "HyperDash": false }, { "StartTime": 8875, - "Position": 62.7117844 + "Position": 62.7117844, + "HyperDash": false }, { "StartTime": 8937, - "Position": 65.52607 + "Position": 65.52607, + "HyperDash": false }, { "StartTime": 9000, - "Position": 101.81015 + "Position": 101.81015, + "HyperDash": false }, { "StartTime": 9062, - "Position": 134.47818 + "Position": 134.47818, + "HyperDash": false }, { "StartTime": 9125, - "Position": 141.414444 + "Position": 141.414444, + "HyperDash": false }, { "StartTime": 9187, - "Position": 164.1861 + "Position": 164.1861, + "HyperDash": false }, { "StartTime": 9250, - "Position": 176.600418 + "Position": 176.600418, + "HyperDash": false }, { "StartTime": 9312, - "Position": 184.293015 + "Position": 184.293015, + "HyperDash": false }, { "StartTime": 9375, - "Position": 212.2076 + "Position": 212.2076, + "HyperDash": false }, { "StartTime": 9437, - "Position": 236.438324 + "Position": 236.438324, + "HyperDash": false }, { "StartTime": 9500, - "Position": 237.2304 + "Position": 237.2304, + "HyperDash": false }, { "StartTime": 9562, - "Position": 241.253983 + "Position": 241.253983, + "HyperDash": false }, { "StartTime": 9625, - "Position": 233.950623 + "Position": 233.950623, + "HyperDash": false }, { "StartTime": 9687, - "Position": 265.3786 + "Position": 265.3786, + "HyperDash": false }, { "StartTime": 9750, - "Position": 236.8865 + "Position": 236.8865, + "HyperDash": false }, { "StartTime": 9812, - "Position": 273.38974 + "Position": 273.38974, + "HyperDash": false }, { "StartTime": 9875, - "Position": 267.701874 + "Position": 267.701874, + "HyperDash": false }, { "StartTime": 9937, - "Position": 263.2331 + "Position": 263.2331, + "HyperDash": false }, { "StartTime": 10000, - "Position": 270.339874 + "Position": 270.339874, + "HyperDash": false }, { "StartTime": 10062, - "Position": 291.9349 + "Position": 291.9349, + "HyperDash": false }, { "StartTime": 10125, - "Position": 294.2969 + "Position": 294.2969, + "HyperDash": false }, { "StartTime": 10187, - "Position": 307.834137 + "Position": 307.834137, + "HyperDash": false }, { "StartTime": 10250, - "Position": 310.6449 + "Position": 310.6449, + "HyperDash": false }, { "StartTime": 10312, - "Position": 344.746338 + "Position": 344.746338, + "HyperDash": false }, { "StartTime": 10375, - "Position": 349.21875 + "Position": 349.21875, + "HyperDash": false }, { "StartTime": 10437, - "Position": 373.943 + "Position": 373.943, + "HyperDash": false }, { "StartTime": 10500, - "Position": 401.0588 + "Position": 401.0588, + "HyperDash": false }, { "StartTime": 10558, - "Position": 421.21347 + "Position": 421.21347, + "HyperDash": false }, { "StartTime": 10616, - "Position": 431.6034 + "Position": 431.6034, + "HyperDash": false }, { "StartTime": 10674, - "Position": 433.835754 + "Position": 433.835754, + "HyperDash": false }, { "StartTime": 10732, - "Position": 452.5042 + "Position": 452.5042, + "HyperDash": false }, { "StartTime": 10790, - "Position": 486.290955 + "Position": 486.290955, + "HyperDash": false }, { "StartTime": 10848, - "Position": 488.943237 + "Position": 488.943237, + "HyperDash": false }, { "StartTime": 10906, - "Position": 493.3372 + "Position": 493.3372, + "HyperDash": false }, { "StartTime": 10999, - "Position": 508.166229 + "Position": 508.166229, + "HyperDash": false } ] }, @@ -563,39 +697,48 @@ "StartTime": 11500, "Objects": [{ "StartTime": 11500, - "Position": 97 + "Position": 97, + "HyperDash": false }, { "StartTime": 11562, - "Position": 267 + "Position": 267, + "HyperDash": false }, { "StartTime": 11625, - "Position": 116 + "Position": 116, + "HyperDash": false }, { "StartTime": 11687, - "Position": 451 + "Position": 451, + "HyperDash": false }, { "StartTime": 11750, - "Position": 414 + "Position": 414, + "HyperDash": false }, { "StartTime": 11812, - "Position": 88 + "Position": 88, + "HyperDash": false }, { "StartTime": 11875, - "Position": 257 + "Position": 257, + "HyperDash": false }, { "StartTime": 11937, - "Position": 175 + "Position": 175, + "HyperDash": false }, { "StartTime": 12000, - "Position": 38 + "Position": 38, + "HyperDash": false } ] }, @@ -603,263 +746,328 @@ "StartTime": 12500, "Objects": [{ "StartTime": 12500, - "Position": 512 + "Position": 512, + "HyperDash": false }, { "StartTime": 12562, - "Position": 494.3132 + "Position": 494.3132, + "HyperDash": false }, { "StartTime": 12625, - "Position": 461.3089 + "Position": 461.3089, + "HyperDash": false }, { "StartTime": 12687, - "Position": 469.6221 + "Position": 469.6221, + "HyperDash": false }, { "StartTime": 12750, - "Position": 441.617767 + "Position": 441.617767, + "HyperDash": false }, { "StartTime": 12812, - "Position": 402.930969 + "Position": 402.930969, + "HyperDash": false }, { "StartTime": 12875, - "Position": 407.926666 + "Position": 407.926666, + "HyperDash": false }, { "StartTime": 12937, - "Position": 364.239868 + "Position": 364.239868, + "HyperDash": false }, { "StartTime": 13000, - "Position": 353.235535 + "Position": 353.235535, + "HyperDash": false }, { "StartTime": 13062, - "Position": 320.548767 + "Position": 320.548767, + "HyperDash": false }, { "StartTime": 13125, - "Position": 303.544434 + "Position": 303.544434, + "HyperDash": false }, { "StartTime": 13187, - "Position": 295.857635 + "Position": 295.857635, + "HyperDash": false }, { "StartTime": 13250, - "Position": 265.853333 + "Position": 265.853333, + "HyperDash": false }, { "StartTime": 13312, - "Position": 272.166534 + "Position": 272.166534, + "HyperDash": false }, { "StartTime": 13375, - "Position": 240.1622 + "Position": 240.1622, + "HyperDash": false }, { "StartTime": 13437, - "Position": 229.4754 + "Position": 229.4754, + "HyperDash": false }, { "StartTime": 13500, - "Position": 194.471069 + "Position": 194.471069, + "HyperDash": false }, { "StartTime": 13562, - "Position": 158.784271 + "Position": 158.784271, + "HyperDash": false }, { "StartTime": 13625, - "Position": 137.779968 + "Position": 137.779968, + "HyperDash": false }, { "StartTime": 13687, - "Position": 147.09314 + "Position": 147.09314, + "HyperDash": false }, { "StartTime": 13750, - "Position": 122.088837 + "Position": 122.088837, + "HyperDash": false }, { "StartTime": 13812, - "Position": 77.40204 + "Position": 77.40204, + "HyperDash": false }, { "StartTime": 13875, - "Position": 79.3977356 + "Position": 79.3977356, + "HyperDash": false }, { "StartTime": 13937, - "Position": 56.710907 + "Position": 56.710907, + "HyperDash": false }, { "StartTime": 14000, - "Position": 35.7066345 + "Position": 35.7066345, + "HyperDash": false }, { "StartTime": 14062, - "Position": 1.01980591 + "Position": 1.01980591, + "HyperDash": false }, { "StartTime": 14125, - "Position": 0 + "Position": 0, + "HyperDash": false }, { "StartTime": 14187, - "Position": 21.7696266 + "Position": 21.7696266, + "HyperDash": false }, { "StartTime": 14250, - "Position": 49.0119171 + "Position": 49.0119171, + "HyperDash": false }, { "StartTime": 14312, - "Position": 48.9488258 + "Position": 48.9488258, + "HyperDash": false }, { "StartTime": 14375, - "Position": 87.19112 + "Position": 87.19112, + "HyperDash": false }, { "StartTime": 14437, - "Position": 97.12803 + "Position": 97.12803, + "HyperDash": false }, { "StartTime": 14500, - "Position": 118.370323 + "Position": 118.370323, + "HyperDash": false }, { "StartTime": 14562, - "Position": 130.307236 + "Position": 130.307236, + "HyperDash": false }, { "StartTime": 14625, - "Position": 154.549515 + "Position": 154.549515, + "HyperDash": false }, { "StartTime": 14687, - "Position": 190.486435 + "Position": 190.486435, + "HyperDash": false }, { "StartTime": 14750, - "Position": 211.728714 + "Position": 211.728714, + "HyperDash": false }, { "StartTime": 14812, - "Position": 197.665634 + "Position": 197.665634, + "HyperDash": false }, { "StartTime": 14875, - "Position": 214.907928 + "Position": 214.907928, + "HyperDash": false }, { "StartTime": 14937, - "Position": 263.844849 + "Position": 263.844849, + "HyperDash": false }, { "StartTime": 15000, - "Position": 271.087128 + "Position": 271.087128, + "HyperDash": false }, { "StartTime": 15062, - "Position": 270.024017 + "Position": 270.024017, + "HyperDash": false }, { "StartTime": 15125, - "Position": 308.266327 + "Position": 308.266327, + "HyperDash": false }, { "StartTime": 15187, - "Position": 313.203247 + "Position": 313.203247, + "HyperDash": false }, { "StartTime": 15250, - "Position": 328.445526 + "Position": 328.445526, + "HyperDash": false }, { "StartTime": 15312, - "Position": 370.382446 + "Position": 370.382446, + "HyperDash": false }, { "StartTime": 15375, - "Position": 387.624725 + "Position": 387.624725, + "HyperDash": false }, { "StartTime": 15437, - "Position": 421.561646 + "Position": 421.561646, + "HyperDash": false }, { "StartTime": 15500, - "Position": 423.803925 + "Position": 423.803925, + "HyperDash": false }, { "StartTime": 15562, - "Position": 444.740845 + "Position": 444.740845, + "HyperDash": false }, { "StartTime": 15625, - "Position": 469.983124 + "Position": 469.983124, + "HyperDash": false }, { "StartTime": 15687, - "Position": 473.920044 + "Position": 473.920044, + "HyperDash": false }, { "StartTime": 15750, - "Position": 501.162323 + "Position": 501.162323, + "HyperDash": false }, { "StartTime": 15812, - "Position": 488.784332 + "Position": 488.784332, + "HyperDash": false }, { "StartTime": 15875, - "Position": 466.226227 + "Position": 466.226227, + "HyperDash": false }, { "StartTime": 15937, - "Position": 445.978638 + "Position": 445.978638, + "HyperDash": false }, { "StartTime": 16000, - "Position": 446.420532 + "Position": 446.420532, + "HyperDash": false }, { "StartTime": 16058, - "Position": 428.4146 + "Position": 428.4146, + "HyperDash": false }, { "StartTime": 16116, - "Position": 420.408844 + "Position": 420.408844, + "HyperDash": false }, { "StartTime": 16174, - "Position": 374.402924 + "Position": 374.402924, + "HyperDash": false }, { "StartTime": 16232, - "Position": 371.397156 + "Position": 371.397156, + "HyperDash": false }, { "StartTime": 16290, - "Position": 350.391235 + "Position": 350.391235, + "HyperDash": false }, { "StartTime": 16348, - "Position": 340.385468 + "Position": 340.385468, + "HyperDash": false }, { "StartTime": 16406, - "Position": 337.3797 + "Position": 337.3797, + "HyperDash": false }, { "StartTime": 16500, - "Position": 291.1977 + "Position": 291.1977, + "HyperDash": false } ] }, @@ -867,71 +1075,88 @@ "StartTime": 17000, "Objects": [{ "StartTime": 17000, - "Position": 256 + "Position": 256, + "HyperDash": false }, { "StartTime": 17062, - "Position": 247.16 + "Position": 247.16, + "HyperDash": false }, { "StartTime": 17125, - "Position": 211 + "Position": 211, + "HyperDash": false }, { "StartTime": 17187, - "Position": 183.16 + "Position": 183.16, + "HyperDash": false }, { "StartTime": 17250, - "Position": 176 + "Position": 176, + "HyperDash": false }, { "StartTime": 17312, - "Position": 204.84 + "Position": 204.84, + "HyperDash": false }, { "StartTime": 17375, - "Position": 218 + "Position": 218, + "HyperDash": false }, { "StartTime": 17437, - "Position": 231.84 + "Position": 231.84, + "HyperDash": false }, { "StartTime": 17500, - "Position": 256 + "Position": 256, + "HyperDash": false }, { "StartTime": 17562, - "Position": 229.16 + "Position": 229.16, + "HyperDash": false }, { "StartTime": 17625, - "Position": 227 + "Position": 227, + "HyperDash": false }, { "StartTime": 17687, - "Position": 186.16 + "Position": 186.16, + "HyperDash": false }, { "StartTime": 17750, - "Position": 176 + "Position": 176, + "HyperDash": false }, { "StartTime": 17803, - "Position": 211.959991 + "Position": 211.959991, + "HyperDash": false }, { "StartTime": 17857, - "Position": 197.23999 + "Position": 197.23999, + "HyperDash": false }, { "StartTime": 17910, - "Position": 225.200012 + "Position": 225.200012, + "HyperDash": false }, { "StartTime": 18000, - "Position": 256 + "Position": 256, + "HyperDash": false } ] }, @@ -939,71 +1164,88 @@ "StartTime": 18500, "Objects": [{ "StartTime": 18500, - "Position": 437 + "Position": 437, + "HyperDash": false }, { "StartTime": 18559, - "Position": 289 + "Position": 289, + "HyperDash": false }, { "StartTime": 18618, - "Position": 464 + "Position": 464, + "HyperDash": false }, { "StartTime": 18678, - "Position": 36 + "Position": 36, + "HyperDash": false }, { "StartTime": 18737, - "Position": 378 + "Position": 378, + "HyperDash": false }, { "StartTime": 18796, - "Position": 297 + "Position": 297, + "HyperDash": false }, { "StartTime": 18856, - "Position": 418 + "Position": 418, + "HyperDash": false }, { "StartTime": 18915, - "Position": 329 + "Position": 329, + "HyperDash": false }, { "StartTime": 18975, - "Position": 338 + "Position": 338, + "HyperDash": false }, { "StartTime": 19034, - "Position": 394 + "Position": 394, + "HyperDash": false }, { "StartTime": 19093, - "Position": 40 + "Position": 40, + "HyperDash": false }, { "StartTime": 19153, - "Position": 13 + "Position": 13, + "HyperDash": false }, { "StartTime": 19212, - "Position": 80 + "Position": 80, + "HyperDash": false }, { "StartTime": 19271, - "Position": 138 + "Position": 138, + "HyperDash": false }, { "StartTime": 19331, - "Position": 311 + "Position": 311, + "HyperDash": false }, { "StartTime": 19390, - "Position": 216 + "Position": 216, + "HyperDash": false }, { "StartTime": 19450, - "Position": 310 + "Position": 310, + "HyperDash": false } ] }, @@ -1011,263 +1253,328 @@ "StartTime": 19875, "Objects": [{ "StartTime": 19875, - "Position": 216 + "Position": 216, + "HyperDash": false }, { "StartTime": 19937, - "Position": 228.307053 + "Position": 228.307053, + "HyperDash": false }, { "StartTime": 20000, - "Position": 214.036865 + "Position": 214.036865, + "HyperDash": false }, { "StartTime": 20062, - "Position": 224.312088 + "Position": 224.312088, + "HyperDash": false }, { "StartTime": 20125, - "Position": 253.838928 + "Position": 253.838928, + "HyperDash": false }, { "StartTime": 20187, - "Position": 259.9743 + "Position": 259.9743, + "HyperDash": false }, { "StartTime": 20250, - "Position": 299.999146 + "Position": 299.999146, + "HyperDash": false }, { "StartTime": 20312, - "Position": 289.669067 + "Position": 289.669067, + "HyperDash": false }, { "StartTime": 20375, - "Position": 317.446747 + "Position": 317.446747, + "HyperDash": false }, { "StartTime": 20437, - "Position": 344.750275 + "Position": 344.750275, + "HyperDash": false }, { "StartTime": 20500, - "Position": 328.0156 + "Position": 328.0156, + "HyperDash": false }, { "StartTime": 20562, - "Position": 331.472168 + "Position": 331.472168, + "HyperDash": false }, { "StartTime": 20625, - "Position": 302.165466 + "Position": 302.165466, + "HyperDash": false }, { "StartTime": 20687, - "Position": 303.044617 + "Position": 303.044617, + "HyperDash": false }, { "StartTime": 20750, - "Position": 306.457367 + "Position": 306.457367, + "HyperDash": false }, { "StartTime": 20812, - "Position": 265.220581 + "Position": 265.220581, + "HyperDash": false }, { "StartTime": 20875, - "Position": 270.3294 + "Position": 270.3294, + "HyperDash": false }, { "StartTime": 20937, - "Position": 257.57605 + "Position": 257.57605, + "HyperDash": false }, { "StartTime": 21000, - "Position": 247.803329 + "Position": 247.803329, + "HyperDash": false }, { "StartTime": 21062, - "Position": 225.958359 + "Position": 225.958359, + "HyperDash": false }, { "StartTime": 21125, - "Position": 201.79332 + "Position": 201.79332, + "HyperDash": false }, { "StartTime": 21187, - "Position": 170.948349 + "Position": 170.948349, + "HyperDash": false }, { "StartTime": 21250, - "Position": 146.78334 + "Position": 146.78334, + "HyperDash": false }, { "StartTime": 21312, - "Position": 149.93837 + "Position": 149.93837, + "HyperDash": false }, { "StartTime": 21375, - "Position": 119.121056 + "Position": 119.121056, + "HyperDash": false }, { "StartTime": 21437, - "Position": 133.387573 + "Position": 133.387573, + "HyperDash": false }, { "StartTime": 21500, - "Position": 117.503014 + "Position": 117.503014, + "HyperDash": false }, { "StartTime": 21562, - "Position": 103.749374 + "Position": 103.749374, + "HyperDash": false }, { "StartTime": 21625, - "Position": 127.165535 + "Position": 127.165535, + "HyperDash": false }, { "StartTime": 21687, - "Position": 113.029991 + "Position": 113.029991, + "HyperDash": false }, { "StartTime": 21750, - "Position": 101.547928 + "Position": 101.547928, + "HyperDash": false }, { "StartTime": 21812, - "Position": 133.856232 + "Position": 133.856232, + "HyperDash": false }, { "StartTime": 21875, - "Position": 124.28746 + "Position": 124.28746, + "HyperDash": false }, { "StartTime": 21937, - "Position": 121.754929 + "Position": 121.754929, + "HyperDash": false }, { "StartTime": 22000, - "Position": 155.528732 + "Position": 155.528732, + "HyperDash": false }, { "StartTime": 22062, - "Position": 142.1691 + "Position": 142.1691, + "HyperDash": false }, { "StartTime": 22125, - "Position": 186.802155 + "Position": 186.802155, + "HyperDash": false }, { "StartTime": 22187, - "Position": 198.6452 + "Position": 198.6452, + "HyperDash": false }, { "StartTime": 22250, - "Position": 191.892181 + "Position": 191.892181, + "HyperDash": false }, { "StartTime": 22312, - "Position": 232.713028 + "Position": 232.713028, + "HyperDash": false }, { "StartTime": 22375, - "Position": 240.4715 + "Position": 240.4715, + "HyperDash": false }, { "StartTime": 22437, - "Position": 278.3719 + "Position": 278.3719, + "HyperDash": false }, { "StartTime": 22500, - "Position": 288.907257 + "Position": 288.907257, + "HyperDash": false }, { "StartTime": 22562, - "Position": 297.353119 + "Position": 297.353119, + "HyperDash": false }, { "StartTime": 22625, - "Position": 301.273376 + "Position": 301.273376, + "HyperDash": false }, { "StartTime": 22687, - "Position": 339.98288 + "Position": 339.98288, + "HyperDash": false }, { "StartTime": 22750, - "Position": 353.078552 + "Position": 353.078552, + "HyperDash": false }, { "StartTime": 22812, - "Position": 363.8958 + "Position": 363.8958, + "HyperDash": false }, { "StartTime": 22875, - "Position": 398.054047 + "Position": 398.054047, + "HyperDash": false }, { "StartTime": 22937, - "Position": 419.739441 + "Position": 419.739441, + "HyperDash": false }, { "StartTime": 23000, - "Position": 435.178467 + "Position": 435.178467, + "HyperDash": false }, { "StartTime": 23062, - "Position": 420.8687 + "Position": 420.8687, + "HyperDash": false }, { "StartTime": 23125, - "Position": 448.069977 + "Position": 448.069977, + "HyperDash": false }, { "StartTime": 23187, - "Position": 425.688477 + "Position": 425.688477, + "HyperDash": false }, { "StartTime": 23250, - "Position": 426.9612 + "Position": 426.9612, + "HyperDash": false }, { "StartTime": 23312, - "Position": 454.92807 + "Position": 454.92807, + "HyperDash": false }, { "StartTime": 23375, - "Position": 439.749878 + "Position": 439.749878, + "HyperDash": false }, { "StartTime": 23433, - "Position": 440.644684 + "Position": 440.644684, + "HyperDash": false }, { "StartTime": 23491, - "Position": 445.7359 + "Position": 445.7359, + "HyperDash": false }, { "StartTime": 23549, - "Position": 432.0944 + "Position": 432.0944, + "HyperDash": false }, { "StartTime": 23607, - "Position": 415.796173 + "Position": 415.796173, + "HyperDash": false }, { "StartTime": 23665, - "Position": 407.897461 + "Position": 407.897461, + "HyperDash": false }, { "StartTime": 23723, - "Position": 409.462555 + "Position": 409.462555, + "HyperDash": false }, { "StartTime": 23781, - "Position": 406.53775 + "Position": 406.53775, + "HyperDash": false }, { "StartTime": 23874, - "Position": 408.720825 + "Position": 408.720825, + "HyperDash": false } ] } diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-hyperdash-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-hyperdash-expected-conversion.json new file mode 100644 index 0000000000..b2e9431f13 --- /dev/null +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-hyperdash-expected-conversion.json @@ -0,0 +1,19 @@ +{ + "Mappings": [{ + "StartTime": 369, + "Objects": [{ + "StartTime": 369, + "Position": 0, + "HyperDash": true + }] + }, + { + "StartTime": 450, + "Objects": [{ + "StartTime": 450, + "Position": 512, + "HyperDash": false + }] + } + ] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-hyperdash.osu b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-hyperdash.osu new file mode 100644 index 0000000000..db07f8c30e --- /dev/null +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/basic-hyperdash.osu @@ -0,0 +1,21 @@ +osu file format v14 + +[General] +StackLeniency: 0.7 +Mode: 2 + +[Difficulty] +HPDrainRate:6 +CircleSize:4 +OverallDifficulty:9.6 +ApproachRate:9.6 +SliderMultiplier:1.9 +SliderTickRate:1 + +[TimingPoints] +2169,266.666666666667,4,2,1,70,1,0 + + +[HitObjects] +0,192,369,1,0,0:0:0:0: +512,192,450,1,0,0:0:0:0: diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-repeat-slider-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-repeat-slider-expected-conversion.json index 83f9e30800..081b574c5b 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-repeat-slider-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-repeat-slider-expected-conversion.json @@ -3,147 +3,183 @@ "StartTime": 369, "Objects": [{ "StartTime": 369, - "Position": 177 + "Position": 177, + "HyperDash": false }, { "StartTime": 450, - "Position": 216.539276 + "Position": 216.539276, + "HyperDash": false }, { "StartTime": 532, - "Position": 256.5667 + "Position": 256.5667, + "HyperDash": false }, { "StartTime": 614, - "Position": 296.594116 + "Position": 296.594116, + "HyperDash": false }, { "StartTime": 696, - "Position": 336.621521 + "Position": 336.621521, + "HyperDash": false }, { "StartTime": 778, - "Position": 376.99762 + "Position": 376.99762, + "HyperDash": false }, { "StartTime": 860, - "Position": 337.318878 + "Position": 337.318878, + "HyperDash": false }, { "StartTime": 942, - "Position": 297.291443 + "Position": 297.291443, + "HyperDash": false }, { "StartTime": 1024, - "Position": 257.264038 + "Position": 257.264038, + "HyperDash": false }, { "StartTime": 1106, - "Position": 217.2366 + "Position": 217.2366, + "HyperDash": false }, { "StartTime": 1188, - "Position": 177 + "Position": 177, + "HyperDash": false }, { "StartTime": 1270, - "Position": 216.818192 + "Position": 216.818192, + "HyperDash": false }, { "StartTime": 1352, - "Position": 256.8456 + "Position": 256.8456, + "HyperDash": false }, { "StartTime": 1434, - "Position": 296.873047 + "Position": 296.873047, + "HyperDash": false }, { "StartTime": 1516, - "Position": 336.900452 + "Position": 336.900452, + "HyperDash": false }, { "StartTime": 1598, - "Position": 376.99762 + "Position": 376.99762, + "HyperDash": false }, { "StartTime": 1680, - "Position": 337.039948 + "Position": 337.039948, + "HyperDash": false }, { "StartTime": 1762, - "Position": 297.0125 + "Position": 297.0125, + "HyperDash": false }, { "StartTime": 1844, - "Position": 256.9851 + "Position": 256.9851, + "HyperDash": false }, { "StartTime": 1926, - "Position": 216.957672 + "Position": 216.957672, + "HyperDash": false }, { "StartTime": 2008, - "Position": 177 + "Position": 177, + "HyperDash": false }, { "StartTime": 2090, - "Position": 217.097137 + "Position": 217.097137, + "HyperDash": false }, { "StartTime": 2172, - "Position": 257.124573 + "Position": 257.124573, + "HyperDash": false }, { "StartTime": 2254, - "Position": 297.152 + "Position": 297.152, + "HyperDash": false }, { "StartTime": 2336, - "Position": 337.179443 + "Position": 337.179443, + "HyperDash": false }, { "StartTime": 2418, - "Position": 376.99762 + "Position": 376.99762, + "HyperDash": false }, { "StartTime": 2500, - "Position": 336.760956 + "Position": 336.760956, + "HyperDash": false }, { "StartTime": 2582, - "Position": 296.733643 + "Position": 296.733643, + "HyperDash": false }, { "StartTime": 2664, - "Position": 256.7062 + "Position": 256.7062, + "HyperDash": false }, { "StartTime": 2746, - "Position": 216.678772 + "Position": 216.678772, + "HyperDash": false }, { "StartTime": 2828, - "Position": 177 + "Position": 177, + "HyperDash": false }, { "StartTime": 2909, - "Position": 216.887909 + "Position": 216.887909, + "HyperDash": false }, { "StartTime": 2991, - "Position": 256.915344 + "Position": 256.915344, + "HyperDash": false }, { "StartTime": 3073, - "Position": 296.942749 + "Position": 296.942749, + "HyperDash": false }, { "StartTime": 3155, - "Position": 336.970184 + "Position": 336.970184, + "HyperDash": false }, { "StartTime": 3237, - "Position": 376.99762 + "Position": 376.99762, + "HyperDash": false } ] }] diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-spinner-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-spinner-expected-conversion.json index 7333b600fb..01f474c149 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-spinner-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-spinner-expected-conversion.json @@ -3,71 +3,88 @@ "StartTime": 369, "Objects": [{ "StartTime": 369, - "Position": 65 + "Position": 65, + "HyperDash": false }, { "StartTime": 450, - "Position": 482 + "Position": 482, + "HyperDash": false }, { "StartTime": 532, - "Position": 164 + "Position": 164, + "HyperDash": false }, { "StartTime": 614, - "Position": 315 + "Position": 315, + "HyperDash": false }, { "StartTime": 696, - "Position": 145 + "Position": 145, + "HyperDash": false }, { "StartTime": 778, - "Position": 159 + "Position": 159, + "HyperDash": false }, { "StartTime": 860, - "Position": 310 + "Position": 310, + "HyperDash": false }, { "StartTime": 942, - "Position": 441 + "Position": 441, + "HyperDash": false }, { "StartTime": 1024, - "Position": 428 + "Position": 428, + "HyperDash": false }, { "StartTime": 1106, - "Position": 243 + "Position": 243, + "HyperDash": false }, { "StartTime": 1188, - "Position": 422 + "Position": 422, + "HyperDash": false }, { "StartTime": 1270, - "Position": 481 + "Position": 481, + "HyperDash": false }, { "StartTime": 1352, - "Position": 104 + "Position": 104, + "HyperDash": false }, { "StartTime": 1434, - "Position": 473 + "Position": 473, + "HyperDash": false }, { "StartTime": 1516, - "Position": 135 + "Position": 135, + "HyperDash": false }, { "StartTime": 1598, - "Position": 360 + "Position": 360, + "HyperDash": false }, { "StartTime": 1680, - "Position": 123 + "Position": 123, + "HyperDash": false } ] }] diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-stream-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-stream-expected-conversion.json index bbc16ab912..8eaaf3bb90 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-stream-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/hardrock-stream-expected-conversion.json @@ -3,231 +3,264 @@ "StartTime": 369, "Objects": [{ "StartTime": 369, - "Position": 258 + "Position": 258, + "HyperDash": false }] }, { "StartTime": 450, "Objects": [{ "StartTime": 450, - "Position": 254 + "Position": 254, + "HyperDash": false }] }, { "StartTime": 532, "Objects": [{ "StartTime": 532, - "Position": 241 + "Position": 241, + "HyperDash": false }] }, { "StartTime": 614, "Objects": [{ "StartTime": 614, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 696, "Objects": [{ "StartTime": 696, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 778, "Objects": [{ "StartTime": 778, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 860, "Objects": [{ "StartTime": 860, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 942, "Objects": [{ "StartTime": 942, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 1024, "Objects": [{ "StartTime": 1024, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 1106, "Objects": [{ "StartTime": 1106, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 1188, "Objects": [{ "StartTime": 1188, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 1270, "Objects": [{ "StartTime": 1270, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 1352, "Objects": [{ "StartTime": 1352, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 1434, "Objects": [{ "StartTime": 1434, - "Position": 258 + "Position": 258, + "HyperDash": false }] }, { "StartTime": 1516, "Objects": [{ "StartTime": 1516, - "Position": 253 + "Position": 253, + "HyperDash": false }] }, { "StartTime": 1598, "Objects": [{ "StartTime": 1598, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 1680, "Objects": [{ "StartTime": 1680, - "Position": 260 + "Position": 260, + "HyperDash": false }] }, { "StartTime": 1762, "Objects": [{ "StartTime": 1762, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 1844, "Objects": [{ "StartTime": 1844, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 1926, "Objects": [{ "StartTime": 1926, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 2008, "Objects": [{ "StartTime": 2008, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 2090, "Objects": [{ "StartTime": 2090, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 2172, "Objects": [{ "StartTime": 2172, - "Position": 243 + "Position": 243, + "HyperDash": false }] }, { "StartTime": 2254, "Objects": [{ "StartTime": 2254, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 2336, "Objects": [{ "StartTime": 2336, - "Position": 278 + "Position": 278, + "HyperDash": false }] }, { "StartTime": 2418, "Objects": [{ "StartTime": 2418, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 2500, "Objects": [{ "StartTime": 2500, - "Position": 258 + "Position": 258, + "HyperDash": false }] }, { "StartTime": 2582, "Objects": [{ "StartTime": 2582, - "Position": 256 + "Position": 256, + "HyperDash": false }] }, { "StartTime": 2664, "Objects": [{ "StartTime": 2664, - "Position": 242 + "Position": 242, + "HyperDash": false }] }, { "StartTime": 2746, "Objects": [{ "StartTime": 2746, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 2828, "Objects": [{ "StartTime": 2828, - "Position": 238 + "Position": 238, + "HyperDash": false }] }, { "StartTime": 2909, "Objects": [{ "StartTime": 2909, - "Position": 271 + "Position": 271, + "HyperDash": false }] }, { "StartTime": 2991, "Objects": [{ "StartTime": 2991, - "Position": 254 + "Position": 254, + "HyperDash": false }] } ] diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/right-bound-hr-offset-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/right-bound-hr-offset-expected-conversion.json index 3bde97070c..5060389ad8 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/right-bound-hr-offset-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/right-bound-hr-offset-expected-conversion.json @@ -3,14 +3,16 @@ "StartTime": 3368, "Objects": [{ "StartTime": 3368, - "Position": 374 + "Position": 374, + "HyperDash": false }] }, { "StartTime": 3501, "Objects": [{ "StartTime": 3501, - "Position": 446 + "Position": 446, + "HyperDash": false }] } ] diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/slider-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/slider-expected-conversion.json index 58c52b6867..2378ba5511 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/slider-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/slider-expected-conversion.json @@ -1 +1,71 @@ -{"Mappings":[{"StartTime":19184.0,"Objects":[{"StartTime":19184.0,"Position":320.0},{"StartTime":19263.0,"Position":311.730255},{"StartTime":19343.0,"Position":324.6205},{"StartTime":19423.0,"Position":343.0907},{"StartTime":19503.0,"Position":372.2917},{"StartTime":19582.0,"Position":385.194733},{"StartTime":19662.0,"Position":379.0426},{"StartTime":19742.0,"Position":385.1066},{"StartTime":19822.0,"Position":391.624664},{"StartTime":19919.0,"Position":386.27832},{"StartTime":20016.0,"Position":380.117035},{"StartTime":20113.0,"Position":381.664154},{"StartTime":20247.0,"Position":370.872864}]}]} \ No newline at end of file +{ + "Mappings": [{ + "StartTime": 19184, + "Objects": [{ + "StartTime": 19184, + "Position": 320, + "HyperDash": false + }, + { + "StartTime": 19263, + "Position": 311.730255, + "HyperDash": false + }, + { + "StartTime": 19343, + "Position": 324.6205, + "HyperDash": false + }, + { + "StartTime": 19423, + "Position": 343.0907, + "HyperDash": false + }, + { + "StartTime": 19503, + "Position": 372.2917, + "HyperDash": false + }, + { + "StartTime": 19582, + "Position": 385.194733, + "HyperDash": false + }, + { + "StartTime": 19662, + "Position": 379.0426, + "HyperDash": false + }, + { + "StartTime": 19742, + "Position": 385.1066, + "HyperDash": false + }, + { + "StartTime": 19822, + "Position": 391.624664, + "HyperDash": false + }, + { + "StartTime": 19919, + "Position": 386.27832, + "HyperDash": false + }, + { + "StartTime": 20016, + "Position": 380.117035, + "HyperDash": false + }, + { + "StartTime": 20113, + "Position": 381.664154, + "HyperDash": false + }, + { + "StartTime": 20247, + "Position": 370.872864, + "HyperDash": false + } + ] + }] +} \ No newline at end of file diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-and-circles-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-and-circles-expected-conversion.json index dd81947f1c..abd5b2afd1 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-and-circles-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-and-circles-expected-conversion.json @@ -3,18 +3,21 @@ "StartTime": 2589, "Objects": [{ "StartTime": 2589, - "Position": 256 + "Position": 256, + "HyperDash": false }] }, { "StartTime": 2915, "Objects": [{ "StartTime": 2915, - "Position": 65 + "Position": 65, + "HyperDash": false }, { "StartTime": 2916, - "Position": 482 + "Position": 482, + "HyperDash": false } ] }, @@ -22,11 +25,13 @@ "StartTime": 3078, "Objects": [{ "StartTime": 3078, - "Position": 164 + "Position": 164, + "HyperDash": false }, { "StartTime": 3079, - "Position": 315 + "Position": 315, + "HyperDash": false } ] }, @@ -34,11 +39,13 @@ "StartTime": 3241, "Objects": [{ "StartTime": 3241, - "Position": 145 + "Position": 145, + "HyperDash": false }, { "StartTime": 3242, - "Position": 159 + "Position": 159, + "HyperDash": false } ] }, @@ -46,11 +53,13 @@ "StartTime": 3404, "Objects": [{ "StartTime": 3404, - "Position": 310 + "Position": 310, + "HyperDash": false }, { "StartTime": 3405, - "Position": 441 + "Position": 441, + "HyperDash": false } ] }, @@ -58,7 +67,8 @@ "StartTime": 5197, "Objects": [{ "StartTime": 5197, - "Position": 256 + "Position": 256, + "HyperDash": false }] } ] diff --git a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-expected-conversion.json b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-expected-conversion.json index b69b1ae056..8a7847e065 100644 --- a/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-expected-conversion.json +++ b/osu.Game.Rulesets.Catch/Resources/Testing/Beatmaps/spinner-expected-conversion.json @@ -3,71 +3,88 @@ "StartTime": 18500, "Objects": [{ "StartTime": 18500, - "Position": 65 + "Position": 65, + "HyperDash": false }, { "StartTime": 18559, - "Position": 482 + "Position": 482, + "HyperDash": false }, { "StartTime": 18618, - "Position": 164 + "Position": 164, + "HyperDash": false }, { "StartTime": 18678, - "Position": 315 + "Position": 315, + "HyperDash": false }, { "StartTime": 18737, - "Position": 145 + "Position": 145, + "HyperDash": false }, { "StartTime": 18796, - "Position": 159 + "Position": 159, + "HyperDash": false }, { "StartTime": 18856, - "Position": 310 + "Position": 310, + "HyperDash": false }, { "StartTime": 18915, - "Position": 441 + "Position": 441, + "HyperDash": false }, { "StartTime": 18975, - "Position": 428 + "Position": 428, + "HyperDash": false }, { "StartTime": 19034, - "Position": 243 + "Position": 243, + "HyperDash": false }, { "StartTime": 19093, - "Position": 422 + "Position": 422, + "HyperDash": false }, { "StartTime": 19153, - "Position": 481 + "Position": 481, + "HyperDash": false }, { "StartTime": 19212, - "Position": 104 + "Position": 104, + "HyperDash": false }, { "StartTime": 19271, - "Position": 473 + "Position": 473, + "HyperDash": false }, { "StartTime": 19331, - "Position": 135 + "Position": 135, + "HyperDash": false }, { "StartTime": 19390, - "Position": 360 + "Position": 360, + "HyperDash": false }, { "StartTime": 19450, - "Position": 123 + "Position": 123, + "HyperDash": false } ] }] diff --git a/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist b/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist index 8780204d5b..09ed2dd007 100644 --- a/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Mania.Tests.iOS/Info.plist @@ -32,5 +32,7 @@ XSAppIconAssets Assets.xcassets/AppIcon.appiconset + CADisableMinimumFrameDurationOnPhone + diff --git a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs index 24d2a786a0..a30e09cd29 100644 --- a/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs +++ b/osu.Game.Rulesets.Mania.Tests/Editor/TestSceneManiaComposeScreen.cs @@ -8,6 +8,7 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; +using osu.Game.Database; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Screens.Edit; @@ -55,13 +56,13 @@ namespace osu.Game.Rulesets.Mania.Tests.Editor [Test] public void TestDefaultSkin() { - AddStep("set default skin", () => skins.CurrentSkinInfo.Value = SkinInfo.Default); + AddStep("set default skin", () => skins.CurrentSkinInfo.Value = DefaultSkin.CreateInfo().ToLiveUnmanaged()); } [Test] public void TestLegacySkin() { - AddStep("set legacy skin", () => skins.CurrentSkinInfo.Value = DefaultLegacySkin.Info); + AddStep("set legacy skin", () => skins.CurrentSkinInfo.Value = DefaultLegacySkin.CreateInfo().ToLiveUnmanaged()); } } } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs index 9d1f5429a1..1aa20f4737 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaBeatSnapGrid.cs @@ -9,7 +9,6 @@ using osu.Framework.Bindables; using osu.Framework.Caching; using osu.Framework.Graphics; using osu.Framework.Graphics.Shapes; -using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.UI; @@ -46,12 +45,6 @@ namespace osu.Game.Rulesets.Mania.Edit [Resolved] private EditorBeatmap beatmap { get; set; } - [Resolved] - private IScrollingInfo scrollingInfo { get; set; } - - [Resolved] - private Bindable working { get; set; } - [Resolved] private OsuColour colours { get; set; } diff --git a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs index dc858fb54f..9fe1eb7932 100644 --- a/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs +++ b/osu.Game.Rulesets.Mania/Edit/ManiaSelectionHandler.cs @@ -7,16 +7,12 @@ using osu.Framework.Allocation; using osu.Game.Rulesets.Edit; using osu.Game.Rulesets.Mania.Objects; using osu.Game.Rulesets.Objects; -using osu.Game.Rulesets.UI.Scrolling; using osu.Game.Screens.Edit.Compose.Components; namespace osu.Game.Rulesets.Mania.Edit { public class ManiaSelectionHandler : EditorSelectionHandler { - [Resolved] - private IScrollingInfo scrollingInfo { get; set; } - [Resolved] private HitObjectComposer composer { get; set; } diff --git a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs index 90d3c6c4c7..9f4963b022 100644 --- a/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs +++ b/osu.Game.Rulesets.Mania/UI/PoolableHitExplosion.cs @@ -15,9 +15,6 @@ namespace osu.Game.Rulesets.Mania.UI public JudgementResult Result { get; private set; } - [Resolved] - private Column column { get; set; } - private SkinnableDrawable skinnableExplosion; public PoolableHitExplosion() diff --git a/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist b/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist index f79215cf54..dd032ef1c1 100644 --- a/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Osu.Tests.iOS/Info.plist @@ -32,5 +32,7 @@ XSAppIconAssets Assets.xcassets/AppIcon.appiconset + CADisableMinimumFrameDurationOnPhone + diff --git a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs index e698766aac..d673b7a6ac 100644 --- a/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs +++ b/osu.Game.Rulesets.Osu.Tests/TestSceneSliderApplication.cs @@ -67,7 +67,7 @@ namespace osu.Game.Rulesets.Osu.Tests AddStep("create slider", () => { - var tintingSkin = skinManager.GetSkin(DefaultLegacySkin.Info); + var tintingSkin = skinManager.GetSkin(DefaultLegacySkin.CreateInfo()); tintingSkin.Configuration.ConfigDictionary["AllowSliderBallTint"] = "1"; Child = new SkinProvidingContainer(tintingSkin) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs index d073d751d0..4df8ff0b12 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Preprocessing/OsuDifficultyHitObject.cs @@ -11,49 +11,65 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing { public class OsuDifficultyHitObject : DifficultyHitObject { - private const int normalized_radius = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths. + private const int normalised_radius = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths. private const int min_delta_time = 25; - private const float maximum_slider_radius = normalized_radius * 2.4f; - private const float assumed_slider_radius = normalized_radius * 1.8f; + private const float maximum_slider_radius = normalised_radius * 2.4f; + private const float assumed_slider_radius = normalised_radius * 1.8f; protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject; /// - /// Normalized distance from the end position of the previous to the start position of this . + /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms. /// - public double JumpDistance { get; private set; } + public readonly double StrainTime; /// - /// Minimum distance from the end position of the previous to the start position of this . + /// Normalised distance from the "lazy" end position of the previous to the start position of this . + /// + /// The "lazy" end position is the position at which the cursor ends up if the previous hitobject is followed with as minimal movement as possible (i.e. on the edge of slider follow circles). + /// /// - public double MovementDistance { get; private set; } + public double LazyJumpDistance { get; private set; } /// - /// Normalized distance between the start and end position of the previous . + /// Normalised shortest distance to consider for a jump between the previous and this . + /// + /// + /// This is bounded from above by , and is smaller than the former if a more natural path is able to be taken through the previous . + /// + /// + /// Suppose a linear slider - circle pattern. + ///
+ /// Following the slider lazily (see: ) will result in underestimating the true end position of the slider as being closer towards the start position. + /// As a result, overestimates the jump distance because the player is able to take a more natural path by following through the slider to its end, + /// such that the jump is felt as only starting from the slider's true end position. + ///
+ /// Now consider a slider - circle pattern where the circle is stacked along the path inside the slider. + /// In this case, the lazy end position correctly estimates the true end position of the slider and provides the more natural movement path. + ///
+ public double MinimumJumpDistance { get; private set; } + + /// + /// The time taken to travel through , with a minimum value of 25ms. + /// + public double MinimumJumpTime { get; private set; } + + /// + /// Normalised distance between the start and end position of this . /// public double TravelDistance { get; private set; } + /// + /// The time taken to travel through , with a minimum value of 25ms for a non-zero distance. + /// + public double TravelTime { get; private set; } + /// /// Angle the player has to take to hit this . /// Calculated as the angle between the circles (current-2, current-1, current). /// public double? Angle { get; private set; } - /// - /// Milliseconds elapsed since the end time of the previous , with a minimum of 25ms. - /// - public double MovementTime { get; private set; } - - /// - /// Milliseconds elapsed since the start time of the previous to the end time of the same previous , with a minimum of 25ms. - /// - public double TravelTime { get; private set; } - - /// - /// Milliseconds elapsed since the start time of the previous , with a minimum of 25ms. - /// - public readonly double StrainTime; - private readonly OsuHitObject lastLastObject; private readonly OsuHitObject lastObject; @@ -71,12 +87,19 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing private void setDistances(double clockRate) { + if (BaseObject is Slider currentSlider) + { + computeSliderCursorPosition(currentSlider); + TravelDistance = currentSlider.LazyTravelDistance; + TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, min_delta_time); + } + // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner if (BaseObject is Spinner || lastObject is Spinner) return; // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps. - float scalingFactor = normalized_radius / (float)BaseObject.Radius; + float scalingFactor = normalised_radius / (float)BaseObject.Radius; if (BaseObject.Radius < 30) { @@ -85,29 +108,40 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing } Vector2 lastCursorPosition = getEndCursorPosition(lastObject); - JumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; + + LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; + MinimumJumpTime = StrainTime; + MinimumJumpDistance = LazyJumpDistance; if (lastObject is Slider lastSlider) { - computeSliderCursorPosition(lastSlider); - TravelDistance = lastSlider.LazyTravelDistance; - TravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time); - MovementTime = Math.Max(StrainTime - TravelTime, min_delta_time); + double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time); + MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, min_delta_time); + + // + // There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects. + // + // 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject. + // + // <======o==> ← slider + // | ← most natural jump path + // o ← a follow-up hitcircle + // + // In this case the most natural jump path is approximated by LazyJumpDistance. + // + // 2. The flow pattern, where players follow through the slider to its visual extent into the next hitobject. + // + // <======o==>---o + // ↑ + // most natural jump path + // + // In this case the most natural jump path is better approximated by a new distance called "tailJumpDistance" - the distance between the slider's tail and the next hitobject. + // + // Thus, the player is assumed to jump the minimum of these two distances in all cases. + // - // Jump distance from the slider tail to the next object, as opposed to the lazy position of JumpDistance. float tailJumpDistance = Vector2.Subtract(lastSlider.TailCircle.StackedPosition, BaseObject.StackedPosition).Length * scalingFactor; - - // For hitobjects which continue in the direction of the slider, the player will normally follow through the slider, - // such that they're not jumping from the lazy position but rather from very close to (or the end of) the slider. - // In such cases, a leniency is applied by also considering the jump distance from the tail of the slider, and taking the minimum jump distance. - // Additional distance is removed based on position of jump relative to slider follow circle radius. - // JumpDistance is the leniency distance beyond the assumed_slider_radius. tailJumpDistance is maximum_slider_radius since the full distance of radial leniency is still possible. - MovementDistance = Math.Max(0, Math.Min(JumpDistance - (maximum_slider_radius - assumed_slider_radius), tailJumpDistance - maximum_slider_radius)); - } - else - { - MovementTime = StrainTime; - MovementDistance = JumpDistance; + MinimumJumpDistance = Math.Max(0, Math.Min(LazyJumpDistance - (maximum_slider_radius - assumed_slider_radius), tailJumpDistance - maximum_slider_radius)); } if (lastLastObject != null && !(lastLastObject is Spinner)) @@ -139,7 +173,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing slider.LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived. var currCursorPosition = slider.StackedPosition; - double scalingFactor = normalized_radius / slider.Radius; // lazySliderDistance is coded to be sensitive to scaling, this makes the maths easier with the thresholds being used. + double scalingFactor = normalised_radius / slider.Radius; // lazySliderDistance is coded to be sensitive to scaling, this makes the maths easier with the thresholds being used. for (int i = 1; i < slider.NestedHitObjects.Count; i++) { @@ -167,7 +201,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing else if (currMovementObj is SliderRepeat) { // For a slider repeat, assume a tighter movement threshold to better assess repeat sliders. - requiredMovement = normalized_radius; + requiredMovement = normalised_radius; } if (currMovementLength > requiredMovement) diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs index 2a8d2ce759..a6301aed6d 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Aim.cs @@ -44,24 +44,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills var osuLastLastObj = (OsuDifficultyHitObject)Previous[1]; // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. - double currVelocity = osuCurrObj.JumpDistance / osuCurrObj.StrainTime; + double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime; // But if the last object is a slider, then we extend the travel velocity through the slider into the current object. if (osuLastObj.BaseObject is Slider && withSliders) { - double movementVelocity = osuCurrObj.MovementDistance / osuCurrObj.MovementTime; // calculate the movement velocity from slider end to current object - double travelVelocity = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; // calculate the slider velocity from slider head to slider end. + double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; // calculate the slider velocity from slider head to slider end. + double movementVelocity = osuCurrObj.MinimumJumpDistance / osuCurrObj.MinimumJumpTime; // calculate the movement velocity from slider end to current object currVelocity = Math.Max(currVelocity, movementVelocity + travelVelocity); // take the larger total combined velocity. } // As above, do the same for the previous hitobject. - double prevVelocity = osuLastObj.JumpDistance / osuLastObj.StrainTime; + double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime; if (osuLastLastObj.BaseObject is Slider && withSliders) { - double movementVelocity = osuLastObj.MovementDistance / osuLastObj.MovementTime; - double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime; + double travelVelocity = osuLastLastObj.TravelDistance / osuLastLastObj.TravelTime; + double movementVelocity = osuLastObj.MinimumJumpDistance / osuLastObj.MinimumJumpTime; prevVelocity = Math.Max(prevVelocity, movementVelocity + travelVelocity); } @@ -94,7 +94,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern. * Math.Min(angleBonus, 125 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime * Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4 - * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.JumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter). + * Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.LazyJumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter). } // Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute. @@ -107,8 +107,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills if (Math.Max(prevVelocity, currVelocity) != 0) { // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities. - prevVelocity = (osuLastObj.JumpDistance + osuLastObj.TravelDistance) / osuLastObj.StrainTime; - currVelocity = (osuCurrObj.JumpDistance + osuCurrObj.TravelDistance) / osuCurrObj.StrainTime; + prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.StrainTime; + currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.StrainTime; // Scale with ratio of difference compared to 0.5 * max dist. double distRatio = Math.Pow(Math.Sin(Math.PI / 2 * Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity)), 2); @@ -119,7 +119,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills // Reward for % distance slowed down compared to previous, paying attention to not award overlap double nonOverlapVelocityBuff = Math.Abs(prevVelocity - currVelocity) // do not award overlap - * Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, Math.Min(osuCurrObj.JumpDistance, osuLastObj.JumpDistance) / 100)), 2); + * Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, Math.Min(osuCurrObj.LazyJumpDistance, osuLastObj.LazyJumpDistance) / 100)), 2); // Choose the largest bonus, multiplied by ratio. velocityChangeBonus = Math.Max(overlapVelocityBuff, nonOverlapVelocityBuff) * distRatio; @@ -128,10 +128,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime) / Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime), 2); } - if (osuCurrObj.TravelTime != 0) + if (osuLastObj.TravelTime != 0) { // Reward sliders based on velocity. - sliderBonus = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; + sliderBonus = osuLastObj.TravelDistance / osuLastObj.TravelTime; } // Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger. diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs index cb1ccf949e..21a2fc2252 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Flashlight.cs @@ -62,7 +62,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills smallDistNerf = Math.Min(1.0, jumpDistance / 75.0); // We also want to nerf stacks so that only the first object of the stack is accounted for. - double stackNerf = Math.Min(1.0, (osuLoop.JumpDistance / scalingFactor) / 25.0); + double stackNerf = Math.Min(1.0, (osuLoop.LazyJumpDistance / scalingFactor) / 25.0); result += Math.Pow(0.8, i) * stackNerf * scalingFactor * jumpDistance / cumulativeStrainTime; } diff --git a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs index 24881d9c47..06d1ef7346 100644 --- a/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs +++ b/osu.Game.Rulesets.Osu/Difficulty/Skills/Speed.cs @@ -55,73 +55,75 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills bool firstDeltaSwitch = false; - for (int i = Previous.Count - 2; i > 0; i--) + int rhythmStart = 0; + + while (rhythmStart < Previous.Count - 2 && current.StartTime - Previous[rhythmStart].StartTime < history_time_max) + rhythmStart++; + + for (int i = rhythmStart; i > 0; i--) { OsuDifficultyHitObject currObj = (OsuDifficultyHitObject)Previous[i - 1]; OsuDifficultyHitObject prevObj = (OsuDifficultyHitObject)Previous[i]; OsuDifficultyHitObject lastObj = (OsuDifficultyHitObject)Previous[i + 1]; - double currHistoricalDecay = Math.Max(0, (history_time_max - (current.StartTime - currObj.StartTime))) / history_time_max; // scales note 0 to 1 from history to now + double currHistoricalDecay = (history_time_max - (current.StartTime - currObj.StartTime)) / history_time_max; // scales note 0 to 1 from history to now - if (currHistoricalDecay != 0) + currHistoricalDecay = Math.Min((double)(Previous.Count - i) / Previous.Count, currHistoricalDecay); // either we're limited by time or limited by object count. + + double currDelta = currObj.StrainTime; + double prevDelta = prevObj.StrainTime; + double lastDelta = lastObj.StrainTime; + double currRatio = 1.0 + 6.0 * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / (Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta))), 2)); // fancy function to calculate rhythmbonuses. + + double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - greatWindow * 0.6) / (greatWindow * 0.6)); + + windowPenalty = Math.Min(1, windowPenalty); + + double effectiveRatio = windowPenalty * currRatio; + + if (firstDeltaSwitch) { - currHistoricalDecay = Math.Min((double)(Previous.Count - i) / Previous.Count, currHistoricalDecay); // either we're limited by time or limited by object count. - - double currDelta = currObj.StrainTime; - double prevDelta = prevObj.StrainTime; - double lastDelta = lastObj.StrainTime; - double currRatio = 1.0 + 6.0 * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / (Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta))), 2)); // fancy function to calculate rhythmbonuses. - - double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - greatWindow * 0.6) / (greatWindow * 0.6)); - - windowPenalty = Math.Min(1, windowPenalty); - - double effectiveRatio = windowPenalty * currRatio; - - if (firstDeltaSwitch) + if (!(prevDelta > 1.25 * currDelta || prevDelta * 1.25 < currDelta)) { - if (!(prevDelta > 1.25 * currDelta || prevDelta * 1.25 < currDelta)) - { - if (islandSize < 7) - islandSize++; // island is still progressing, count size. - } - else - { - if (Previous[i - 1].BaseObject is Slider) // bpm change is into slider, this is easy acc window - effectiveRatio *= 0.125; - - if (Previous[i].BaseObject is Slider) // bpm change was from a slider, this is easier typically than circle -> circle - effectiveRatio *= 0.25; - - if (previousIslandSize == islandSize) // repeated island size (ex: triplet -> triplet) - effectiveRatio *= 0.25; - - if (previousIslandSize % 2 == islandSize % 2) // repeated island polartiy (2 -> 4, 3 -> 5) - effectiveRatio *= 0.50; - - if (lastDelta > prevDelta + 10 && prevDelta > currDelta + 10) // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. - effectiveRatio *= 0.125; - - rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay * Math.Sqrt(4 + islandSize) / 2 * Math.Sqrt(4 + previousIslandSize) / 2; - - startRatio = effectiveRatio; - - previousIslandSize = islandSize; // log the last island size. - - if (prevDelta * 1.25 < currDelta) // we're slowing down, stop counting - firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. - - islandSize = 1; - } + if (islandSize < 7) + islandSize++; // island is still progressing, count size. } - else if (prevDelta > 1.25 * currDelta) // we want to be speeding up. + else { - // Begin counting island until we change speed again. - firstDeltaSwitch = true; + if (Previous[i - 1].BaseObject is Slider) // bpm change is into slider, this is easy acc window + effectiveRatio *= 0.125; + + if (Previous[i].BaseObject is Slider) // bpm change was from a slider, this is easier typically than circle -> circle + effectiveRatio *= 0.25; + + if (previousIslandSize == islandSize) // repeated island size (ex: triplet -> triplet) + effectiveRatio *= 0.25; + + if (previousIslandSize % 2 == islandSize % 2) // repeated island polartiy (2 -> 4, 3 -> 5) + effectiveRatio *= 0.50; + + if (lastDelta > prevDelta + 10 && prevDelta > currDelta + 10) // previous increase happened a note ago, 1/1->1/2-1/4, dont want to buff this. + effectiveRatio *= 0.125; + + rhythmComplexitySum += Math.Sqrt(effectiveRatio * startRatio) * currHistoricalDecay * Math.Sqrt(4 + islandSize) / 2 * Math.Sqrt(4 + previousIslandSize) / 2; + startRatio = effectiveRatio; + + previousIslandSize = islandSize; // log the last island size. + + if (prevDelta * 1.25 < currDelta) // we're slowing down, stop counting + firstDeltaSwitch = false; // if we're speeding up, this stays true and we keep counting island size. + islandSize = 1; } } + else if (prevDelta > 1.25 * currDelta) // we want to be speeding up. + { + // Begin counting island until we change speed again. + firstDeltaSwitch = true; + startRatio = effectiveRatio; + islandSize = 1; + } } return Math.Sqrt(4 + rhythmComplexitySum * rhythm_multiplier) / 2; //produces multiplier that can be applied to strain. range [1, infinity) (not really though) @@ -154,7 +156,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills if (strainTime < min_speed_bonus) speedBonus = 1 + 0.75 * Math.Pow((min_speed_bonus - strainTime) / speed_balancing_factor, 2); - double distance = Math.Min(single_spacing_threshold, osuCurrObj.TravelDistance + osuCurrObj.JumpDistance); + double travelDistance = osuPrevObj?.TravelDistance ?? 0; + double distance = Math.Min(single_spacing_threshold, travelDistance + osuCurrObj.MinimumJumpDistance); return (speedBonus + speedBonus * Math.Pow(distance / single_spacing_threshold, 3.5)) / strainTime; } diff --git a/osu.Game.Rulesets.Osu/OsuInputManager.cs b/osu.Game.Rulesets.Osu/OsuInputManager.cs index 7314021a14..5c6b907e42 100644 --- a/osu.Game.Rulesets.Osu/OsuInputManager.cs +++ b/osu.Game.Rulesets.Osu/OsuInputManager.cs @@ -3,8 +3,10 @@ using System.Collections.Generic; using System.ComponentModel; +using osu.Framework.Input; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; +using osu.Framework.Input.StateChanges.Events; using osu.Game.Rulesets.UI; namespace osu.Game.Rulesets.Osu @@ -39,6 +41,19 @@ namespace osu.Game.Rulesets.Osu return base.Handle(e); } + protected override bool HandleMouseTouchStateChange(TouchStateChangeEvent e) + { + if (!AllowUserCursorMovement) + { + // Still allow for forwarding of the "touch" part, but replace the positional data with that of the mouse. + // Primarily relied upon by the "autopilot" osu! mod. + var touch = new Touch(e.Touch.Source, CurrentState.Mouse.Position); + e = new TouchStateChangeEvent(e.State, e.Input, touch, e.IsActive, null); + } + + return base.HandleMouseTouchStateChange(e); + } + private class OsuKeyBindingContainer : RulesetKeyBindingContainer { public bool AllowUserPresses = true; diff --git a/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist b/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist index 5fe822946a..ac658cd14e 100644 --- a/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist +++ b/osu.Game.Rulesets.Taiko.Tests.iOS/Info.plist @@ -32,5 +32,7 @@ XSAppIconAssets Assets.xcassets/AppIcon.appiconset + CADisableMinimumFrameDurationOnPhone + diff --git a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs index 861b800038..16be20f7f3 100644 --- a/osu.Game.Rulesets.Taiko/UI/InputDrum.cs +++ b/osu.Game.Rulesets.Taiko/UI/InputDrum.cs @@ -12,7 +12,6 @@ using osu.Framework.Input.Events; using osu.Game.Graphics; using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.UI; -using osu.Game.Screens.Play; using osu.Game.Skinning; using osuTK; @@ -149,9 +148,6 @@ namespace osu.Game.Rulesets.Taiko.UI centreHit.Colour = colours.Pink; } - [Resolved(canBeNull: true)] - private GameplayClock gameplayClock { get; set; } - public bool OnPressed(KeyBindingPressEvent e) { Drawable target = null; diff --git a/osu.Game.Tests.iOS/Info.plist b/osu.Game.Tests.iOS/Info.plist index 98a4223116..1a89345bc5 100644 --- a/osu.Game.Tests.iOS/Info.plist +++ b/osu.Game.Tests.iOS/Info.plist @@ -32,5 +32,7 @@ XSAppIconAssets Assets.xcassets/AppIcon.appiconset + CADisableMinimumFrameDurationOnPhone + diff --git a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs index 6e5a546e87..81d89359e0 100644 --- a/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs +++ b/osu.Game.Tests/Beatmaps/Formats/LegacyScoreDecoderTest.cs @@ -2,14 +2,20 @@ // See the LICENCE file in the repository root for full licence text. using System.Collections.Generic; +using System.Globalization; +using System.IO; using System.Linq; using NUnit.Framework; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Replays; using osu.Game.Rulesets; using osu.Game.Rulesets.Catch; using osu.Game.Rulesets.Mania; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Replays; +using osu.Game.Rulesets.Osu.UI; +using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Taiko; using osu.Game.Scoring; @@ -21,6 +27,14 @@ namespace osu.Game.Tests.Beatmaps.Formats [TestFixture] public class LegacyScoreDecoderTest { + private CultureInfo originalCulture; + + [SetUp] + public void SetUp() + { + originalCulture = CultureInfo.CurrentCulture; + } + [Test] public void TestDecodeManiaReplay() { @@ -44,6 +58,59 @@ namespace osu.Game.Tests.Beatmaps.Formats } } + [Test] + public void TestCultureInvariance() + { + var ruleset = new OsuRuleset().RulesetInfo; + var scoreInfo = TestResources.CreateTestScoreInfo(ruleset); + var beatmap = new TestBeatmap(ruleset); + var score = new Score + { + ScoreInfo = scoreInfo, + Replay = new Replay + { + Frames = new List + { + new OsuReplayFrame(2000, OsuPlayfield.BASE_SIZE / 2, OsuAction.LeftButton) + } + } + }; + + // the "se" culture is used here, as it encodes the negative number sign as U+2212 MINUS SIGN, + // rather than the classic ASCII U+002D HYPHEN-MINUS. + CultureInfo.CurrentCulture = new CultureInfo("se"); + + var encodeStream = new MemoryStream(); + + var encoder = new LegacyScoreEncoder(score, beatmap); + encoder.Encode(encodeStream); + + var decodeStream = new MemoryStream(encodeStream.GetBuffer()); + + var decoder = new TestLegacyScoreDecoder(); + var decodedAfterEncode = decoder.Parse(decodeStream); + + Assert.Multiple(() => + { + Assert.That(decodedAfterEncode, Is.Not.Null); + + Assert.That(decodedAfterEncode.ScoreInfo.User.Username, Is.EqualTo(scoreInfo.User.Username)); + Assert.That(decodedAfterEncode.ScoreInfo.BeatmapInfoID, Is.EqualTo(scoreInfo.BeatmapInfoID)); + Assert.That(decodedAfterEncode.ScoreInfo.Ruleset, Is.EqualTo(scoreInfo.Ruleset)); + Assert.That(decodedAfterEncode.ScoreInfo.TotalScore, Is.EqualTo(scoreInfo.TotalScore)); + Assert.That(decodedAfterEncode.ScoreInfo.MaxCombo, Is.EqualTo(scoreInfo.MaxCombo)); + Assert.That(decodedAfterEncode.ScoreInfo.Date, Is.EqualTo(scoreInfo.Date)); + + Assert.That(decodedAfterEncode.Replay.Frames.Count, Is.EqualTo(1)); + }); + } + + [TearDown] + public void TearDown() + { + CultureInfo.CurrentCulture = originalCulture; + } + private class TestLegacyScoreDecoder : LegacyScoreDecoder { private static readonly Dictionary rulesets = new Ruleset[] diff --git a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs index 6e2b9d20a8..6d0d5702e9 100644 --- a/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs +++ b/osu.Game.Tests/Beatmaps/IO/ImportBeatmapTest.cs @@ -1022,7 +1022,7 @@ namespace osu.Game.Tests.Beatmaps.IO { return ImportScoreTest.LoadScoreIntoOsu(osu, new ScoreInfo { - OnlineScoreID = 2, + OnlineID = 2, BeatmapInfo = beatmapInfo, BeatmapInfoID = beatmapInfo.ID }, new ImportScoreTest.TestArchiveReader()); diff --git a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs index 2a60a7b96d..3a82cbc785 100644 --- a/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs +++ b/osu.Game.Tests/Beatmaps/TestSceneBeatmapDifficultyCache.cs @@ -25,9 +25,6 @@ namespace osu.Game.Tests.Beatmaps private BeatmapSetInfo importedSet; - [Resolved] - private BeatmapManager beatmaps { get; set; } - private TestBeatmapDifficultyCache difficultyCache; private IBindable starDifficultyBindable; diff --git a/osu.Game.Tests/Database/BeatmapImporterTests.cs b/osu.Game.Tests/Database/BeatmapImporterTests.cs index d2193350ad..e47e24021f 100644 --- a/osu.Game.Tests/Database/BeatmapImporterTests.cs +++ b/osu.Game.Tests/Database/BeatmapImporterTests.cs @@ -809,7 +809,7 @@ namespace osu.Game.Tests.Database // TODO: reimplement when we have score support in realm. // return ImportScoreTest.LoadScoreIntoOsu(osu, new ScoreInfo // { - // OnlineScoreID = 2, + // OnlineID = 2, // Beatmap = beatmap, // BeatmapInfoID = beatmap.ID // }, new ImportScoreTest.TestArchiveReader()); @@ -852,7 +852,11 @@ namespace osu.Game.Tests.Database { 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... @@ -865,16 +869,16 @@ namespace osu.Game.Tests.Database // 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)); 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; using System.Threading; using System.Threading.Tasks; using NUnit.Framework; +using osu.Game.Database; +using osu.Game.Models; #nullable enable @@ -33,6 +35,39 @@ namespace osu.Game.Tests.Database }); } + /// + /// 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 16e2c0fc6a..06cb5a3607 100644 --- a/osu.Game.Tests/Database/RealmLiveTests.cs +++ b/osu.Game.Tests/Database/RealmLiveTests.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Linq; using System.Threading.Tasks; using NUnit.Framework; +using osu.Framework.Testing; using osu.Game.Database; using osu.Game.Models; using Realms; @@ -21,14 +22,41 @@ namespace osu.Game.Tests.Database { RunTestWithRealm((realmFactory, _) => { - ILive beatmap = realmFactory.CreateContext().Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))).ToLive(); + ILive beatmap = realmFactory.CreateContext().Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))).ToLive(realmFactory); - ILive beatmap2 = realmFactory.CreateContext().All().First().ToLive(); + ILive beatmap2 = realmFactory.CreateContext().All().First().ToLive(realmFactory); Assert.AreEqual(beatmap, beatmap2); }); } + [Test] + public void TestAccessAfterStorageMigrate() + { + RunTestWithRealm((realmFactory, storage) => + { + var beatmap = new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()); + + ILive liveBeatmap; + + using (var context = realmFactory.CreateContext()) + { + context.Write(r => r.Add(beatmap)); + + liveBeatmap = beatmap.ToLive(realmFactory); + } + + using (var migratedStorage = new TemporaryNativeStorage("realm-test-migration-target")) + { + migratedStorage.DeleteDirectory(string.Empty); + + storage.Migrate(migratedStorage); + + Assert.IsFalse(liveBeatmap.PerformRead(l => l.Hidden)); + } + }); + } + [Test] public void TestAccessAfterAttach() { @@ -36,7 +64,7 @@ namespace osu.Game.Tests.Database { var beatmap = new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()); - var liveBeatmap = beatmap.ToLive(); + var liveBeatmap = beatmap.ToLive(realmFactory); using (var context = realmFactory.CreateContext()) context.Write(r => r.Add(beatmap)); @@ -49,7 +77,7 @@ namespace osu.Game.Tests.Database public void TestAccessNonManaged() { var beatmap = new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()); - var liveBeatmap = beatmap.ToLive(); + var liveBeatmap = beatmap.ToLiveUnmanaged(); Assert.IsFalse(beatmap.Hidden); Assert.IsFalse(liveBeatmap.Value.Hidden); @@ -62,43 +90,6 @@ namespace osu.Game.Tests.Database Assert.IsFalse(liveBeatmap.PerformRead(l => l.Hidden)); } - [Test] - public void TestValueAccessWithOpenContext() - { - 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(() => - { - 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() { @@ -111,7 +102,7 @@ namespace osu.Game.Tests.Database { var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); - liveBeatmap = beatmap.ToLive(); + liveBeatmap = beatmap.ToLive(realmFactory); } }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); @@ -140,7 +131,7 @@ namespace osu.Game.Tests.Database { var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); - liveBeatmap = beatmap.ToLive(); + liveBeatmap = beatmap.ToLive(realmFactory); } }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); @@ -154,6 +145,60 @@ namespace osu.Game.Tests.Database }); } + [Test] + public void TestValueAccessNonManaged() + { + RunTestWithRealm((realmFactory, _) => + { + var beatmap = new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()); + var liveBeatmap = beatmap.ToLive(realmFactory); + + 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(realmFactory); + } + }, 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() { @@ -166,7 +211,7 @@ namespace osu.Game.Tests.Database { var beatmap = threadContext.Write(r => r.Add(new RealmBeatmap(CreateRuleset(), new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); - liveBeatmap = beatmap.ToLive(); + liveBeatmap = beatmap.ToLive(realmFactory); } }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); @@ -191,7 +236,7 @@ namespace osu.Game.Tests.Database using (var updateThreadContext = realmFactory.CreateContext()) { - updateThreadContext.All().SubscribeForNotifications(gotChange); + updateThreadContext.All().QueryAsyncWithNotifications(gotChange); ILive? liveBeatmap = null; Task.Factory.StartNew(() => @@ -205,7 +250,7 @@ namespace osu.Game.Tests.Database // not just a refresh from the resolved Live. threadContext.Write(r => r.Add(new RealmBeatmap(ruleset, new RealmBeatmapDifficulty(), new RealmBeatmapMetadata()))); - liveBeatmap = beatmap.ToLive(); + liveBeatmap = beatmap.ToLive(realmFactory); } }, TaskCreationOptions.LongRunning | TaskCreationOptions.HideScheduler).Wait(); @@ -215,23 +260,22 @@ namespace osu.Game.Tests.Database 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/Database/RealmTest.cs b/osu.Game.Tests/Database/RealmTest.cs index 04c9f2577a..6904464485 100644 --- a/osu.Game.Tests/Database/RealmTest.cs +++ b/osu.Game.Tests/Database/RealmTest.cs @@ -10,6 +10,7 @@ using osu.Framework.Logging; using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Database; +using osu.Game.IO; using osu.Game.Models; #nullable enable @@ -27,15 +28,16 @@ namespace osu.Game.Tests.Database storage.DeleteDirectory(string.Empty); } - protected void RunTestWithRealm(Action testAction, [CallerMemberName] string caller = "") + protected void RunTestWithRealm(Action testAction, [CallerMemberName] string caller = "") { using (HeadlessGameHost host = new CleanRunHeadlessGameHost(caller)) { host.Run(new RealmTestGame(() => { - var testStorage = storage.GetStorageForDirectory(caller); + // ReSharper disable once AccessToDisposedClosure + var testStorage = new OsuStorage(host, storage.GetStorageForDirectory(caller)); - using (var realmFactory = new RealmContextFactory(testStorage, caller)) + using (var realmFactory = new RealmContextFactory(testStorage, "client")) { Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}"); testAction(realmFactory, testStorage); @@ -58,7 +60,7 @@ namespace osu.Game.Tests.Database { var testStorage = storage.GetStorageForDirectory(caller); - using (var realmFactory = new RealmContextFactory(testStorage, caller)) + using (var realmFactory = new RealmContextFactory(testStorage, "client")) { Logger.Log($"Running test using realm file {testStorage.GetFullPath(realmFactory.Filename)}"); await testAction(realmFactory, testStorage); diff --git a/osu.Game.Tests/Database/RulesetStoreTests.cs b/osu.Game.Tests/Database/RulesetStoreTests.cs index f4e0838be1..cc7e8a0c97 100644 --- a/osu.Game.Tests/Database/RulesetStoreTests.cs +++ b/osu.Game.Tests/Database/RulesetStoreTests.cs @@ -45,9 +45,9 @@ namespace osu.Game.Tests.Database { var rulesets = new RealmRulesetStore(realmFactory, storage); - Assert.IsTrue((rulesets.AvailableRulesets.First() as RealmRuleset)?.IsManaged == false); - Assert.IsTrue((rulesets.GetRuleset(0) as RealmRuleset)?.IsManaged == false); - Assert.IsTrue((rulesets.GetRuleset("mania") as RealmRuleset)?.IsManaged == false); + Assert.IsFalse(rulesets.AvailableRulesets.First().IsManaged); + Assert.IsFalse(rulesets.GetRuleset(0)?.IsManaged); + Assert.IsFalse(rulesets.GetRuleset("mania")?.IsManaged); }); } } diff --git a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs index 860828ae81..f05d9ab3dc 100644 --- a/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs +++ b/osu.Game.Tests/Database/TestRealmKeyBindingStore.cs @@ -52,6 +52,45 @@ namespace osu.Game.Tests.Database Assert.That(queryCount(GlobalAction.Select), Is.EqualTo(2)); } + [Test] + public void TestDefaultsPopulationRemovesExcess() + { + Assert.That(queryCount(), Is.EqualTo(0)); + + KeyBindingContainer testContainer = new TestKeyBindingContainer(); + + // Add some excess bindings for an action which only supports 1. + using (var realm = realmContextFactory.CreateContext()) + using (var transaction = realm.BeginWrite()) + { + realm.Add(new RealmKeyBinding + { + Action = GlobalAction.Back, + KeyCombination = new KeyCombination(InputKey.A) + }); + + realm.Add(new RealmKeyBinding + { + Action = GlobalAction.Back, + KeyCombination = new KeyCombination(InputKey.S) + }); + + realm.Add(new RealmKeyBinding + { + Action = GlobalAction.Back, + KeyCombination = new KeyCombination(InputKey.D) + }); + + transaction.Commit(); + } + + Assert.That(queryCount(GlobalAction.Back), Is.EqualTo(3)); + + keyBindingStore.Register(testContainer, Enumerable.Empty()); + + Assert.That(queryCount(GlobalAction.Back), Is.EqualTo(1)); + } + private int queryCount(GlobalAction? match = null) { using (var realm = realmContextFactory.CreateContext()) diff --git a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs index 3bf6aaac7a..3aab28886e 100644 --- a/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs +++ b/osu.Game.Tests/Gameplay/TestSceneStoryboardSamples.cs @@ -15,6 +15,7 @@ using osu.Framework.IO.Stores; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Audio; +using osu.Game.Database; using osu.Game.IO; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; @@ -167,7 +168,7 @@ namespace osu.Game.Tests.Gameplay private class TestSkin : LegacySkin { public TestSkin(string resourceName, IStorageResourceProvider resources) - : base(DefaultLegacySkin.Info, new TestResourceStore(resourceName), resources, "skin.ini") + : base(DefaultLegacySkin.CreateInfo(), new TestResourceStore(resourceName), resources, "skin.ini") { } } @@ -220,6 +221,7 @@ namespace osu.Game.Tests.Gameplay public AudioManager AudioManager => Audio; public IResourceStore Files => null; public new IResourceStore Resources => base.Resources; + public RealmContextFactory RealmContextFactory => null; public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => null; #endregion diff --git a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs index b612899d79..28937b2120 100644 --- a/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs +++ b/osu.Game.Tests/Input/ConfineMouseTrackerTest.cs @@ -18,9 +18,6 @@ namespace osu.Game.Tests.Input [Resolved] private FrameworkConfigManager frameworkConfigManager { get; set; } - [Resolved] - private OsuConfigManager osuConfigManager { get; set; } - [TestCase(WindowMode.Windowed)] [TestCase(WindowMode.Borderless)] public void TestDisableConfining(WindowMode windowMode) 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 @@ namespace osu.Game.Tests.NonVisual { 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 @@ namespace osu.Game.Tests.NonVisual { 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 @@ namespace osu.Game.Tests.NonVisual { 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 @@ namespace osu.Game.Tests.NonVisual { 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 @@ namespace osu.Game.Tests.NonVisual { 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 @@ namespace osu.Game.Tests.NonVisual { 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 @@ namespace osu.Game.Tests.NonVisual { 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 @@ namespace osu.Game.Tests.NonVisual { 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 @@ namespace osu.Game.Tests.NonVisual { 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..bc0041e2c2 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() { @@ -24,8 +45,6 @@ namespace osu.Game.Tests.NonVisual.Multiplayer AddRepeatStep("add some users", () => Client.AddUser(new APIUser { Id = id++ }), 5); checkPlayingUserCount(0); - AddAssert("playlist item is available", () => Client.CurrentMatchPlayingItem.Value != null); - changeState(3, MultiplayerUserState.WaitingForLoad); checkPlayingUserCount(3); @@ -43,8 +62,6 @@ namespace osu.Game.Tests.NonVisual.Multiplayer AddStep("leave room", () => Client.LeaveRoom()); checkPlayingUserCount(0); - - AddAssert("playlist item is null", () => Client.CurrentMatchPlayingItem.Value == null); } [Test] diff --git a/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs b/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs new file mode 100644 index 0000000000..2ec5b778d1 --- /dev/null +++ b/osu.Game.Tests/Online/Chat/MessageNotifierTest.cs @@ -0,0 +1,90 @@ +// 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.Game.Online.Chat; + +namespace osu.Game.Tests.Online.Chat +{ + [TestFixture] + public class MessageNotifierTest + { + [Test] + public void TestContainsUsernameMidlinePositive() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("This is a test message", "Test")); + } + + [Test] + public void TestContainsUsernameStartOfLinePositive() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("Test message", "Test")); + } + + [Test] + public void TestContainsUsernameEndOfLinePositive() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("This is a test", "Test")); + } + + [Test] + public void TestContainsUsernameMidlineNegative() + { + Assert.IsFalse(MessageNotifier.CheckContainsUsername("This is a testmessage for notifications", "Test")); + } + + [Test] + public void TestContainsUsernameStartOfLineNegative() + { + Assert.IsFalse(MessageNotifier.CheckContainsUsername("Testmessage", "Test")); + } + + [Test] + public void TestContainsUsernameEndOfLineNegative() + { + Assert.IsFalse(MessageNotifier.CheckContainsUsername("This is a notificationtest", "Test")); + } + + [Test] + public void TestContainsUsernameBetweenInterpunction() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("Hello 'test'-message", "Test")); + } + + [Test] + public void TestContainsUsernameUnicode() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("Test \u0460\u0460 message", "\u0460\u0460")); + } + + [Test] + public void TestContainsUsernameUnicodeNegative() + { + Assert.IsFalse(MessageNotifier.CheckContainsUsername("Test ha\u0460\u0460o message", "\u0460\u0460")); + } + + [Test] + public void TestContainsUsernameSpecialCharactersPositive() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("Test [#^-^#] message", "[#^-^#]")); + } + + [Test] + public void TestContainsUsernameSpecialCharactersNegative() + { + Assert.IsFalse(MessageNotifier.CheckContainsUsername("Test pad[#^-^#]oru message", "[#^-^#]")); + } + + [Test] + public void TestContainsUsernameAtSign() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("@username hi", "username")); + } + + [Test] + public void TestContainsUsernameColon() + { + Assert.IsTrue(MessageNotifier.CheckContainsUsername("username: hi", "username")); + } + } +} diff --git a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs index 8378b33b3d..4b160e1d67 100644 --- a/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs +++ b/osu.Game.Tests/Online/TestAPIModJsonSerialization.cs @@ -13,7 +13,6 @@ using osu.Game.Online.Solo; using osu.Game.Rulesets; using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.UI; using osu.Game.Scoring; @@ -94,7 +93,7 @@ namespace osu.Game.Tests.Online [Test] public void TestDeserialiseSubmittableScoreWithEmptyMods() { - var score = new SubmittableScore(new ScoreInfo { Ruleset = new OsuRuleset().RulesetInfo }); + var score = new SubmittableScore(new ScoreInfo()); var deserialised = JsonConvert.DeserializeObject(JsonConvert.SerializeObject(score)); @@ -106,7 +105,6 @@ namespace osu.Game.Tests.Online { var score = new SubmittableScore(new ScoreInfo { - Ruleset = new OsuRuleset().RulesetInfo, Mods = new Mod[] { new OsuModDoubleTime { SpeedChange = { Value = 2 } } } }); diff --git a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs index 24824b1e23..239c787349 100644 --- a/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game.Tests/Online/TestSceneOnlinePlayBeatmapAvailabilityTracker.cs @@ -114,18 +114,23 @@ namespace osu.Game.Tests.Online public void TestTrackerRespectsChecksum() { AddStep("allow importing", () => beatmaps.AllowImport.SetResult(true)); + AddStep("import beatmap", () => beatmaps.Import(testBeatmapFile).Wait()); + addAvailabilityCheckStep("initially locally available", BeatmapAvailability.LocallyAvailable); AddStep("import altered beatmap", () => { beatmaps.Import(TestResources.GetTestBeatmapForImport(true)).Wait(); }); - addAvailabilityCheckStep("state still not downloaded", BeatmapAvailability.NotDownloaded); + addAvailabilityCheckStep("state not downloaded", BeatmapAvailability.NotDownloaded); AddStep("recreate tracker", () => Child = availabilityTracker = new OnlinePlayBeatmapAvailabilityTracker { SelectedItem = { BindTarget = selectedItem } }); addAvailabilityCheckStep("state not downloaded as well", BeatmapAvailability.NotDownloaded); + + AddStep("reimport original beatmap", () => beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait()); + addAvailabilityCheckStep("locally available after re-import", BeatmapAvailability.LocallyAvailable); } private void addAvailabilityCheckStep(string description, Func expected) 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/Resources/TestResources.cs b/osu.Game.Tests/Resources/TestResources.cs index 440d5e701f..445394fc77 100644 --- a/osu.Game.Tests/Resources/TestResources.cs +++ b/osu.Game.Tests/Resources/TestResources.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Text; using System.Threading; using NUnit.Framework; @@ -12,8 +13,12 @@ using osu.Framework.IO.Stores; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Scoring; +using osu.Game.Scoring; namespace osu.Game.Tests.Resources { @@ -137,5 +142,63 @@ namespace osu.Game.Tests.Resources } } } + + /// + /// Create a test score model. + /// + /// The ruleset for which the score was set against. + /// + public static ScoreInfo CreateTestScoreInfo(RulesetInfo ruleset = null) => + CreateTestScoreInfo(CreateTestBeatmapSetInfo(1, new[] { ruleset ?? new OsuRuleset().RulesetInfo }).Beatmaps.First()); + + /// + /// Create a test score model. + /// + /// The beatmap for which the score was set against. + /// + public static ScoreInfo CreateTestScoreInfo(BeatmapInfo beatmap) => new ScoreInfo + { + User = new APIUser + { + Id = 2, + Username = "peppy", + CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", + }, + BeatmapInfo = beatmap, + Ruleset = beatmap.Ruleset, + RulesetID = beatmap.Ruleset.ID ?? 0, + Mods = new Mod[] { new TestModHardRock(), new TestModDoubleTime() }, + TotalScore = 2845370, + Accuracy = 0.95, + MaxCombo = 999, + Position = 1, + Rank = ScoreRank.S, + Date = DateTimeOffset.Now, + Statistics = new Dictionary + { + [HitResult.Miss] = 1, + [HitResult.Meh] = 50, + [HitResult.Ok] = 100, + [HitResult.Good] = 200, + [HitResult.Great] = 300, + [HitResult.Perfect] = 320, + [HitResult.SmallTickHit] = 50, + [HitResult.SmallTickMiss] = 25, + [HitResult.LargeTickHit] = 100, + [HitResult.LargeTickMiss] = 50, + [HitResult.SmallBonus] = 10, + [HitResult.SmallBonus] = 50 + }, + }; + + private class TestModHardRock : ModHardRock + { + public override double ScoreMultiplier => 1; + } + + private class TestModDoubleTime : ModDoubleTime + { + public override double ScoreMultiplier => 1; + } } } diff --git a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs index 0dee0f89ea..bbc92b7817 100644 --- a/osu.Game.Tests/Scores/IO/ImportScoreTest.cs +++ b/osu.Game.Tests/Scores/IO/ImportScoreTest.cs @@ -40,7 +40,7 @@ namespace osu.Game.Tests.Scores.IO Combo = 250, User = new APIUser { Username = "Test user" }, Date = DateTimeOffset.Now, - OnlineScoreID = 12345, + OnlineID = 12345, }; var imported = await LoadScoreIntoOsu(osu, toImport); @@ -52,7 +52,7 @@ namespace osu.Game.Tests.Scores.IO Assert.AreEqual(toImport.Combo, imported.Combo); Assert.AreEqual(toImport.User.Username, imported.User.Username); Assert.AreEqual(toImport.Date, imported.Date); - Assert.AreEqual(toImport.OnlineScoreID, imported.OnlineScoreID); + Assert.AreEqual(toImport.OnlineID, imported.OnlineID); } finally { @@ -163,12 +163,12 @@ namespace osu.Game.Tests.Scores.IO { var osu = LoadOsuIntoHost(host, true); - await LoadScoreIntoOsu(osu, new ScoreInfo { OnlineScoreID = 2 }, new TestArchiveReader()); + await LoadScoreIntoOsu(osu, new ScoreInfo { OnlineID = 2 }, new TestArchiveReader()); var scoreManager = osu.Dependencies.Get(); // Note: A new score reference is used here since the import process mutates the original object to set an ID - Assert.That(scoreManager.IsAvailableLocally(new ScoreInfo { OnlineScoreID = 2 })); + Assert.That(scoreManager.IsAvailableLocally(new ScoreInfo { OnlineID = 2 })); } finally { diff --git a/osu.Game.Tests/Scores/IO/TestScoreEquality.cs b/osu.Game.Tests/Scores/IO/TestScoreEquality.cs index d1374eb6e5..42fcb3acab 100644 --- a/osu.Game.Tests/Scores/IO/TestScoreEquality.cs +++ b/osu.Game.Tests/Scores/IO/TestScoreEquality.cs @@ -44,24 +44,6 @@ namespace osu.Game.Tests.Scores.IO Assert.That(score1, Is.EqualTo(score2)); } - [Test] - public void TestNonMatchingByHash() - { - ScoreInfo score1 = new ScoreInfo { Hash = "a" }; - ScoreInfo score2 = new ScoreInfo { Hash = "b" }; - - Assert.That(score1, Is.Not.EqualTo(score2)); - } - - [Test] - public void TestMatchingByHash() - { - ScoreInfo score1 = new ScoreInfo { Hash = "a" }; - ScoreInfo score2 = new ScoreInfo { Hash = "a" }; - - Assert.That(score1, Is.EqualTo(score2)); - } - [Test] public void TestNonMatchingByNull() { diff --git a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs index ecc9c92025..f2ce002650 100644 --- a/osu.Game.Tests/Skins/IO/ImportSkinTest.cs +++ b/osu.Game.Tests/Skins/IO/ImportSkinTest.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Platform; +using osu.Game.Database; using osu.Game.IO; using osu.Game.IO.Archives; using osu.Game.Skinning; @@ -163,32 +164,109 @@ namespace osu.Game.Tests.Skins.IO assertCorrectMetadata(import2, "name 1 [my custom skin 2]", "author 1", osu); }); + [Test] + public Task TestExportThenImportDefaultSkin() => runSkinTest(osu => + { + var skinManager = osu.Dependencies.Get(); + + skinManager.EnsureMutableSkin(); + + MemoryStream exportStream = new MemoryStream(); + + Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID; + + skinManager.CurrentSkinInfo.Value.PerformRead(s => + { + Assert.IsFalse(s.Protected); + Assert.AreEqual(typeof(DefaultSkin), s.CreateInstance(skinManager).GetType()); + + new LegacySkinExporter(osu.Dependencies.Get()).ExportModelTo(s, exportStream); + + Assert.Greater(exportStream.Length, 0); + }); + + var imported = skinManager.Import(new ImportTask(exportStream, "exported.osk")); + + imported.Result.PerformRead(s => + { + Assert.IsFalse(s.Protected); + Assert.AreNotEqual(originalSkinId, s.ID); + Assert.AreEqual(typeof(DefaultSkin), s.CreateInstance(skinManager).GetType()); + }); + + return Task.CompletedTask; + }); + + [Test] + public Task TestExportThenImportClassicSkin() => runSkinTest(osu => + { + var skinManager = osu.Dependencies.Get(); + + skinManager.CurrentSkinInfo.Value = skinManager.DefaultLegacySkin.SkinInfo; + + skinManager.EnsureMutableSkin(); + + MemoryStream exportStream = new MemoryStream(); + + Guid originalSkinId = skinManager.CurrentSkinInfo.Value.ID; + + skinManager.CurrentSkinInfo.Value.PerformRead(s => + { + Assert.IsFalse(s.Protected); + Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType()); + + new LegacySkinExporter(osu.Dependencies.Get()).ExportModelTo(s, exportStream); + + Assert.Greater(exportStream.Length, 0); + }); + + var imported = skinManager.Import(new ImportTask(exportStream, "exported.osk")); + + imported.Result.PerformRead(s => + { + Assert.IsFalse(s.Protected); + Assert.AreNotEqual(originalSkinId, s.ID); + Assert.AreEqual(typeof(DefaultLegacySkin), s.CreateInstance(skinManager).GetType()); + }); + + return Task.CompletedTask; + }); + #endregion - private void assertCorrectMetadata(SkinInfo import1, string name, string creator, OsuGameBase osu) + private void assertCorrectMetadata(ILive import1, string name, string creator, OsuGameBase osu) { - Assert.That(import1.Name, Is.EqualTo(name)); - Assert.That(import1.Creator, Is.EqualTo(creator)); + import1.PerformRead(i => + { + Assert.That(i.Name, Is.EqualTo(name)); + Assert.That(i.Creator, Is.EqualTo(creator)); - // for extra safety let's reconstruct the skin, reading from the skin.ini. - var instance = import1.CreateInstance((IStorageResourceProvider)osu.Dependencies.Get(typeof(SkinManager))); + // for extra safety let's reconstruct the skin, reading from the skin.ini. + var instance = i.CreateInstance((IStorageResourceProvider)osu.Dependencies.Get(typeof(SkinManager))); - Assert.That(instance.Configuration.SkinInfo.Name, Is.EqualTo(name)); - Assert.That(instance.Configuration.SkinInfo.Creator, Is.EqualTo(creator)); + Assert.That(instance.Configuration.SkinInfo.Name, Is.EqualTo(name)); + Assert.That(instance.Configuration.SkinInfo.Creator, Is.EqualTo(creator)); + }); } - private void assertImportedBoth(SkinInfo import1, SkinInfo import2) + private void assertImportedBoth(ILive import1, ILive import2) { - Assert.That(import2.ID, Is.Not.EqualTo(import1.ID)); - Assert.That(import2.Hash, Is.Not.EqualTo(import1.Hash)); - Assert.That(import2.Files.Select(f => f.FileInfoID), Is.Not.EquivalentTo(import1.Files.Select(f => f.FileInfoID))); + import1.PerformRead(i1 => import2.PerformRead(i2 => + { + Assert.That(i2.ID, Is.Not.EqualTo(i1.ID)); + Assert.That(i2.Hash, Is.Not.EqualTo(i1.Hash)); + Assert.That(i2.Files.First(), Is.Not.EqualTo(i1.Files.First())); + })); } - private void assertImportedOnce(SkinInfo import1, SkinInfo import2) + private void assertImportedOnce(ILive import1, ILive import2) { - Assert.That(import2.ID, Is.EqualTo(import1.ID)); - Assert.That(import2.Hash, Is.EqualTo(import1.Hash)); - Assert.That(import2.Files.Select(f => f.FileInfoID), Is.EquivalentTo(import1.Files.Select(f => f.FileInfoID))); + import1.PerformRead(i1 => import2.PerformRead(i2 => + { + Assert.That(i2.ID, Is.EqualTo(i1.ID)); + Assert.That(i2.Hash, Is.EqualTo(i1.Hash)); + Assert.That(i2.Files.First(), Is.EqualTo(i1.Files.First())); + })); } private MemoryStream createEmptyOsk() @@ -255,10 +333,10 @@ namespace osu.Game.Tests.Skins.IO } } - private async Task loadSkinIntoOsu(OsuGameBase osu, ArchiveReader archive = null) + private async Task> loadSkinIntoOsu(OsuGameBase osu, ArchiveReader archive = null) { var skinManager = osu.Dependencies.Get(); - return (await skinManager.Import(archive)).Value; + return await skinManager.Import(archive); } } } diff --git a/osu.Game.Tests/Skins/TestSceneSkinResources.cs b/osu.Game.Tests/Skins/TestSceneSkinResources.cs index 10f1ab31df..09535b76e3 100644 --- a/osu.Game.Tests/Skins/TestSceneSkinResources.cs +++ b/osu.Game.Tests/Skins/TestSceneSkinResources.cs @@ -24,7 +24,7 @@ namespace osu.Game.Tests.Skins private void load() { var imported = skins.Import(new ZipArchiveReader(TestResources.OpenResource("Archives/ogg-skin.osk"))).Result; - skin = skins.GetSkin(imported.Value); + skin = imported.PerformRead(skinInfo => skins.GetSkin(skinInfo)); } [Test] diff --git a/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs index ec16578b71..844fe7705a 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneBackgroundScreenDefault.cs @@ -5,15 +5,20 @@ using System; using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics.Textures; using osu.Framework.Screens; using osu.Framework.Testing; +using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Database; using osu.Game.Graphics.Backgrounds; using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Screens; using osu.Game.Screens.Backgrounds; using osu.Game.Skinning; +using osu.Game.Tests.Beatmaps; namespace osu.Game.Tests.Visual.Background { @@ -21,8 +26,7 @@ namespace osu.Game.Tests.Visual.Background public class TestSceneBackgroundScreenDefault : OsuTestScene { private BackgroundScreenStack stack; - private BackgroundScreenDefault screen; - + private TestBackgroundScreenDefault screen; private Graphics.Backgrounds.Background getCurrentBackground() => screen.ChildrenOfType().FirstOrDefault(); [Resolved] @@ -35,10 +39,96 @@ namespace osu.Game.Tests.Visual.Background public void SetUpSteps() { AddStep("create background stack", () => Child = stack = new BackgroundScreenStack()); - AddStep("push default screen", () => stack.Push(screen = new BackgroundScreenDefault(false))); + AddStep("push default screen", () => stack.Push(screen = new TestBackgroundScreenDefault())); AddUntilStep("wait for screen to load", () => screen.IsCurrentScreen()); } + [Test] + public void TestBeatmapBackgroundTracksBeatmap() + { + setSupporter(true); + setSourceMode(BackgroundSource.Beatmap); + + AddStep("change beatmap", () => Beatmap.Value = createTestWorkingBeatmapWithUniqueBackground()); + AddAssert("background changed", () => screen.CheckLastLoadChange() == true); + + Graphics.Backgrounds.Background last = null; + + AddUntilStep("wait for beatmap background to be loaded", () => getCurrentBackground()?.GetType() == typeof(BeatmapBackground)); + AddStep("store background", () => last = getCurrentBackground()); + + AddStep("change beatmap", () => Beatmap.Value = createTestWorkingBeatmapWithUniqueBackground()); + + AddUntilStep("wait for beatmap background to change", () => screen.CheckLastLoadChange() == true); + + AddUntilStep("background is new beatmap background", () => last != getCurrentBackground()); + AddStep("store background", () => last = getCurrentBackground()); + + AddStep("change beatmap", () => Beatmap.Value = createTestWorkingBeatmapWithUniqueBackground()); + + AddUntilStep("wait for beatmap background to change", () => screen.CheckLastLoadChange() == true); + AddUntilStep("background is new beatmap background", () => last != getCurrentBackground()); + } + + [Test] + public void TestBeatmapBackgroundTracksBeatmapWhenSuspended() + { + setSupporter(true); + setSourceMode(BackgroundSource.Beatmap); + + AddStep("change beatmap", () => Beatmap.Value = createTestWorkingBeatmapWithUniqueBackground()); + AddAssert("background changed", () => screen.CheckLastLoadChange() == true); + AddUntilStep("wait for beatmap background to be loaded", () => getCurrentBackground()?.GetType() == typeof(BeatmapBackground)); + + BackgroundScreenBeatmap nestedScreen = null; + + // of note, this needs to be a type that doesn't match BackgroundScreenDefault else it is silently not pushed by the background stack. + AddStep("push new background to stack", () => stack.Push(nestedScreen = new BackgroundScreenBeatmap(Beatmap.Value))); + AddUntilStep("wait for screen to load", () => nestedScreen.IsLoaded && nestedScreen.IsCurrentScreen()); + AddUntilStep("previous background hidden", () => !screen.IsAlive); + + AddAssert("top level background hasn't changed yet", () => screen.CheckLastLoadChange() == null); + + AddStep("change beatmap", () => Beatmap.Value = createTestWorkingBeatmapWithUniqueBackground()); + + AddAssert("top level background hasn't changed yet", () => screen.CheckLastLoadChange() == null); + + AddStep("pop screen back to top level", () => screen.MakeCurrent()); + + AddAssert("top level background changed", () => screen.CheckLastLoadChange() == true); + } + + [Test] + public void TestBeatmapBackgroundIgnoresNoChangeWhenSuspended() + { + BackgroundScreenBeatmap nestedScreen = null; + WorkingBeatmap originalWorking = null; + + setSupporter(true); + setSourceMode(BackgroundSource.Beatmap); + + AddStep("change beatmap", () => originalWorking = Beatmap.Value = createTestWorkingBeatmapWithUniqueBackground()); + AddAssert("background changed", () => screen.CheckLastLoadChange() == true); + AddUntilStep("wait for beatmap background to be loaded", () => getCurrentBackground()?.GetType() == typeof(BeatmapBackground)); + + // of note, this needs to be a type that doesn't match BackgroundScreenDefault else it is silently not pushed by the background stack. + AddStep("push new background to stack", () => stack.Push(nestedScreen = new BackgroundScreenBeatmap(Beatmap.Value))); + AddUntilStep("wait for screen to load", () => nestedScreen.IsLoaded && nestedScreen.IsCurrentScreen()); + + // we're testing a case where scheduling may be used to avoid issues, so ensure the scheduler is no longer running. + AddUntilStep("wait for top level not alive", () => !screen.IsAlive); + + AddStep("change beatmap", () => Beatmap.Value = createTestWorkingBeatmapWithUniqueBackground()); + AddStep("change beatmap back", () => Beatmap.Value = originalWorking); + + AddAssert("top level background hasn't changed yet", () => screen.CheckLastLoadChange() == null); + + AddStep("pop screen back to top level", () => screen.MakeCurrent()); + + AddStep("top level screen is current", () => screen.IsCurrentScreen()); + AddAssert("top level background reused existing", () => screen.CheckLastLoadChange() == false); + } + [Test] public void TestBackgroundTypeSwitch() { @@ -77,36 +167,24 @@ namespace osu.Game.Tests.Visual.Background [TestCase(BackgroundSource.Skin, typeof(SkinBackground))] public void TestBackgroundDoesntReloadOnNoChange(BackgroundSource source, Type backgroundType) { - Graphics.Backgrounds.Background last = null; - setSourceMode(source); setSupporter(true); if (source == BackgroundSource.Skin) setCustomSkin(); - AddUntilStep("wait for beatmap background to be loaded", () => (last = getCurrentBackground())?.GetType() == backgroundType); + AddUntilStep("wait for beatmap background to be loaded", () => (getCurrentBackground())?.GetType() == backgroundType); AddAssert("next doesn't load new background", () => screen.Next() == false); - - // doesn't really need to be checked but might as well. - AddWaitStep("wait a bit", 5); - AddUntilStep("ensure same background instance", () => last == getCurrentBackground()); } [Test] public void TestBackgroundCyclingOnDefaultSkin([Values] bool supporter) { - Graphics.Backgrounds.Background last = null; - setSourceMode(BackgroundSource.Skin); setSupporter(supporter); setDefaultSkin(); - AddUntilStep("wait for beatmap background to be loaded", () => (last = getCurrentBackground())?.GetType() == typeof(Graphics.Backgrounds.Background)); + AddUntilStep("wait for beatmap background to be loaded", () => (getCurrentBackground())?.GetType() == typeof(Graphics.Backgrounds.Background)); AddAssert("next cycles background", () => screen.Next()); - - // doesn't really need to be checked but might as well. - AddWaitStep("wait a bit", 5); - AddUntilStep("ensure different background instance", () => last != getCurrentBackground()); } private void setSourceMode(BackgroundSource source) => @@ -119,10 +197,46 @@ namespace osu.Game.Tests.Visual.Background Id = API.LocalUser.Value.Id + 1, }); + private WorkingBeatmap createTestWorkingBeatmapWithUniqueBackground() => new UniqueBackgroundTestWorkingBeatmap(Audio); + + private class TestBackgroundScreenDefault : BackgroundScreenDefault + { + private bool? lastLoadTriggerCausedChange; + + public TestBackgroundScreenDefault() + : base(false) + { + } + + public override bool Next() + { + bool didChange = base.Next(); + lastLoadTriggerCausedChange = didChange; + return didChange; + } + + public bool? CheckLastLoadChange() + { + bool? lastChange = lastLoadTriggerCausedChange; + lastLoadTriggerCausedChange = null; + return lastChange; + } + } + + private class UniqueBackgroundTestWorkingBeatmap : TestWorkingBeatmap + { + public UniqueBackgroundTestWorkingBeatmap(AudioManager audioManager) + : base(new Beatmap(), null, audioManager) + { + } + + protected override Texture GetBackground() => new Texture(1, 1); + } + private void setCustomSkin() { // feign a skin switch. this doesn't do anything except force CurrentSkin to become a LegacySkin. - AddStep("set custom skin", () => skins.CurrentSkinInfo.Value = new SkinInfo { ID = 5 }); + AddStep("set custom skin", () => skins.CurrentSkinInfo.Value = new SkinInfo().ToLiveUnmanaged()); } private void setDefaultSkin() => AddStep("set default skin", () => skins.CurrentSkinInfo.SetDefault()); diff --git a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs index 194341d1ab..33b1d9a67d 100644 --- a/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs +++ b/osu.Game.Tests/Visual/Background/TestSceneUserDimBackgrounds.cs @@ -18,7 +18,6 @@ using osu.Game.Configuration; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Scoring; @@ -28,7 +27,6 @@ using osu.Game.Screens.Play; using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Ranking; using osu.Game.Screens.Select; -using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Resources; using osuTK; using osuTK.Graphics; @@ -229,12 +227,7 @@ namespace osu.Game.Tests.Visual.Background FadeAccessibleResults results = null; - AddStep("Transition to Results", () => player.Push(results = new FadeAccessibleResults(new ScoreInfo - { - User = new APIUser { Username = "osu!" }, - BeatmapInfo = new TestBeatmap(Ruleset.Value).BeatmapInfo, - Ruleset = Ruleset.Value, - }))); + AddStep("Transition to Results", () => player.Push(results = new FadeAccessibleResults(TestResources.CreateTestScoreInfo()))); AddUntilStep("Wait for results is current", () => results.IsCurrentScreen()); diff --git a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs index 5effc1f215..0b9857486a 100644 --- a/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs +++ b/osu.Game.Tests/Visual/Beatmaps/TestSceneBeatmapCard.cs @@ -11,17 +11,18 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Testing; using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; using osu.Game.Beatmaps.Drawables.Cards; +using osu.Game.Graphics.Containers; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osuTK; -using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Tests.Visual.Beatmaps { - public class TestSceneBeatmapCard : OsuTestScene + public class TestSceneBeatmapCard : OsuManualInputManagerTestScene { /// /// All cards on this scene use a common online ID to ensure that map download, preview tracks, etc. can be tested manually with online sources. @@ -227,7 +228,7 @@ namespace osu.Game.Tests.Visual.Beatmaps new BasicScrollContainer { RelativeSizeAxes = Axes.Both, - Child = new FillFlowContainer + Child = new ReverseChildIDFillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, @@ -248,6 +249,35 @@ namespace osu.Game.Tests.Visual.Beatmaps } [Test] - public void TestNormal() => createTestCase(beatmapSetInfo => new BeatmapCard(beatmapSetInfo)); + public void TestNormal() + { + createTestCase(beatmapSetInfo => new BeatmapCard(beatmapSetInfo)); + } + + [Test] + public void TestHoverState() + { + AddStep("create cards", () => Child = createContent(OverlayColourScheme.Blue, s => new BeatmapCard(s))); + + AddStep("Hover card", () => InputManager.MoveMouseTo(firstCard())); + AddWaitStep("wait for potential state change", 5); + AddAssert("card is not expanded", () => !firstCard().Expanded.Value); + + AddStep("Hover spectrum display", () => InputManager.MoveMouseTo(firstCard().ChildrenOfType().Single())); + AddUntilStep("card is expanded", () => firstCard().Expanded.Value); + + AddStep("Hover difficulty content", () => InputManager.MoveMouseTo(firstCard().ChildrenOfType().Single())); + AddWaitStep("wait for potential state change", 5); + AddAssert("card is still expanded", () => firstCard().Expanded.Value); + + AddStep("Hover main content again", () => InputManager.MoveMouseTo(firstCard())); + AddWaitStep("wait for potential state change", 5); + AddAssert("card is still expanded", () => firstCard().Expanded.Value); + + AddStep("Hover away", () => InputManager.MoveMouseTo(this.ChildrenOfType().Last())); + AddUntilStep("card is not expanded", () => !firstCard().Expanded.Value); + + BeatmapCard firstCard() => this.ChildrenOfType().First(); + } } } 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/Editing/TestSceneDifficultySwitching.cs b/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs index c81a1abfbc..c23db5e440 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneDifficultySwitching.cs @@ -10,6 +10,7 @@ using osu.Game.Beatmaps; using osu.Game.Overlays.Dialog; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.UI; using osu.Game.Screens.Edit; using osu.Game.Tests.Beatmaps.IO; @@ -89,6 +90,7 @@ namespace osu.Game.Tests.Visual.Editing confirmEditingBeatmap(() => targetDifficulty); AddAssert("no objects selected", () => !EditorBeatmap.SelectedHitObjects.Any()); + AddUntilStep("wait for drawable ruleset", () => Editor.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); AddStep("paste object", () => Editor.Paste()); if (sameRuleset) diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs index 92c8131568..db20d3c7ba 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorBeatmapCreation.cs @@ -49,7 +49,7 @@ namespace osu.Game.Tests.Visual.Editing public void TestCreateNewBeatmap() { AddStep("save beatmap", () => Editor.Save()); - AddAssert("new beatmap persisted", () => EditorBeatmap.BeatmapInfo.ID > 0); + AddAssert("new beatmap persisted", () => EditorBeatmap.BeatmapInfo.IsManaged); AddAssert("new beatmap in database", () => beatmapManager.QueryBeatmapSet(s => s.ID == EditorBeatmap.BeatmapInfo.BeatmapSet.ID)?.DeletePending == false); } diff --git a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs index 160af47a6d..50794f15ed 100644 --- a/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs +++ b/osu.Game.Tests/Visual/Editing/TestSceneEditorTestGameplay.cs @@ -9,6 +9,7 @@ using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Rulesets; +using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; using osu.Game.Screens.Backgrounds; using osu.Game.Screens.Edit; @@ -44,6 +45,7 @@ namespace osu.Game.Tests.Visual.Editing protected override void LoadEditor() { Beatmap.Value = beatmaps.GetWorkingBeatmap(importedBeatmapSet.Beatmaps.First(b => b.RulesetID == 0)); + SelectedMods.Value = new[] { new ModCinema() }; base.LoadEditor(); } @@ -67,6 +69,7 @@ namespace osu.Game.Tests.Visual.Editing var background = this.ChildrenOfType().Single(); return background.Colour == Color4.DarkGray && background.BlurAmount.Value == 0; }); + AddAssert("no mods selected", () => SelectedMods.Value.Count == 0); } [Test] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs index 7398527f57..c5f56cae9e 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneBeatmapSkinFallbacks.cs @@ -12,6 +12,7 @@ using osu.Framework.Testing; using osu.Framework.Timing; using osu.Framework.Utils; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Extensions; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; @@ -41,7 +42,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestEmptyLegacyBeatmapSkinFallsBack() { - CreateSkinTest(SkinInfo.Default, () => new LegacyBeatmapSkin(new BeatmapInfo(), null, null)); + CreateSkinTest(DefaultSkin.CreateInfo(), () => new LegacyBeatmapSkin(new BeatmapInfo(), null, null)); AddUntilStep("wait for hud load", () => Player.ChildrenOfType().All(c => c.ComponentsLoaded)); AddAssert("hud from default skin", () => AssertComponentsFromExpectedSource(SkinnableTarget.MainHUDComponents, skinManager.CurrentSkin.Value)); } @@ -52,7 +53,7 @@ namespace osu.Game.Tests.Visual.Gameplay { AddStep("setup skins", () => { - skinManager.CurrentSkinInfo.Value = gameCurrentSkin; + skinManager.CurrentSkinInfo.Value = gameCurrentSkin.ToLiveUnmanaged(); currentBeatmapSkin = getBeatmapSkin(); }); }); diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs index 745932315c..fa27e1abdd 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneFailJudgement.cs @@ -26,6 +26,7 @@ namespace osu.Game.Tests.Visual.Gameplay AddAssert("total number of results == 1", () => { var score = new ScoreInfo(); + ((FailPlayer)Player).ScoreProcessor.PopulateScore(score); return score.Statistics.Values.Sum() == 1; diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs index f5f17a0bc1..e03c8d7561 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneLeadIn.cs @@ -85,11 +85,12 @@ namespace osu.Game.Tests.Visual.Gameplay loopGroup.Scale.Add(Easing.None, -20000, -18000, 0, 1); var target = addEventToLoop ? loopGroup : sprite.TimelineGroup; - target.Alpha.Add(Easing.None, firstStoryboardEvent, firstStoryboardEvent + 500, 0, 1); + double targetTime = addEventToLoop ? 20000 : 0; + target.Alpha.Add(Easing.None, targetTime + firstStoryboardEvent, targetTime + firstStoryboardEvent + 500, 0, 1); // these should be ignored due to being in the future. sprite.TimelineGroup.Alpha.Add(Easing.None, 18000, 20000, 0, 1); - loopGroup.Alpha.Add(Easing.None, 18000, 20000, 0, 1); + loopGroup.Alpha.Add(Easing.None, 38000, 40000, 0, 1); storyboard.GetLayer("Background").Add(sprite); diff --git a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs index 06eaa726c9..958d617d63 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestScenePlayerLoader.cs @@ -251,7 +251,12 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestMutedNotificationMuteButton() { - addVolumeSteps("mute button", () => volumeOverlay.IsMuted.Value = true, () => !volumeOverlay.IsMuted.Value); + addVolumeSteps("mute button", () => + { + // Importantly, in the case the volume is muted but the user has a volume level set, it should be retained. + audioManager.VolumeTrack.Value = 0.5f; + volumeOverlay.IsMuted.Value = true; + }, () => !volumeOverlay.IsMuted.Value && audioManager.VolumeTrack.Value == 0.5f); } /// diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs index f47fae33ca..42c4f89e9d 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayDownloadButton.cs @@ -164,7 +164,7 @@ namespace osu.Game.Tests.Visual.Gameplay private ScoreInfo getScoreInfo(bool replayAvailable) { - return new APIScoreInfo + return new APIScore { OnlineID = 2553163309, RulesetID = 0, diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs index dcc193669b..e6361a15d7 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecorder.cs @@ -43,83 +43,88 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty()); - [SetUp] - public void SetUp() => Schedule(() => + [SetUpSteps] + public void SetUpSteps() { - replay = new Replay(); + AddStep("Reset recorder state", cleanUpState); - Add(new GridContainer + AddStep("Setup containers", () => { - RelativeSizeAxes = Axes.Both, - Content = new[] + replay = new Replay(); + + Add(new GridContainer { - new Drawable[] + RelativeSizeAxes = Axes.Both, + Content = new[] { - recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + new Drawable[] { - Recorder = recorder = new TestReplayRecorder(new Score + recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { - Replay = replay, - ScoreInfo = { BeatmapInfo = gameplayState.Beatmap.BeatmapInfo } - }) - { - ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), - }, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + Recorder = recorder = new TestReplayRecorder(new Score { - new Box + Replay = replay, + ScoreInfo = { BeatmapInfo = gameplayState.Beatmap.BeatmapInfo } + }) + { + ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - Colour = Color4.Brown, - RelativeSizeAxes = Axes.Both, - }, - new OsuSpriteText - { - Text = "Recording", - Scale = new Vector2(3), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new TestInputConsumer() - } - }, - } - }, - new Drawable[] - { - playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + new Box + { + Colour = Color4.Brown, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Recording", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestInputConsumer() + } + }, + } + }, + new Drawable[] { - ReplayInputHandler = new TestFramedReplayInputHandler(replay) + playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { - GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos), - }, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + ReplayInputHandler = new TestFramedReplayInputHandler(replay) { - new Box + GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos), + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] { - Colour = Color4.DarkBlue, - RelativeSizeAxes = Axes.Both, - }, - new OsuSpriteText - { - Text = "Playback", - Scale = new Vector2(3), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new TestInputConsumer() - } - }, + new Box + { + Colour = Color4.DarkBlue, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Playback", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestInputConsumer() + } + }, + } } } - } + }); }); - }); + } [Test] public void TestBasic() @@ -184,7 +189,14 @@ namespace osu.Game.Tests.Visual.Gameplay [TearDownSteps] public void TearDown() { - AddStep("stop recorder", () => recorder.Expire()); + AddStep("stop recorder", cleanUpState); + } + + private void cleanUpState() + { + // Ensure previous recorder is disposed else it may affect the global playing state of `SpectatorClient`. + recorder?.RemoveAndDisposeImmediately(); + recorder = null; } public class TestFramedReplayInputHandler : FramedReplayInputHandler diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs deleted file mode 100644 index 3f7155f1e2..0000000000 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneReplayRecording.cs +++ /dev/null @@ -1,228 +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 System; -using System.Collections.Generic; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Containers; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Input.Bindings; -using osu.Framework.Input.Events; -using osu.Framework.Input.StateChanges; -using osu.Game.Beatmaps; -using osu.Game.Graphics.Sprites; -using osu.Game.Replays; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Osu; -using osu.Game.Rulesets.Replays; -using osu.Game.Rulesets.UI; -using osu.Game.Scoring; -using osu.Game.Screens.Play; -using osu.Game.Tests.Visual.UserInterface; -using osuTK; -using osuTK.Graphics; - -namespace osu.Game.Tests.Visual.Gameplay -{ - public class TestSceneReplayRecording : OsuTestScene - { - private readonly TestRulesetInputManager playbackManager; - - private readonly TestRulesetInputManager recordingManager; - - [Cached] - private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty()); - - public TestSceneReplayRecording() - { - Replay replay = new Replay(); - - Add(new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] - { - recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) - { - Recorder = new TestReplayRecorder(new Score - { - Replay = replay, - ScoreInfo = { BeatmapInfo = gameplayState.Beatmap.BeatmapInfo } - }) - { - ScreenSpaceToGamefield = pos => recordingManager?.ToLocalSpace(pos) ?? Vector2.Zero, - }, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - Colour = Color4.Brown, - RelativeSizeAxes = Axes.Both, - }, - new OsuSpriteText - { - Text = "Recording", - Scale = new Vector2(3), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new TestConsumer() - } - }, - } - }, - new Drawable[] - { - playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) - { - ReplayInputHandler = new TestFramedReplayInputHandler(replay) - { - GamefieldToScreenSpace = pos => playbackManager?.ToScreenSpace(pos) ?? Vector2.Zero, - }, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - Colour = Color4.DarkBlue, - RelativeSizeAxes = Axes.Both, - }, - new OsuSpriteText - { - Text = "Playback", - Scale = new Vector2(3), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new TestConsumer() - } - }, - } - } - } - }); - } - - protected override void Update() - { - base.Update(); - - playbackManager.ReplayInputHandler.SetFrameFromTime(Time.Current - 500); - } - } - - public class TestFramedReplayInputHandler : FramedReplayInputHandler - { - public TestFramedReplayInputHandler(Replay replay) - : base(replay) - { - } - - public override void CollectPendingInputs(List inputs) - { - inputs.Add(new MousePositionAbsoluteInput { Position = GamefieldToScreenSpace(CurrentFrame?.Position ?? Vector2.Zero) }); - inputs.Add(new ReplayState { PressedActions = CurrentFrame?.Actions ?? new List() }); - } - } - - public class TestConsumer : CompositeDrawable, IKeyBindingHandler - { - public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => Parent.ReceivePositionalInputAt(screenSpacePos); - - private readonly Box box; - - public TestConsumer() - { - Size = new Vector2(30); - - Origin = Anchor.Centre; - - InternalChildren = new Drawable[] - { - box = new Box - { - Colour = Color4.Black, - RelativeSizeAxes = Axes.Both, - }, - }; - } - - protected override bool OnMouseMove(MouseMoveEvent e) - { - Position = e.MousePosition; - return base.OnMouseMove(e); - } - - public bool OnPressed(KeyBindingPressEvent e) - { - if (e.Repeat) - return false; - - box.Colour = Color4.White; - return true; - } - - public void OnReleased(KeyBindingReleaseEvent e) - { - box.Colour = Color4.Black; - } - } - - public class TestRulesetInputManager : RulesetInputManager - { - public TestRulesetInputManager(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) - : base(ruleset, variant, unique) - { - } - - protected override KeyBindingContainer CreateKeyBindingContainer(RulesetInfo ruleset, int variant, SimultaneousBindingMode unique) - => new TestKeyBindingContainer(); - - internal class TestKeyBindingContainer : KeyBindingContainer - { - public override IEnumerable DefaultKeyBindings => new[] - { - new KeyBinding(InputKey.MouseLeft, TestAction.Down), - }; - } - } - - public class TestReplayFrame : ReplayFrame - { - public Vector2 Position; - - public List Actions = new List(); - - public TestReplayFrame(double time, Vector2 position, params TestAction[] actions) - : base(time) - { - Position = position; - Actions.AddRange(actions); - } - } - - public enum TestAction - { - Down, - } - - internal class TestReplayRecorder : ReplayRecorder - { - public TestReplayRecorder(Score target) - : base(target) - { - } - - protected override ReplayFrame HandleFrame(Vector2 mousePosition, List actions, ReplayFrame previousFrame) => - new TestReplayFrame(Time.Current, mousePosition, actions.ToArray()); - } -} diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs index a0b27755b7..a0602e21b9 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinEditor.cs @@ -2,12 +2,10 @@ // 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.Framework.Testing; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; -using osu.Game.Skinning; using osu.Game.Skinning.Editor; namespace osu.Game.Tests.Visual.Gameplay @@ -16,9 +14,6 @@ namespace osu.Game.Tests.Visual.Gameplay { private SkinEditor skinEditor; - [Resolved] - private SkinManager skinManager { get; set; } - protected override bool Autoplay => true; [SetUpSteps] diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs index 723e35ed55..3074a91dc6 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSkinnableHUDOverlay.cs @@ -10,7 +10,6 @@ using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Testing; -using osu.Game.Configuration; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Osu; @@ -36,9 +35,6 @@ namespace osu.Game.Tests.Visual.Gameplay private Drawable hideTarget => hudOverlay.KeyCounter; private FillFlowContainer keyCounterFlow => hudOverlay.KeyCounter.ChildrenOfType>().First(); - [Resolved] - private OsuConfigManager config { get; set; } - [Test] public void TestComboCounterIncrementing() { diff --git a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs index 5fbccd54c8..f7e9a1fe16 100644 --- a/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs +++ b/osu.Game.Tests/Visual/Gameplay/TestSceneSpectatorPlayback.cs @@ -51,7 +51,7 @@ namespace osu.Game.Tests.Visual.Gameplay private TestReplayRecorder recorder; - private readonly ManualClock manualClock = new ManualClock(); + private ManualClock manualClock; private OsuSpriteText latencyDisplay; @@ -66,113 +66,121 @@ namespace osu.Game.Tests.Visual.Gameplay [Cached] private GameplayState gameplayState = new GameplayState(new Beatmap(), new OsuRuleset(), Array.Empty()); - [SetUp] - public void SetUp() => Schedule(() => + [SetUpSteps] + public void SetUpSteps() { - replay = new Replay(); + AddStep("Reset recorder state", cleanUpState); - users.BindTo(spectatorClient.PlayingUsers); - users.BindCollectionChanged((obj, args) => + AddStep("Setup containers", () => { - switch (args.Action) + replay = new Replay(); + manualClock = new ManualClock(); + + spectatorClient.OnNewFrames += onNewFrames; + + users.BindTo(spectatorClient.PlayingUsers); + users.BindCollectionChanged((obj, args) => { - case NotifyCollectionChangedAction.Add: - Debug.Assert(args.NewItems != null); - - foreach (int user in args.NewItems) - { - if (user == api.LocalUser.Value.Id) - spectatorClient.WatchUser(user); - } - - break; - - case NotifyCollectionChangedAction.Remove: - Debug.Assert(args.OldItems != null); - - foreach (int user in args.OldItems) - { - if (user == api.LocalUser.Value.Id) - spectatorClient.StopWatchingUser(user); - } - - break; - } - }, true); - - spectatorClient.OnNewFrames += onNewFrames; - - Add(new GridContainer - { - RelativeSizeAxes = Axes.Both, - Content = new[] - { - new Drawable[] + switch (args.Action) { - recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + case NotifyCollectionChangedAction.Add: + Debug.Assert(args.NewItems != null); + + foreach (int user in args.NewItems) + { + if (user == api.LocalUser.Value.Id) + spectatorClient.WatchUser(user); + } + + break; + + case NotifyCollectionChangedAction.Remove: + Debug.Assert(args.OldItems != null); + + foreach (int user in args.OldItems) + { + if (user == api.LocalUser.Value.Id) + spectatorClient.StopWatchingUser(user); + } + + break; + } + }, true); + + Children = new Drawable[] + { + new GridContainer + { + RelativeSizeAxes = Axes.Both, + Content = new[] { - Recorder = recorder = new TestReplayRecorder + new Drawable[] { - ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), - }, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] + recordingManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) { - new Box + Recorder = recorder = new TestReplayRecorder + { + ScreenSpaceToGamefield = pos => recordingManager.ToLocalSpace(pos), + }, + Child = new Container { - Colour = Color4.Brown, RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.Brown, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Sending", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestInputConsumer() + } }, - new OsuSpriteText - { - Text = "Sending", - Scale = new Vector2(3), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new TestInputConsumer() } }, + new Drawable[] + { + playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) + { + Clock = new FramedClock(manualClock), + ReplayInputHandler = replayHandler = new TestFramedReplayInputHandler(replay) + { + GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos), + }, + Child = new Container + { + RelativeSizeAxes = Axes.Both, + Children = new Drawable[] + { + new Box + { + Colour = Color4.DarkBlue, + RelativeSizeAxes = Axes.Both, + }, + new OsuSpriteText + { + Text = "Receiving", + Scale = new Vector2(3), + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + }, + new TestInputConsumer() + } + }, + } + } } }, - new Drawable[] - { - playbackManager = new TestRulesetInputManager(TestSceneModSettings.CreateTestRulesetInfo(), 0, SimultaneousBindingMode.Unique) - { - Clock = new FramedClock(manualClock), - ReplayInputHandler = replayHandler = new TestFramedReplayInputHandler(replay) - { - GamefieldToScreenSpace = pos => playbackManager.ToScreenSpace(pos), - }, - Child = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new Drawable[] - { - new Box - { - Colour = Color4.DarkBlue, - RelativeSizeAxes = Axes.Both, - }, - new OsuSpriteText - { - Text = "Receiving", - Scale = new Vector2(3), - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - }, - new TestInputConsumer() - } - }, - } - } - } + latencyDisplay = new OsuSpriteText() + }; }); - - Add(latencyDisplay = new OsuSpriteText()); - }); + } private void onNewFrames(int userId, FrameDataBundle frames) { @@ -189,6 +197,7 @@ namespace osu.Game.Tests.Visual.Gameplay [Test] public void TestBasic() { + AddStep("Wait for user input", () => { }); } private double latency = SpectatorClient.TIME_BETWEEN_SENDS; @@ -232,11 +241,15 @@ namespace osu.Game.Tests.Visual.Gameplay [TearDownSteps] public void TearDown() { - AddStep("stop recorder", () => - { - recorder.Expire(); - spectatorClient.OnNewFrames -= onNewFrames; - }); + AddStep("stop recorder", cleanUpState); + } + + private void cleanUpState() + { + // Ensure previous recorder is disposed else it may affect the global playing state of `SpectatorClient`. + recorder?.RemoveAndDisposeImmediately(); + recorder = null; + spectatorClient.OnNewFrames -= onNewFrames; } public class TestFramedReplayInputHandler : FramedReplayInputHandler diff --git a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs index 57d60cea9e..c65595d82e 100644 --- a/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs +++ b/osu.Game.Tests/Visual/Menus/TestSceneToolbar.cs @@ -20,7 +20,7 @@ namespace osu.Game.Tests.Visual.Menus private TestToolbar toolbar; [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } [SetUp] public void SetUp() => Schedule(() => diff --git a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs index 357db16e2c..5acb44ac45 100644 --- a/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs +++ b/osu.Game.Tests/Visual/Multiplayer/QueueModeTestScene.cs @@ -31,17 +31,16 @@ namespace osu.Game.Tests.Visual.Multiplayer protected BeatmapInfo InitialBeatmap { get; private set; } protected BeatmapInfo OtherBeatmap { get; private set; } - protected IScreen CurrentScreen => multiplayerScreenStack.CurrentScreen; - protected IScreen CurrentSubScreen => multiplayerScreenStack.MultiplayerScreen.CurrentSubScreen; + protected IScreen CurrentScreen => multiplayerComponents.CurrentScreen; + protected IScreen CurrentSubScreen => multiplayerComponents.MultiplayerScreen.CurrentSubScreen; private BeatmapManager beatmaps; private RulesetStore rulesets; private BeatmapSetInfo importedSet; - private TestMultiplayerScreenStack multiplayerScreenStack; + private TestMultiplayerComponents multiplayerComponents; - protected TestMultiplayerClient Client => multiplayerScreenStack.Client; - protected TestMultiplayerRoomManager RoomManager => multiplayerScreenStack.RoomManager; + protected TestMultiplayerClient Client => multiplayerComponents.Client; [Cached(typeof(UserLookupCache))] private UserLookupCache lookupCache = new TestUserLookupCache(); @@ -65,12 +64,12 @@ namespace osu.Game.Tests.Visual.Multiplayer OtherBeatmap = importedSet.Beatmaps.Last(b => b.RulesetID == 0); }); - AddStep("load multiplayer", () => LoadScreen(multiplayerScreenStack = new TestMultiplayerScreenStack())); - AddUntilStep("wait for multiplayer to load", () => multiplayerScreenStack.IsLoaded); + AddStep("load multiplayer", () => LoadScreen(multiplayerComponents = new TestMultiplayerComponents())); + AddUntilStep("wait for multiplayer to load", () => multiplayerComponents.IsLoaded); AddUntilStep("wait for lounge to load", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); - AddUntilStep("wait for lounge", () => multiplayerScreenStack.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); - AddStep("open room", () => multiplayerScreenStack.ChildrenOfType().Single().Open(new Room + AddUntilStep("wait for lounge", () => multiplayerComponents.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); + AddStep("open room", () => multiplayerComponents.ChildrenOfType().Single().Open(new Room { Name = { Value = "Test Room" }, QueueMode = { Value = Mode }, @@ -93,7 +92,7 @@ namespace osu.Game.Tests.Visual.Multiplayer InputManager.Click(MouseButton.Left); }); - AddUntilStep("wait for join", () => RoomManager.RoomJoined); + AddUntilStep("wait for join", () => Client.RoomJoined); } [Test] @@ -110,8 +109,8 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for ready", () => Client.LocalUser?.State == MultiplayerUserState.Ready); clickReadyButton(); - AddUntilStep("wait for player", () => multiplayerScreenStack.CurrentScreen is Player player && player.IsLoaded); - AddStep("exit player", () => multiplayerScreenStack.MultiplayerScreen.MakeCurrent()); + AddUntilStep("wait for player", () => multiplayerComponents.CurrentScreen is Player player && player.IsLoaded); + AddStep("exit player", () => multiplayerComponents.MultiplayerScreen.MakeCurrent()); } private void clickReadyButton() diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneAllPlayersQueueMode.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneAllPlayersQueueMode.cs index ccfae1deef..a5744f9986 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneAllPlayersQueueMode.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneAllPlayersQueueMode.cs @@ -19,7 +19,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestFirstItemSelectedByDefault() { - AddAssert("first item selected", () => Client.CurrentMatchPlayingItem.Value?.ID == Client.APIRoom?.Playlist[0].ID); + AddAssert("first item selected", () => Client.Room?.Settings.PlaylistItemId == Client.APIRoom?.Playlist[0].ID); } [Test] @@ -27,13 +27,11 @@ namespace osu.Game.Tests.Visual.Multiplayer { addItem(() => OtherBeatmap); AddAssert("playlist has 2 items", () => Client.APIRoom?.Playlist.Count == 2); - AddAssert("last playlist item is different", () => Client.APIRoom?.Playlist[1].Beatmap.Value.OnlineID == OtherBeatmap.OnlineID); addItem(() => InitialBeatmap); AddAssert("playlist has 3 items", () => Client.APIRoom?.Playlist.Count == 3); - AddAssert("last playlist item is different", () => Client.APIRoom?.Playlist[2].Beatmap.Value.OnlineID == InitialBeatmap.OnlineID); - AddAssert("first item still selected", () => Client.CurrentMatchPlayingItem.Value?.ID == Client.APIRoom?.Playlist[0].ID); + AddAssert("first item still selected", () => Client.Room?.Settings.PlaylistItemId == Client.APIRoom?.Playlist[0].ID); } [Test] @@ -43,7 +41,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("playlist has only one item", () => Client.APIRoom?.Playlist.Count == 1); AddAssert("playlist item is expired", () => Client.APIRoom?.Playlist[0].Expired == true); - AddAssert("last item selected", () => Client.CurrentMatchPlayingItem.Value?.ID == Client.APIRoom?.Playlist[0].ID); + AddAssert("last item selected", () => Client.Room?.Settings.PlaylistItemId == Client.APIRoom?.Playlist[0].ID); } [Test] @@ -55,12 +53,12 @@ namespace osu.Game.Tests.Visual.Multiplayer RunGameplay(); AddAssert("first item expired", () => Client.APIRoom?.Playlist[0].Expired == true); - AddAssert("next item selected", () => Client.CurrentMatchPlayingItem.Value?.ID == Client.APIRoom?.Playlist[1].ID); + AddAssert("next item selected", () => Client.Room?.Settings.PlaylistItemId == Client.APIRoom?.Playlist[1].ID); RunGameplay(); AddAssert("second item expired", () => Client.APIRoom?.Playlist[1].Expired == true); - AddAssert("next item selected", () => Client.CurrentMatchPlayingItem.Value?.ID == Client.APIRoom?.Playlist[2].ID); + AddAssert("next item selected", () => Client.Room?.Settings.PlaylistItemId == Client.APIRoom?.Playlist[2].ID); } [Test] @@ -74,22 +72,22 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("change queue mode", () => Client.ChangeSettings(queueMode: QueueMode.HostOnly)); AddAssert("playlist has 3 items", () => Client.APIRoom?.Playlist.Count == 3); - AddAssert("playlist item is the other beatmap", () => Client.CurrentMatchPlayingItem.Value?.BeatmapID == OtherBeatmap.OnlineID); - AddAssert("playlist item is not expired", () => Client.APIRoom?.Playlist[1].Expired == false); + AddAssert("item 2 is not expired", () => Client.APIRoom?.Playlist[1].Expired == false); + AddAssert("current item is the other beatmap", () => Client.Room?.Settings.PlaylistItemId == 2); } [Test] public void TestCorrectItemSelectedAfterNewItemAdded() { addItem(() => OtherBeatmap); - AddAssert("selected beatmap is initial beatmap", () => Beatmap.Value.BeatmapInfo.OnlineID == InitialBeatmap.OnlineID); + AddUntilStep("selected beatmap is initial beatmap", () => Beatmap.Value.BeatmapInfo.OnlineID == InitialBeatmap.OnlineID); } private void addItem(Func beatmap) { - AddStep("click edit button", () => + AddStep("click add button", () => { - InputManager.MoveMouseTo(this.ChildrenOfType().Single().AddOrEditPlaylistButton); + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); InputManager.Click(MouseButton.Left); }); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs index 2c28a1752e..423822cbe4 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoom.cs @@ -11,12 +11,14 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Testing; using osu.Framework.Utils; using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Online.Rooms.RoomStatuses; using osu.Game.Overlays; using osu.Game.Rulesets.Osu; using osu.Game.Screens.OnlinePlay.Lounge; using osu.Game.Screens.OnlinePlay.Lounge.Components; +using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Tests.Beatmaps; using osuTK; @@ -172,6 +174,39 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("password icon hidden", () => Precision.AlmostEquals(0, drawableRoom.ChildrenOfType().Single().Alpha)); } + [Test] + public void TestMultiplayerRooms() + { + AddStep("create rooms", () => Child = new FillFlowContainer + { + AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.X, + Direction = FillDirection.Vertical, + Spacing = new Vector2(5), + Children = new[] + { + new DrawableMatchRoom(new Room + { + Name = { Value = "A host-only room" }, + QueueMode = { Value = QueueMode.HostOnly }, + Type = { Value = MatchType.HeadToHead } + }), + new DrawableMatchRoom(new Room + { + Name = { Value = "An all-players, team-versus room" }, + QueueMode = { Value = QueueMode.AllPlayers }, + Type = { Value = MatchType.TeamVersus } + }), + new DrawableMatchRoom(new Room + { + Name = { Value = "A round-robin room" }, + QueueMode = { Value = QueueMode.AllPlayersRoundRobin }, + Type = { Value = MatchType.HeadToHead } + }), + } + }); + } + private DrawableRoom createLoungeRoom(Room room) { room.Host.Value ??= new APIUser { Username = "peppy", Id = 2 }; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs index 55aa665ff1..f9784384fd 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneDrawableRoomPlaylist.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -48,7 +49,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestNonEditableNonSelectable() { - createPlaylist(false, false); + createPlaylist(); moveToItem(0); assertHandleVisibility(0, false); @@ -61,7 +62,11 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestEditable() { - createPlaylist(true, false); + createPlaylist(p => + { + p.AllowReordering = true; + p.AllowDeletion = true; + }); moveToItem(0); assertHandleVisibility(0, true); @@ -74,7 +79,12 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestMarkInvalid() { - createPlaylist(true, true); + createPlaylist(p => + { + p.AllowReordering = true; + p.AllowDeletion = true; + p.AllowSelection = true; + }); AddStep("mark item 0 as invalid", () => playlist.Items[0].MarkInvalid()); @@ -87,7 +97,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestSelectable() { - createPlaylist(false, true); + createPlaylist(p => p.AllowSelection = true); moveToItem(0); assertHandleVisibility(0, false); @@ -101,7 +111,12 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestEditableSelectable() { - createPlaylist(true, true); + createPlaylist(p => + { + p.AllowReordering = true; + p.AllowDeletion = true; + p.AllowSelection = true; + }); moveToItem(0); assertHandleVisibility(0, true); @@ -115,7 +130,12 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestSelectionNotLostAfterRearrangement() { - createPlaylist(true, true); + createPlaylist(p => + { + p.AllowReordering = true; + p.AllowDeletion = true; + p.AllowSelection = true; + }); moveToItem(0); AddStep("click", () => InputManager.Click(MouseButton.Left)); @@ -128,95 +148,6 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("item 1 is selected", () => playlist.SelectedItem.Value == playlist.Items[1]); } - [Test] - public void TestItemRemovedOnDeletion() - { - PlaylistItem selectedItem = null; - - createPlaylist(true, true); - - moveToItem(0); - AddStep("click", () => InputManager.Click(MouseButton.Left)); - AddStep("retrieve selection", () => selectedItem = playlist.SelectedItem.Value); - - moveToDeleteButton(0); - AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); - - AddAssert("item removed", () => !playlist.Items.Contains(selectedItem)); - } - - [Test] - public void TestNextItemSelectedAfterDeletion() - { - createPlaylist(true, true); - - moveToItem(0); - AddStep("click", () => InputManager.Click(MouseButton.Left)); - - moveToDeleteButton(0); - AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); - - AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]); - } - - [Test] - public void TestLastItemSelectedAfterLastItemDeleted() - { - createPlaylist(true, true); - - AddWaitStep("wait for flow", 5); // Items may take 1 update frame to flow. A wait count of 5 is guaranteed to result in the flow being updated as desired. - AddStep("scroll to bottom", () => playlist.ChildrenOfType>().First().ScrollToEnd(false)); - - moveToItem(19); - AddStep("click", () => InputManager.Click(MouseButton.Left)); - - moveToDeleteButton(19); - AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); - - AddAssert("item 18 is selected", () => playlist.SelectedItem.Value == playlist.Items[18]); - } - - [Test] - public void TestSelectionResetWhenAllItemsDeleted() - { - createPlaylist(true, true); - - AddStep("remove all but one item", () => - { - playlist.Items.RemoveRange(1, playlist.Items.Count - 1); - }); - - moveToItem(0); - AddStep("click", () => InputManager.Click(MouseButton.Left)); - moveToDeleteButton(0); - AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); - - AddAssert("no item selected", () => playlist.SelectedItem.Value == null); - } - - // Todo: currently not possible due to bindable list shortcomings (https://github.com/ppy/osu-framework/issues/3081) - // [Test] - public void TestNextItemSelectedAfterExternalDeletion() - { - createPlaylist(true, true); - - moveToItem(0); - AddStep("click", () => InputManager.Click(MouseButton.Left)); - AddStep("remove item 0", () => playlist.Items.RemoveAt(0)); - - AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]); - } - - [Test] - public void TestChangeBeatmapAndRemove() - { - createPlaylist(true, true); - - AddStep("change beatmap of first item", () => playlist.Items[0].BeatmapID = 30); - moveToDeleteButton(0); - AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); - } - [Test] public void TestDownloadButtonHiddenWhenBeatmapExists() { @@ -224,7 +155,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("import beatmap", () => manager.Import(beatmap.BeatmapSet).Wait()); - createPlaylist(beatmap); + createPlaylistWithBeatmaps(beatmap); assertDownloadButtonVisible(false); @@ -247,7 +178,7 @@ namespace osu.Game.Tests.Visual.Multiplayer var byChecksum = CreateAPIBeatmap(); byChecksum.Checksum = "1337"; // Some random checksum that does not exist locally. - createPlaylist(byOnlineId, byChecksum); + createPlaylistWithBeatmaps(byOnlineId, byChecksum); AddAssert("download buttons shown", () => playlist.ChildrenOfType().All(d => d.IsPresent)); } @@ -261,7 +192,7 @@ namespace osu.Game.Tests.Visual.Multiplayer beatmap.BeatmapSet.HasExplicitContent = true; - createPlaylist(beatmap); + createPlaylistWithBeatmaps(beatmap); } [Test] @@ -269,7 +200,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("create playlist", () => { - Child = playlist = new TestPlaylist(false, false) + Child = playlist = new TestPlaylist { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -312,11 +243,22 @@ namespace osu.Game.Tests.Visual.Multiplayer [TestCase(true)] public void TestWithOwner(bool withOwner) { - createPlaylist(false, false, withOwner); + createPlaylist(p => p.ShowItemOwners = withOwner); AddAssert("owner visible", () => playlist.ChildrenOfType().All(a => a.IsPresent == withOwner)); } + [Test] + public void TestWithAllButtonsEnabled() + { + createPlaylist(p => + { + p.AllowDeletion = true; + p.AllowShowingResults = true; + p.AllowEditing = true; + }); + } + private void moveToItem(int index, Vector2? offset = null) => AddStep($"move mouse to item {index}", () => InputManager.MoveMouseTo(playlist.ChildrenOfType().ElementAt(index), offset)); @@ -326,12 +268,6 @@ namespace osu.Game.Tests.Visual.Multiplayer InputManager.MoveMouseTo(item.ChildrenOfType.PlaylistItemHandle>().Single(), offset); }); - private void moveToDeleteButton(int index, Vector2? offset = null) => AddStep($"move mouse to delete button {index}", () => - { - var item = playlist.ChildrenOfType>().ElementAt(index); - InputManager.MoveMouseTo(item.ChildrenOfType().ElementAt(0), offset); - }); - private void assertHandleVisibility(int index, bool visible) => AddAssert($"handle {index} {(visible ? "is" : "is not")} visible", () => (playlist.ChildrenOfType.PlaylistItemHandle>().ElementAt(index).Alpha > 0) == visible); @@ -340,17 +276,19 @@ namespace osu.Game.Tests.Visual.Multiplayer => AddAssert($"delete button {index} {(visible ? "is" : "is not")} visible", () => (playlist.ChildrenOfType().ElementAt(2 + index * 2).Alpha > 0) == visible); - private void createPlaylist(bool allowEdit, bool allowSelection, bool showItemOwner = false) + private void createPlaylist(Action setupPlaylist = null) { AddStep("create playlist", () => { - Child = playlist = new TestPlaylist(allowEdit, allowSelection, showItemOwner) + Child = playlist = new TestPlaylist { Anchor = Anchor.Centre, Origin = Anchor.Centre, Size = new Vector2(500, 300) }; + setupPlaylist?.Invoke(playlist); + for (int i = 0; i < 20; i++) { playlist.Items.Add(new PlaylistItem @@ -386,11 +324,11 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); } - private void createPlaylist(params IBeatmapInfo[] beatmaps) + private void createPlaylistWithBeatmaps(params IBeatmapInfo[] beatmaps) { AddStep("create playlist", () => { - Child = playlist = new TestPlaylist(false, false) + Child = playlist = new TestPlaylist { Anchor = Anchor.Centre, Origin = Anchor.Centre, @@ -423,11 +361,6 @@ namespace osu.Game.Tests.Visual.Multiplayer private class TestPlaylist : DrawableRoomPlaylist { public new IReadOnlyDictionary> ItemMap => base.ItemMap; - - public TestPlaylist(bool allowEdit, bool allowSelection, bool showItemOwner = false) - : base(allowEdit, allowSelection, showItemOwner: showItemOwner) - { - } } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs index 1de7289446..c7eeff81fe 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneHostOnlyQueueMode.cs @@ -7,7 +7,9 @@ using NUnit.Framework; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Online.Multiplayer; +using osu.Game.Screens.OnlinePlay; using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist; using osuTK.Input; namespace osu.Game.Tests.Visual.Multiplayer @@ -19,7 +21,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestFirstItemSelectedByDefault() { - AddAssert("first item selected", () => Client.CurrentMatchPlayingItem.Value?.ID == Client.APIRoom?.Playlist[0].ID); + AddAssert("first item selected", () => Client.Room?.Settings.PlaylistItemId == Client.APIRoom?.Playlist[0].ID); } [Test] @@ -27,7 +29,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { selectNewItem(() => InitialBeatmap); - AddAssert("playlist item still selected", () => Client.CurrentMatchPlayingItem.Value?.ID == Client.APIRoom?.Playlist[0].ID); + AddAssert("playlist item still selected", () => Client.Room?.Settings.PlaylistItemId == Client.APIRoom?.Playlist[0].ID); } [Test] @@ -35,7 +37,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { selectNewItem(() => OtherBeatmap); - AddAssert("playlist item still selected", () => Client.CurrentMatchPlayingItem.Value?.ID == Client.APIRoom?.Playlist[0].ID); + AddAssert("playlist item still selected", () => Client.Room?.Settings.PlaylistItemId == Client.APIRoom?.Playlist[0].ID); } [Test] @@ -46,7 +48,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("playlist contains two items", () => Client.APIRoom?.Playlist.Count == 2); AddAssert("first playlist item expired", () => Client.APIRoom?.Playlist[0].Expired == true); AddAssert("second playlist item not expired", () => Client.APIRoom?.Playlist[1].Expired == false); - AddAssert("second playlist item selected", () => Client.CurrentMatchPlayingItem.Value?.ID == Client.APIRoom?.Playlist[1].ID); + AddAssert("second playlist item selected", () => Client.Room?.Settings.PlaylistItemId == Client.APIRoom?.Playlist[1].ID); } [Test] @@ -74,11 +76,25 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("api room updated", () => Client.APIRoom?.QueueMode.Value == QueueMode.AllPlayers); } + [Test] + public void TestAddItemsAsHost() + { + addItem(() => OtherBeatmap); + + AddAssert("playlist contains two items", () => Client.APIRoom?.Playlist.Count == 2); + } + private void selectNewItem(Func beatmap) { + AddUntilStep("wait for playlist panels to load", () => + { + var queueList = this.ChildrenOfType().Single(); + return queueList.ChildrenOfType().Count() == queueList.Items.Count; + }); + AddStep("click edit button", () => { - InputManager.MoveMouseTo(this.ChildrenOfType().Single().AddOrEditPlaylistButton); + InputManager.MoveMouseTo(this.ChildrenOfType().First()); InputManager.Click(MouseButton.Left); }); @@ -88,7 +104,20 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("select other beatmap", () => ((Screens.Select.SongSelect)CurrentSubScreen).FinaliseSelection(otherBeatmap = beatmap())); AddUntilStep("wait for return to match", () => CurrentSubScreen is MultiplayerMatchSubScreen); - AddUntilStep("selected item is new beatmap", () => Client.CurrentMatchPlayingItem.Value?.Beatmap.Value?.OnlineID == otherBeatmap.OnlineID); + AddUntilStep("selected item is new beatmap", () => (CurrentSubScreen as MultiplayerMatchSubScreen)?.SelectedItem.Value?.BeatmapID == otherBeatmap.OnlineID); + } + + private void addItem(Func beatmap) + { + AddStep("click add button", () => + { + InputManager.MoveMouseTo(this.ChildrenOfType().Single()); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for song select", () => CurrentSubScreen is Screens.Select.SongSelect select && select.BeatmapSetsLoaded); + AddStep("select other beatmap", () => ((Screens.Select.SongSelect)CurrentSubScreen).FinaliseSelection(beatmap())); + AddUntilStep("wait for return to match", () => CurrentSubScreen is MultiplayerMatchSubScreen); } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs index d66603a448..1d61a5d496 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchBeatmapDetailArea.cs @@ -2,11 +2,8 @@ // 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.Beatmaps; using osu.Game.Online.Rooms; -using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Screens.OnlinePlay.Components; @@ -18,12 +15,6 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestSceneMatchBeatmapDetailArea : OnlinePlayTestScene { - [Resolved] - private BeatmapManager beatmapManager { get; set; } - - [Resolved] - private RulesetStore rulesetStore { get; set; } - [SetUp] public new void Setup() => Schedule(() => { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index 4521a7fa0f..bd0e5c4eb9 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs @@ -30,6 +30,8 @@ using osu.Game.Screens.OnlinePlay.Lounge.Components; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Multiplayer; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; +using osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist; +using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; using osu.Game.Screens.Spectate; @@ -44,10 +46,10 @@ namespace osu.Game.Tests.Visual.Multiplayer private RulesetStore rulesets; private BeatmapSetInfo importedSet; - private TestMultiplayerScreenStack multiplayerScreenStack; + private TestMultiplayerComponents multiplayerComponents; - private TestMultiplayerClient client => multiplayerScreenStack.Client; - private TestMultiplayerRoomManager roomManager => multiplayerScreenStack.RoomManager; + private TestMultiplayerClient client => multiplayerComponents.Client; + private TestMultiplayerRoomManager roomManager => multiplayerComponents.RoomManager; [Cached(typeof(UserLookupCache))] private UserLookupCache lookupCache = new TestUserLookupCache(); @@ -69,8 +71,8 @@ namespace osu.Game.Tests.Visual.Multiplayer importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); }); - AddStep("load multiplayer", () => LoadScreen(multiplayerScreenStack = new TestMultiplayerScreenStack())); - AddUntilStep("wait for multiplayer to load", () => multiplayerScreenStack.IsLoaded); + AddStep("load multiplayer", () => LoadScreen(multiplayerComponents = new TestMultiplayerComponents())); + AddUntilStep("wait for multiplayer to load", () => multiplayerComponents.IsLoaded); AddUntilStep("wait for lounge to load", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); } @@ -214,7 +216,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("Press select", () => InputManager.Key(Key.Enter)); AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); - AddUntilStep("wait for join", () => roomManager.RoomJoined); + AddUntilStep("wait for join", () => client.RoomJoined); } [Test] @@ -293,7 +295,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("join room", () => InputManager.Key(Key.Enter)); AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); - AddUntilStep("wait for join", () => roomManager.RoomJoined); + AddUntilStep("wait for join", () => client.RoomJoined); AddAssert("Check participant count correct", () => client.APIRoom?.ParticipantCount.Value == 1); AddAssert("Check participant list contains user", () => client.APIRoom?.RecentParticipants.Count(u => u.Id == API.LocalUser.Value.Id) == 1); @@ -351,7 +353,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("press join room button", () => passwordEntryPopover.ChildrenOfType().First().TriggerClick()); AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); - AddUntilStep("wait for join", () => roomManager.RoomJoined); + AddUntilStep("wait for join", () => client.RoomJoined); } [Test] @@ -391,12 +393,51 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - AddStep("set user ready", () => client.ChangeState(MultiplayerUserState.Ready)); - AddStep("delete beatmap", () => beatmaps.Delete(importedSet)); + pressReadyButton(); + AddStep("delete beatmap", () => beatmaps.Delete(importedSet)); AddUntilStep("user state is idle", () => client.LocalUser?.State == MultiplayerUserState.Idle); } + [Test] + public void TestPlayStartsWithCorrectBeatmapWhileAtSongSelect() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + pressReadyButton(); + + AddStep("Enter song select", () => + { + var currentSubScreen = ((Screens.OnlinePlay.Multiplayer.Multiplayer)multiplayerComponents.CurrentScreen).CurrentSubScreen; + ((MultiplayerMatchSubScreen)currentSubScreen).OpenSongSelection(client.Room?.Settings.PlaylistItemId); + }); + + AddUntilStep("wait for song select", () => this.ChildrenOfType().FirstOrDefault()?.BeatmapSetsLoaded == true); + + AddAssert("Beatmap matches current item", () => Beatmap.Value.BeatmapInfo.OnlineID == client.Room?.Playlist.First().BeatmapID); + + AddStep("Select next beatmap", () => InputManager.Key(Key.Down)); + + AddUntilStep("Beatmap doesn't match current item", () => Beatmap.Value.BeatmapInfo.OnlineID != client.Room?.Playlist.First().BeatmapID); + + AddStep("start match externally", () => client.StartMatch()); + + AddUntilStep("play started", () => multiplayerComponents.CurrentScreen is Player); + + AddAssert("Beatmap matches current item", () => Beatmap.Value.BeatmapInfo.OnlineID == client.Room?.Playlist.First().BeatmapID); + } + [Test] public void TestLocalPlayDoesNotStartWhileSpectatingWithNoBeatmap() { @@ -432,7 +473,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("start match externally", () => client.StartMatch()); - AddAssert("play not started", () => multiplayerScreenStack.IsCurrentScreen()); + AddAssert("play not started", () => multiplayerComponents.IsCurrentScreen()); } [Test] @@ -476,7 +517,7 @@ namespace osu.Game.Tests.Visual.Multiplayer importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); }); - AddUntilStep("play started", () => multiplayerScreenStack.CurrentScreen is SpectatorScreen); + AddUntilStep("play started", () => multiplayerComponents.CurrentScreen is SpectatorScreen); } [Test] @@ -518,16 +559,16 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("open mod overlay", () => this.ChildrenOfType().Single().TriggerClick()); - AddStep("invoke on back button", () => multiplayerScreenStack.OnBackButton()); + AddStep("invoke on back button", () => multiplayerComponents.OnBackButton()); AddAssert("mod overlay is hidden", () => this.ChildrenOfType().Single().State.Value == Visibility.Hidden); AddAssert("dialog overlay is hidden", () => DialogOverlay.State.Value == Visibility.Hidden); - testLeave("back button", () => multiplayerScreenStack.OnBackButton()); + testLeave("back button", () => multiplayerComponents.OnBackButton()); // mimics home button and OS window close - testLeave("forced exit", () => multiplayerScreenStack.Exit()); + testLeave("forced exit", () => multiplayerComponents.Exit()); void testLeave(string actionName, Action action) { @@ -555,20 +596,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - AddUntilStep("wait for ready button to be enabled", () => readyButton.ChildrenOfType().Single().Enabled.Value); - - AddStep("click ready button", () => - { - InputManager.MoveMouseTo(readyButton); - InputManager.Click(MouseButton.Left); - }); - - AddUntilStep("wait for player to be ready", () => client.Room?.Users[0].State == MultiplayerUserState.Ready); - AddUntilStep("wait for ready button to be enabled", () => readyButton.ChildrenOfType().Single().Enabled.Value); - - AddStep("click start button", () => InputManager.Click(MouseButton.Left)); - - AddUntilStep("wait for player", () => multiplayerScreenStack.CurrentScreen is Player); + enterGameplay(); // Gameplay runs in real-time, so we need to incrementally check if gameplay has finished in order to not time out. for (double i = 1000; i < TestResources.QUICK_BEATMAP_LENGTH; i += 1000) @@ -577,7 +605,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep($"wait for time > {i}", () => this.ChildrenOfType().SingleOrDefault()?.GameplayClock.CurrentTime > time); } - AddUntilStep("wait for results", () => multiplayerScreenStack.CurrentScreen is ResultsScreen); + AddUntilStep("wait for results", () => multiplayerComponents.CurrentScreen is ResultsScreen); } [Test] @@ -618,7 +646,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("join room", () => InputManager.Key(Key.Enter)); AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); - AddUntilStep("wait for join", () => roomManager.RoomJoined); + AddUntilStep("wait for join", () => client.RoomJoined); AddAssert("local room has correct settings", () => { @@ -628,12 +656,178 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } - private MultiplayerReadyButton readyButton => this.ChildrenOfType().Single(); + [Test] + public void TestSpectatingStateResetOnBackButtonDuringGameplay() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + QueueMode = { Value = QueueMode.AllPlayers }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddStep("set spectating state", () => client.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Spectating)); + AddUntilStep("state set to spectating", () => client.LocalUser?.State == MultiplayerUserState.Spectating); + + AddStep("join other user", () => client.AddUser(new APIUser { Id = 1234 })); + AddStep("set other user ready", () => client.ChangeUserState(1234, MultiplayerUserState.Ready)); + + pressReadyButton(1234); + AddUntilStep("wait for gameplay", () => (multiplayerComponents.CurrentScreen as MultiSpectatorScreen)?.IsLoaded == true); + + AddStep("press back button and exit", () => + { + multiplayerComponents.OnBackButton(); + multiplayerComponents.Exit(); + }); + + AddUntilStep("wait for return to match subscreen", () => multiplayerComponents.MultiplayerScreen.IsCurrentScreen()); + AddUntilStep("user state is idle", () => client.LocalUser?.State == MultiplayerUserState.Idle); + } + + [Test] + public void TestSpectatingStateNotResetOnBackButtonOutsideOfGameplay() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + QueueMode = { Value = QueueMode.AllPlayers }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + AddStep("set spectating state", () => client.ChangeUserState(API.LocalUser.Value.OnlineID, MultiplayerUserState.Spectating)); + AddUntilStep("state set to spectating", () => client.LocalUser?.State == MultiplayerUserState.Spectating); + + AddStep("join other user", () => client.AddUser(new APIUser { Id = 1234 })); + AddStep("set other user ready", () => client.ChangeUserState(1234, MultiplayerUserState.Ready)); + + pressReadyButton(1234); + AddUntilStep("wait for gameplay", () => (multiplayerComponents.CurrentScreen as MultiSpectatorScreen)?.IsLoaded == true); + AddStep("set other user loaded", () => client.ChangeUserState(1234, MultiplayerUserState.Loaded)); + AddStep("set other user finished play", () => client.ChangeUserState(1234, MultiplayerUserState.FinishedPlay)); + + AddStep("press back button and exit", () => + { + multiplayerComponents.OnBackButton(); + multiplayerComponents.Exit(); + }); + + AddUntilStep("wait for return to match subscreen", () => multiplayerComponents.MultiplayerScreen.IsCurrentScreen()); + AddWaitStep("wait for possible state change", 5); + AddUntilStep("user state is spectating", () => client.LocalUser?.State == MultiplayerUserState.Spectating); + } + + [Test] + public void TestItemAddedByOtherUserDuringGameplay() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + QueueMode = { Value = QueueMode.AllPlayers }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + enterGameplay(); + + AddStep("join other user", () => client.AddUser(new APIUser { Id = 1234 })); + AddStep("add item as other user", () => client.AddUserPlaylistItem(1234, new MultiplayerPlaylistItem(new PlaylistItem + { + BeatmapID = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo.OnlineID ?? -1 + }))); + + AddUntilStep("item arrived in playlist", () => client.Room?.Playlist.Count == 2); + + AddStep("exit gameplay as initial user", () => multiplayerComponents.MultiplayerScreen.MakeCurrent()); + AddUntilStep("queue contains item", () => this.ChildrenOfType().Single().Items.Single().ID == 2); + } + + [Test] + public void TestItemAddedAndDeletedByOtherUserDuringGameplay() + { + createRoom(() => new Room + { + Name = { Value = "Test Room" }, + QueueMode = { Value = QueueMode.AllPlayers }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + } + } + }); + + enterGameplay(); + + AddStep("join other user", () => client.AddUser(new APIUser { Id = 1234 })); + AddStep("add item as other user", () => client.AddUserPlaylistItem(1234, new MultiplayerPlaylistItem(new PlaylistItem + { + BeatmapID = beatmaps.GetWorkingBeatmap(importedSet.Beatmaps.First(b => b.RulesetID == 0)).BeatmapInfo.OnlineID ?? -1 + }))); + + AddUntilStep("item arrived in playlist", () => client.Room?.Playlist.Count == 2); + + AddStep("delete item as other user", () => client.RemoveUserPlaylistItem(1234, 2)); + AddUntilStep("item removed from playlist", () => client.Room?.Playlist.Count == 1); + + AddStep("exit gameplay as initial user", () => multiplayerComponents.MultiplayerScreen.MakeCurrent()); + AddUntilStep("queue is empty", () => this.ChildrenOfType().Single().Items.Count == 0); + } + + private void enterGameplay() + { + pressReadyButton(); + pressReadyButton(); + AddUntilStep("wait for player", () => multiplayerComponents.CurrentScreen is Player); + } + + private ReadyButton readyButton => this.ChildrenOfType().Single(); + + private void pressReadyButton(int? playingUserId = null) + { + AddUntilStep("wait for ready button to be enabled", () => readyButton.Enabled.Value); + + MultiplayerUserState lastState = MultiplayerUserState.Idle; + MultiplayerRoomUser user = null; + + AddStep("click ready button", () => + { + user = playingUserId == null ? client.LocalUser : client.Room?.Users.Single(u => u.UserID == playingUserId); + lastState = user?.State ?? MultiplayerUserState.Idle; + + InputManager.MoveMouseTo(readyButton); + InputManager.Click(MouseButton.Left); + }); + + AddUntilStep("wait for state change", () => user?.State != lastState); + } private void createRoom(Func room) { - AddUntilStep("wait for lounge", () => multiplayerScreenStack.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); - AddStep("open room", () => multiplayerScreenStack.ChildrenOfType().Single().Open(room())); + AddUntilStep("wait for lounge", () => multiplayerComponents.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); + AddStep("open room", () => multiplayerComponents.ChildrenOfType().Single().Open(room())); AddUntilStep("wait for room open", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); AddWaitStep("wait for transition", 2); @@ -644,7 +838,7 @@ namespace osu.Game.Tests.Visual.Multiplayer InputManager.Click(MouseButton.Left); }); - AddUntilStep("wait for join", () => roomManager.RoomJoined); + AddUntilStep("wait for join", () => client.RoomJoined); } } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs index 84b24ba3a1..d671673d3c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSongSelect.cs @@ -144,7 +144,8 @@ namespace osu.Game.Tests.Visual.Multiplayer 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)); @@ -178,7 +179,7 @@ namespace osu.Game.Tests.Visual.Multiplayer public new BeatmapCarousel Carousel => base.Carousel; public TestMultiplayerMatchSongSelect(Room room, WorkingBeatmap beatmap = null, RulesetInfo ruleset = null) - : base(room, beatmap, ruleset) + : base(room, null, beatmap, ruleset) { } } diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs index 5708b2f789..73f2ed5b39 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlayer.cs @@ -6,6 +6,7 @@ using NUnit.Framework; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; using osu.Game.Rulesets.Osu; using osu.Game.Screens.OnlinePlay.Multiplayer; @@ -27,7 +28,11 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("initialise gameplay", () => { - Stack.Push(player = new MultiplayerPlayer(Client.APIRoom, Client.CurrentMatchPlayingItem.Value, Client.Room?.Users.ToArray())); + Stack.Push(player = new MultiplayerPlayer(Client.APIRoom, new PlaylistItem + { + Beatmap = { Value = Beatmap.Value.BeatmapInfo }, + Ruleset = { Value = Beatmap.Value.BeatmapInfo.Ruleset } + }, Client.Room?.Users.ToArray())); }); AddUntilStep("wait for player to be current", () => player.IsCurrentScreen() && player.IsLoaded); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs new file mode 100644 index 0000000000..674ee0f186 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerPlaylist.cs @@ -0,0 +1,234 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Graphics; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist; +using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Resources; +using osuTK; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiplayerPlaylist : MultiplayerTestScene + { + private MultiplayerPlaylist list; + private BeatmapManager beatmaps; + private RulesetStore rulesets; + private BeatmapSetInfo importedSet; + private BeatmapInfo importedBeatmap; + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); + } + + [SetUp] + public new void Setup() => Schedule(() => + { + Child = list = new MultiplayerPlaylist + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + RelativeSizeAxes = Axes.Both, + Size = new Vector2(0.4f, 0.8f) + }; + }); + + [SetUpSteps] + public new void SetUpSteps() + { + AddStep("import beatmap", () => + { + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); + importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); + importedBeatmap = importedSet.Beatmaps.First(b => b.RulesetID == 0); + }); + + AddStep("change to all players mode", () => Client.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers })); + } + + [Test] + public void TestNonExpiredItemsAddedToQueueList() + { + assertItemInQueueListStep(1, 0); + + addItemStep(); + assertItemInQueueListStep(2, 1); + + addItemStep(); + assertItemInQueueListStep(3, 2); + } + + [Test] + public void TestExpiredItemsAddedToHistoryList() + { + assertItemInQueueListStep(1, 0); + + addItemStep(true); + assertItemInHistoryListStep(2, 0); + + addItemStep(true); + assertItemInHistoryListStep(3, 0); + assertItemInHistoryListStep(2, 1); + + // Initial item is still in the queue. + assertItemInQueueListStep(1, 0); + } + + [Test] + public void TestExpiredItemsMoveToQueueList() + { + addItemStep(); + addItemStep(); + + AddStep("finish current item", () => Client.FinishCurrentItem()); + + assertItemInHistoryListStep(1, 0); + assertItemInQueueListStep(2, 0); + assertItemInQueueListStep(3, 1); + + AddStep("finish current item", () => Client.FinishCurrentItem()); + + assertItemInHistoryListStep(2, 0); + assertItemInHistoryListStep(1, 1); + assertItemInQueueListStep(3, 0); + + AddStep("finish current item", () => Client.FinishCurrentItem()); + + assertItemInHistoryListStep(3, 0); + assertItemInHistoryListStep(2, 1); + assertItemInHistoryListStep(1, 2); + } + + [Test] + public void TestListsClearedWhenRoomLeft() + { + addItemStep(); + AddStep("finish current item", () => Client.FinishCurrentItem()); + + AddStep("leave room", () => RoomManager.PartRoom()); + AddUntilStep("wait for room part", () => Client.Room == null); + + AddUntilStep("item 0 not in lists", () => !inHistoryList(0) && !inQueueList(0)); + AddUntilStep("item 1 not in lists", () => !inHistoryList(0) && !inQueueList(0)); + } + + [Ignore("Expired items are initially removed from the room.")] + [Test] + public void TestJoinRoomWithMixedItemsAddedInCorrectLists() + { + AddStep("leave room", () => RoomManager.PartRoom()); + AddUntilStep("wait for room part", () => Client.Room == null); + + AddStep("join room with items", () => + { + RoomManager.CreateRoom(new Room + { + Name = { Value = "test name" }, + Playlist = + { + new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(Ruleset.Value).BeatmapInfo }, + Ruleset = { Value = Ruleset.Value } + }, + new PlaylistItem + { + Beatmap = { Value = new TestBeatmap(Ruleset.Value).BeatmapInfo }, + Ruleset = { Value = Ruleset.Value }, + Expired = true + } + } + }); + }); + + AddUntilStep("wait for room join", () => RoomJoined); + + assertItemInQueueListStep(1, 0); + assertItemInHistoryListStep(2, 0); + } + + /// + /// Adds a step to create a new playlist item. + /// + private void addItemStep(bool expired = false) => AddStep("add item", () => Client.AddPlaylistItem(new MultiplayerPlaylistItem(new PlaylistItem + { + Beatmap = { Value = importedBeatmap }, + BeatmapID = importedBeatmap.OnlineID ?? -1, + Expired = expired, + PlayedAt = DateTimeOffset.Now + }))); + + /// + /// Asserts the position of a given playlist item in the queue list. + /// + /// The item id. + /// The index at which the item should appear visually. The item with index 0 is at the top of the list. + private void assertItemInQueueListStep(int playlistItemId, int visualIndex) + { + changeDisplayModeStep(MultiplayerPlaylistDisplayMode.Queue); + + AddUntilStep($"{playlistItemId} in queue at pos = {visualIndex}", () => + { + return !inHistoryList(playlistItemId) + && this.ChildrenOfType() + .Single() + .ChildrenOfType() + .OrderBy(drawable => drawable.Position.Y) + .TakeWhile(drawable => drawable.Item.ID != playlistItemId) + .Count() == visualIndex; + }); + } + + /// + /// Asserts the position of a given playlist item in the history list. + /// + /// The item id. + /// The index at which the item should appear visually. The item with index 0 is at the top of the list. + private void assertItemInHistoryListStep(int playlistItemId, int visualIndex) + { + changeDisplayModeStep(MultiplayerPlaylistDisplayMode.History); + + AddUntilStep($"{playlistItemId} in history at pos = {visualIndex}", () => + { + return !inQueueList(playlistItemId) + && this.ChildrenOfType() + .Single() + .ChildrenOfType() + .OrderBy(drawable => drawable.Position.Y) + .TakeWhile(drawable => drawable.Item.ID != playlistItemId) + .Count() == visualIndex; + }); + } + + private void changeDisplayModeStep(MultiplayerPlaylistDisplayMode mode) => AddStep($"change list to {mode}", () => list.DisplayMode.Value = mode); + + private bool inQueueList(int playlistItemId) + { + return this.ChildrenOfType() + .Single() + .Items.Any(i => i.ID == playlistItemId); + } + + private bool inHistoryList(int playlistItemId) + { + return this.ChildrenOfType() + .Single() + .Items.Any(i => i.ID == playlistItemId); + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs new file mode 100644 index 0000000000..61a92c32a4 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs @@ -0,0 +1,164 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Audio; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Platform; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Database; +using osu.Game.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist; +using osu.Game.Tests.Resources; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestSceneMultiplayerQueueList : MultiplayerTestScene + { + private readonly Bindable selectedItem = new Bindable(); + + [Cached(typeof(UserLookupCache))] + private readonly TestUserLookupCache userLookupCache = new TestUserLookupCache(); + + private MultiplayerQueueList playlist; + private BeatmapManager beatmaps; + private RulesetStore rulesets; + private BeatmapSetInfo importedSet; + private BeatmapInfo importedBeatmap; + + [BackgroundDependencyLoader] + private void load(GameHost host, AudioManager audio) + { + Dependencies.Cache(rulesets = new RulesetStore(ContextFactory)); + Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, ContextFactory, rulesets, null, audio, Resources, host, Beatmap.Default)); + } + + public override void SetUpSteps() + { + base.SetUpSteps(); + + AddStep("create playlist", () => + { + selectedItem.Value = null; + + Child = playlist = new MultiplayerQueueList + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500, 300), + SelectedItem = { BindTarget = selectedItem }, + Items = { BindTarget = Client.APIRoom!.Playlist } + }; + }); + + AddStep("import beatmap", () => + { + beatmaps.Import(TestResources.GetQuickTestBeatmapForImport()).Wait(); + importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); + importedBeatmap = importedSet.Beatmaps.First(b => b.RulesetID == 0); + }); + + AddStep("change to all players mode", () => Client.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers })); + } + + [Test] + public void TestDeleteButtonAlwaysVisibleForHost() + { + AddStep("set all players queue mode", () => Client.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers })); + AddUntilStep("wait for queue mode change", () => Client.APIRoom?.QueueMode.Value == QueueMode.AllPlayers); + + addPlaylistItem(() => API.LocalUser.Value.OnlineID); + assertDeleteButtonVisibility(1, true); + addPlaylistItem(() => 1234); + assertDeleteButtonVisibility(2, true); + } + + [Test] + public void TestDeleteButtonOnlyVisibleForItemOwnerIfNotHost() + { + AddStep("set all players queue mode", () => Client.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers })); + AddUntilStep("wait for queue mode change", () => Client.APIRoom?.QueueMode.Value == QueueMode.AllPlayers); + + AddStep("join other user", () => Client.AddUser(new APIUser { Id = 1234 })); + AddStep("set other user as host", () => Client.TransferHost(1234)); + + addPlaylistItem(() => API.LocalUser.Value.OnlineID); + assertDeleteButtonVisibility(1, true); + addPlaylistItem(() => 1234); + assertDeleteButtonVisibility(2, false); + + AddStep("set local user as host", () => Client.TransferHost(API.LocalUser.Value.OnlineID)); + assertDeleteButtonVisibility(1, true); + assertDeleteButtonVisibility(2, true); + } + + [Test] + public void TestCurrentItemDoesNotHaveDeleteButton() + { + AddStep("set all players queue mode", () => Client.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers })); + AddUntilStep("wait for queue mode change", () => Client.APIRoom?.QueueMode.Value == QueueMode.AllPlayers); + + addPlaylistItem(() => API.LocalUser.Value.OnlineID); + + AddStep("select item 0", () => selectedItem.Value = playlist.ChildrenOfType>().ElementAt(0).Model); + assertDeleteButtonVisibility(0, false); + assertDeleteButtonVisibility(1, true); + + AddStep("select item 1", () => selectedItem.Value = playlist.ChildrenOfType>().ElementAt(1).Model); + assertDeleteButtonVisibility(0, true); + assertDeleteButtonVisibility(1, false); + } + + private void addPlaylistItem(Func userId) + { + long itemId = -1; + + AddStep("add playlist item", () => + { + MultiplayerPlaylistItem item = new MultiplayerPlaylistItem(new PlaylistItem + { + Beatmap = { Value = importedBeatmap }, + BeatmapID = importedBeatmap.OnlineID ?? -1, + }); + + Client.AddUserPlaylistItem(userId(), item); + + itemId = item.ID; + }); + + AddUntilStep("item arrived in playlist", () => playlist.ChildrenOfType>().Any(i => i.Model.ID == itemId)); + } + + private void deleteItem(int index) + { + OsuRearrangeableListItem item = null; + + AddStep($"move mouse to delete button {index}", () => + { + item = playlist.ChildrenOfType>().ElementAt(index); + InputManager.MoveMouseTo(item.ChildrenOfType().ElementAt(0)); + }); + + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + AddUntilStep("item removed from playlist", () => !playlist.ChildrenOfType>().Contains(item)); + } + + private void assertDeleteButtonVisibility(int index, bool visible) + => AddUntilStep($"delete button {index} {(visible ? "is" : "is not")} visible", + () => (playlist.ChildrenOfType().ElementAt(index).Alpha > 0) == visible); + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs index 84b63a5733..81220e2527 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerReadyButton.cs @@ -163,10 +163,13 @@ namespace osu.Game.Tests.Visual.Multiplayer }); addClickButtonStep(); + AddUntilStep("user is ready", () => Client.Room?.Users[0].State == MultiplayerUserState.Ready); + AddStep("transfer host", () => Client.TransferHost(Client.Room?.Users[1].UserID ?? 0)); addClickButtonStep(); - AddAssert("match not started", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle); + AddUntilStep("user is idle (match not started)", () => Client.Room?.Users[0].State == MultiplayerUserState.Idle); + AddAssert("ready button enabled", () => button.ChildrenOfType().Single().Enabled.Value); } [TestCase(true)] diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerResults.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerResults.cs index 80f807e7d3..4674601f28 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerResults.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerResults.cs @@ -1,13 +1,11 @@ // 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 NUnit.Framework; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Osu; -using osu.Game.Scoring; using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Multiplayer { @@ -22,20 +20,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { var rulesetInfo = new OsuRuleset().RulesetInfo; var beatmapInfo = CreateBeatmap(rulesetInfo).BeatmapInfo; - - var score = new ScoreInfo - { - Rank = ScoreRank.B, - TotalScore = 987654, - Accuracy = 0.8, - MaxCombo = 500, - Combo = 250, - BeatmapInfo = beatmapInfo, - User = new APIUser { Username = "Test user" }, - Date = DateTimeOffset.Now, - OnlineScoreID = 12345, - Ruleset = rulesetInfo, - }; + var score = TestResources.CreateTestScoreInfo(beatmapInfo); PlaylistItem playlistItem = new PlaylistItem { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerTeamResults.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerTeamResults.cs index da1fa226e1..f5df8d7507 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerTeamResults.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerTeamResults.cs @@ -1,15 +1,13 @@ // 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 NUnit.Framework; using osu.Framework.Bindables; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Osu; -using osu.Game.Scoring; using osu.Game.Screens.OnlinePlay.Multiplayer; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Multiplayer { @@ -26,20 +24,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { var rulesetInfo = new OsuRuleset().RulesetInfo; var beatmapInfo = CreateBeatmap(rulesetInfo).BeatmapInfo; - - var score = new ScoreInfo - { - Rank = ScoreRank.B, - TotalScore = 987654, - Accuracy = 0.8, - MaxCombo = 500, - Combo = 250, - BeatmapInfo = beatmapInfo, - User = new APIUser { Username = "Test user" }, - Date = DateTimeOffset.Now, - OnlineScoreID = 12345, - Ruleset = rulesetInfo, - }; + var score = TestResources.CreateTestScoreInfo(beatmapInfo); PlaylistItem playlistItem = new PlaylistItem { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsRoomSettingsPlaylist.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsRoomSettingsPlaylist.cs new file mode 100644 index 0000000000..93ccd5f1e1 --- /dev/null +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsRoomSettingsPlaylist.cs @@ -0,0 +1,188 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Testing; +using osu.Game.Beatmaps; +using osu.Game.Beatmaps.Drawables; +using osu.Game.Database; +using osu.Game.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Rooms; +using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Mods; +using osu.Game.Screens.OnlinePlay; +using osu.Game.Screens.OnlinePlay.Playlists; +using osu.Game.Tests.Beatmaps; +using osuTK; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Multiplayer +{ + public class TestScenePlaylistsRoomSettingsPlaylist : OsuManualInputManagerTestScene + { + private TestPlaylist playlist; + + [Cached(typeof(UserLookupCache))] + private readonly TestUserLookupCache userLookupCache = new TestUserLookupCache(); + + [Test] + public void TestItemRemovedOnDeletion() + { + PlaylistItem selectedItem = null; + + createPlaylist(); + + moveToItem(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddStep("retrieve selection", () => selectedItem = playlist.SelectedItem.Value); + + moveToDeleteButton(0); + AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); + + AddAssert("item removed", () => !playlist.Items.Contains(selectedItem)); + } + + [Test] + public void TestNextItemSelectedAfterDeletion() + { + createPlaylist(); + + moveToItem(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + moveToDeleteButton(0); + AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); + + AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]); + } + + [Test] + public void TestLastItemSelectedAfterLastItemDeleted() + { + createPlaylist(); + + AddWaitStep("wait for flow", 5); // Items may take 1 update frame to flow. A wait count of 5 is guaranteed to result in the flow being updated as desired. + AddStep("scroll to bottom", () => playlist.ChildrenOfType>().First().ScrollToEnd(false)); + + moveToItem(19); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + + moveToDeleteButton(19); + AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); + + AddAssert("item 18 is selected", () => playlist.SelectedItem.Value == playlist.Items[18]); + } + + [Test] + public void TestSelectionResetWhenAllItemsDeleted() + { + createPlaylist(); + + AddStep("remove all but one item", () => + { + playlist.Items.RemoveRange(1, playlist.Items.Count - 1); + }); + + moveToItem(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + moveToDeleteButton(0); + AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); + + AddAssert("no item selected", () => playlist.SelectedItem.Value == null); + } + + // Todo: currently not possible due to bindable list shortcomings (https://github.com/ppy/osu-framework/issues/3081) + // [Test] + public void TestNextItemSelectedAfterExternalDeletion() + { + createPlaylist(); + + moveToItem(0); + AddStep("click", () => InputManager.Click(MouseButton.Left)); + AddStep("remove item 0", () => playlist.Items.RemoveAt(0)); + + AddAssert("item 0 is selected", () => playlist.SelectedItem.Value == playlist.Items[0]); + } + + [Test] + public void TestChangeBeatmapAndRemove() + { + createPlaylist(); + + AddStep("change beatmap of first item", () => playlist.Items[0].BeatmapID = 30); + moveToDeleteButton(0); + AddStep("click delete button", () => InputManager.Click(MouseButton.Left)); + } + + private void moveToItem(int index, Vector2? offset = null) + => AddStep($"move mouse to item {index}", () => InputManager.MoveMouseTo(playlist.ChildrenOfType().ElementAt(index), offset)); + + private void moveToDeleteButton(int index, Vector2? offset = null) => AddStep($"move mouse to delete button {index}", () => + { + var item = playlist.ChildrenOfType>().ElementAt(index); + InputManager.MoveMouseTo(item.ChildrenOfType().ElementAt(0), offset); + }); + + private void createPlaylist() + { + AddStep("create playlist", () => + { + Child = playlist = new TestPlaylist + { + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + Size = new Vector2(500, 300) + }; + + for (int i = 0; i < 20; i++) + { + playlist.Items.Add(new PlaylistItem + { + ID = i, + OwnerID = 2, + Beatmap = + { + Value = i % 2 == 1 + ? new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo + : new BeatmapInfo + { + Metadata = new BeatmapMetadata + { + Artist = "Artist", + Author = new APIUser { Username = "Creator name here" }, + Title = "Long title used to check background colour", + }, + BeatmapSet = new BeatmapSetInfo() + } + }, + Ruleset = { Value = new OsuRuleset().RulesetInfo }, + RequiredMods = + { + new OsuModHardRock(), + new OsuModDoubleTime(), + new OsuModAutoplay() + } + }); + } + }); + + AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); + } + + private class TestPlaylist : PlaylistsRoomSettingsPlaylist + { + public new IReadOnlyDictionary> ItemMap => base.ItemMap; + + public TestPlaylist() + { + AllowSelection = true; + } + } + } +} diff --git a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs index 35c66e8cda..5aac228f4b 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestScenePlaylistsSongSelect.cs @@ -24,9 +24,6 @@ namespace osu.Game.Tests.Visual.Multiplayer { public class TestScenePlaylistsSongSelect : OnlinePlayTestScene { - [Resolved] - private BeatmapManager beatmapManager { get; set; } - private BeatmapManager manager; private RulesetStore rulesets; diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs index c70906927e..81c59b90f5 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneTeamVersus.cs @@ -11,6 +11,7 @@ using osu.Framework.Testing; 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; @@ -30,9 +31,9 @@ namespace osu.Game.Tests.Visual.Multiplayer private RulesetStore rulesets; private BeatmapSetInfo importedSet; - private TestMultiplayerScreenStack multiplayerScreenStack; + private TestMultiplayerComponents multiplayerComponents; - private TestMultiplayerClient client => multiplayerScreenStack.Client; + private TestMultiplayerClient client => multiplayerComponents.Client; [Cached(typeof(UserLookupCache))] private UserLookupCache lookupCache = new TestUserLookupCache(); @@ -54,8 +55,8 @@ namespace osu.Game.Tests.Visual.Multiplayer importedSet = beatmaps.GetAllUsableBeatmapSetsEnumerable(IncludedDetails.All).First(); }); - AddStep("load multiplayer", () => LoadScreen(multiplayerScreenStack = new TestMultiplayerScreenStack())); - AddUntilStep("wait for multiplayer to load", () => multiplayerScreenStack.IsLoaded); + AddStep("load multiplayer", () => LoadScreen(multiplayerComponents = new TestMultiplayerComponents())); + AddUntilStep("wait for multiplayer to load", () => multiplayerComponents.IsLoaded); AddUntilStep("wait for lounge to load", () => this.ChildrenOfType().FirstOrDefault()?.IsLoaded == true); } @@ -76,7 +77,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - AddAssert("room type is team vs", () => client.Room?.Settings.MatchType == MatchType.TeamVersus); + AddUntilStep("room type is team vs", () => client.Room?.Settings.MatchType == MatchType.TeamVersus); AddAssert("user state arrived", () => client.Room?.Users.FirstOrDefault()?.MatchState is TeamVersusUserState); } @@ -102,7 +103,7 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("press own button", () => { - InputManager.MoveMouseTo(multiplayerScreenStack.ChildrenOfType().First()); + InputManager.MoveMouseTo(multiplayerComponents.ChildrenOfType().First()); InputManager.Click(MouseButton.Left); }); AddAssert("user on team 1", () => (client.Room?.Users.FirstOrDefault()?.MatchState as TeamVersusUserState)?.TeamID == 1); @@ -112,12 +113,39 @@ namespace osu.Game.Tests.Visual.Multiplayer AddStep("press other user's button", () => { - InputManager.MoveMouseTo(multiplayerScreenStack.ChildrenOfType().ElementAt(1)); + InputManager.MoveMouseTo(multiplayerComponents.ChildrenOfType().ElementAt(1)); InputManager.Click(MouseButton.Left); }); 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() { @@ -134,31 +162,32 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - AddAssert("room type is head to head", () => client.Room?.Settings.MatchType == MatchType.HeadToHead); + AddUntilStep("room type is head to head", () => client.Room?.Settings.MatchType == MatchType.HeadToHead); - AddUntilStep("team displays are not displaying teams", () => multiplayerScreenStack.ChildrenOfType().All(d => d.DisplayedTeam == null)); + AddUntilStep("team displays are not displaying teams", () => multiplayerComponents.ChildrenOfType().All(d => d.DisplayedTeam == null)); AddStep("change to team vs", () => client.ChangeSettings(matchType: MatchType.TeamVersus)); - AddAssert("room type is team vs", () => client.Room?.Settings.MatchType == MatchType.TeamVersus); + AddUntilStep("room type is team vs", () => client.Room?.Settings.MatchType == MatchType.TeamVersus); - AddUntilStep("team displays are displaying teams", () => multiplayerScreenStack.ChildrenOfType().All(d => d.DisplayedTeam != null)); + AddUntilStep("team displays are displaying teams", () => multiplayerComponents.ChildrenOfType().All(d => d.DisplayedTeam != null)); } private void createRoom(Func room) { - AddStep("open room", () => multiplayerScreenStack.ChildrenOfType().Single().Open(room())); + AddStep("open room", () => multiplayerComponents.ChildrenOfType().Single().Open(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()); InputManager.Click(MouseButton.Left); }); - AddUntilStep("wait for join", () => multiplayerScreenStack.RoomManager.RoomJoined); + AddUntilStep("wait for join", () => client.RoomJoined); } } } diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneEditDefaultSkin.cs b/osu.Game.Tests/Visual/Navigation/TestSceneEditDefaultSkin.cs new file mode 100644 index 0000000000..06306ad197 --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestSceneEditDefaultSkin.cs @@ -0,0 +1,39 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Linq; +using NUnit.Framework; +using osu.Framework.Allocation; +using osu.Framework.Testing; +using osu.Game.Overlays.Settings.Sections; +using osu.Game.Skinning; +using osu.Game.Skinning.Editor; + +namespace osu.Game.Tests.Visual.Navigation +{ + public class TestSceneEditDefaultSkin : OsuGameTestScene + { + private SkinManager skinManager => Game.Dependencies.Get(); + private SkinEditorOverlay skinEditor => Game.Dependencies.Get(); + + [Test] + public void TestEditDefaultSkin() + { + AddAssert("is default skin", () => skinManager.CurrentSkinInfo.Value.ID == SkinInfo.DEFAULT_SKIN); + + AddStep("open settings", () => { Game.Settings.Show(); }); + + // Until step requires as settings has a delayed load. + AddUntilStep("export button disabled", () => Game.Settings.ChildrenOfType().SingleOrDefault()?.Enabled.Value == false); + + // Will create a mutable skin. + AddStep("open skin editor", () => skinEditor.Show()); + + // Until step required as the skin editor may take time to load (and an extra scheduled frame for the mutable part). + AddUntilStep("is modified default skin", () => skinManager.CurrentSkinInfo.Value.ID != SkinInfo.DEFAULT_SKIN); + AddAssert("is not protected", () => skinManager.CurrentSkinInfo.Value.PerformRead(s => !s.Protected)); + + AddUntilStep("export button enabled", () => Game.Settings.ChildrenOfType().SingleOrDefault()?.Enabled.Value == true); + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs b/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs new file mode 100644 index 0000000000..9e684e4f10 --- /dev/null +++ b/osu.Game.Tests/Visual/Navigation/TestSceneMouseWheelVolumeAdjust.cs @@ -0,0 +1,99 @@ +// 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.Game.Configuration; +using osu.Game.Screens.Play; +using osu.Game.Tests.Beatmaps.IO; +using osuTK.Input; + +namespace osu.Game.Tests.Visual.Navigation +{ + public class TestSceneMouseWheelVolumeAdjust : OsuGameTestScene + { + public override void SetUpSteps() + { + base.SetUpSteps(); + + // Headless tests are always at minimum volume. This covers interactive tests, matching that initial value. + AddStep("Set volume to min", () => Game.Audio.Volume.Value = 0); + AddAssert("Volume is min", () => Game.Audio.AggregateVolume.Value == 0); + AddStep("Move mouse to centre", () => InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.Centre)); + } + + [Test] + public void TestAdjustVolumeFromMainMenu() + { + // First scroll makes volume controls appear, second adjusts volume. + AddRepeatStep("Adjust volume using mouse wheel", () => InputManager.ScrollVerticalBy(5), 2); + AddUntilStep("Volume is above zero", () => Game.Audio.AggregateVolume.Value > 0); + } + + [Test] + public void TestAdjustVolumeFromPlayerWheelEnabled() + { + loadToPlayerNonBreakTime(); + + // First scroll makes volume controls appear, second adjusts volume. + AddRepeatStep("Adjust volume using mouse wheel", () => InputManager.ScrollVerticalBy(5), 2); + AddAssert("Volume is above zero", () => Game.Audio.Volume.Value > 0); + } + + [Test] + public void TestAdjustVolumeFromPlayerWheelDisabled() + { + AddStep("disable wheel volume adjust", () => Game.LocalConfig.SetValue(OsuSetting.MouseDisableWheel, true)); + + loadToPlayerNonBreakTime(); + + // First scroll makes volume controls appear, second adjusts volume. + AddRepeatStep("Adjust volume using mouse wheel", () => InputManager.ScrollVerticalBy(5), 2); + AddAssert("Volume is still zero", () => Game.Audio.Volume.Value == 0); + } + + [Test] + public void TestAdjustVolumeFromPlayerWheelDisabledHoldingAlt() + { + AddStep("disable wheel volume adjust", () => Game.LocalConfig.SetValue(OsuSetting.MouseDisableWheel, true)); + + loadToPlayerNonBreakTime(); + + // First scroll makes volume controls appear, second adjusts volume. + AddRepeatStep("Adjust volume using mouse wheel holding alt", () => + { + InputManager.PressKey(Key.AltLeft); + InputManager.ScrollVerticalBy(5); + InputManager.ReleaseKey(Key.AltLeft); + }, 2); + + AddAssert("Volume is above zero", () => Game.Audio.Volume.Value > 0); + } + + private void loadToPlayerNonBreakTime() + { + Player player = null; + Screens.Select.SongSelect songSelect = null; + PushAndConfirm(() => songSelect = new TestSceneScreenNavigation.TestPlaySongSelect()); + AddUntilStep("wait for song select", () => songSelect.BeatmapSetsLoaded); + + AddStep("import beatmap", () => ImportBeatmapTest.LoadOszIntoOsu(Game, virtualTrack: true).Wait()); + AddUntilStep("wait for selected", () => !Game.Beatmap.IsDefault); + AddStep("press enter", () => InputManager.Key(Key.Enter)); + + AddUntilStep("wait for player", () => + { + // dismiss any notifications that may appear (ie. muted notification). + clickMouseInCentre(); + return (player = Game.ScreenStack.CurrentScreen as Player) != null; + }); + + AddUntilStep("wait for play time active", () => !player.IsBreakTime.Value); + } + + private void clickMouseInCentre() + { + InputManager.MoveMouseTo(Game.ScreenSpaceDrawQuad.Centre); + InputManager.Click(MouseButton.Left); + } + } +} diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs b/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs index 2706ff5ceb..4d1e279090 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneOsuGame.cs @@ -83,9 +83,6 @@ namespace osu.Game.Tests.Visual.Navigation [Resolved] private OsuGameBase gameBase { get; set; } - [Resolved] - private GameHost host { get; set; } - [Test] public void TestNullRulesetHandled() { diff --git a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs index c9dec25ad3..1653247570 100644 --- a/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs +++ b/osu.Game.Tests/Visual/Navigation/TestScenePresentScore.cs @@ -128,7 +128,7 @@ namespace osu.Game.Tests.Visual.Navigation imported = Game.ScoreManager.Import(new ScoreInfo { Hash = Guid.NewGuid().ToString(), - OnlineScoreID = i, + OnlineID = i, BeatmapInfo = beatmap.Beatmaps.First(), Ruleset = ruleset ?? new OsuRuleset().RulesetInfo }).Result.Value; diff --git a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs index 664c186cf8..48ab643992 100644 --- a/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs +++ b/osu.Game.Tests/Visual/Navigation/TestSceneScreenNavigation.cs @@ -336,12 +336,12 @@ namespace osu.Game.Tests.Visual.Navigation [Test] public void TestPushMatchSubScreenAndPressBackButtonImmediately() { - TestMultiplayerScreenStack multiplayerScreenStack = null; + TestMultiplayerComponents multiplayerComponents = null; - PushAndConfirm(() => multiplayerScreenStack = new TestMultiplayerScreenStack()); + PushAndConfirm(() => multiplayerComponents = new TestMultiplayerComponents()); - AddUntilStep("wait for lounge", () => multiplayerScreenStack.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); - AddStep("open room", () => multiplayerScreenStack.ChildrenOfType().Single().Open()); + AddUntilStep("wait for lounge", () => multiplayerComponents.ChildrenOfType().SingleOrDefault()?.IsLoaded == true); + AddStep("open room", () => multiplayerComponents.ChildrenOfType().Single().Open()); AddStep("press back button", () => Game.ChildrenOfType().First().Action()); AddWaitStep("wait two frames", 2); } diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapRulesetSelector.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapRulesetSelector.cs index 90f3eb64e4..63741451f3 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapRulesetSelector.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapRulesetSelector.cs @@ -25,7 +25,7 @@ namespace osu.Game.Tests.Visual.Online } [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } [Test] public void TestMultipleRulesetsBeatmapSet() diff --git a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs index 3314e291e8..f87cca80b0 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneBeatmapSetOverlay.cs @@ -30,7 +30,7 @@ namespace osu.Game.Tests.Visual.Online } [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } [Test] public void TestLoading() diff --git a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs index 7028ecf39f..14f32df653 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneChatOverlay.cs @@ -13,7 +13,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input; -using osu.Framework.Platform; using osu.Framework.Testing; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; @@ -47,9 +46,6 @@ namespace osu.Game.Tests.Visual.Online [CanBeNull] private Func> onGetMessages; - [Resolved] - private GameHost host { get; set; } - public TestSceneChatOverlay() { channels = Enumerable.Range(1, 10) @@ -397,6 +393,25 @@ namespace osu.Game.Tests.Visual.Online channelManager.CurrentChannel.Value.Type == ChannelType.PM && channelManager.CurrentChannel.Value.Users.Single().Username == "some body"); } + [Test] + public void TestMultiplayerChannelIsNotShown() + { + Channel multiplayerChannel = null; + + AddStep("join multiplayer channel", () => channelManager.JoinChannel(multiplayerChannel = new Channel(new APIUser()) + { + Name = "#mp_1", + Type = ChannelType.Multiplayer, + })); + + AddAssert("channel joined", () => channelManager.JoinedChannels.Contains(multiplayerChannel)); + AddAssert("channel not present in overlay", () => !chatOverlay.TabMap.ContainsKey(multiplayerChannel)); + AddAssert("multiplayer channel is not current", () => channelManager.CurrentChannel.Value != multiplayerChannel); + + AddStep("leave channel", () => channelManager.LeaveChannel(multiplayerChannel)); + AddAssert("channel left", () => !channelManager.JoinedChannels.Contains(multiplayerChannel)); + } + private void pressChannelHotkey(int number) { var channelKey = Key.Number0 + number; diff --git a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs index 50969aad9b..be2db9a8a0 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneScoresContainer.cs @@ -47,9 +47,9 @@ namespace osu.Game.Tests.Visual.Online var allScores = new APIScoresCollection { - Scores = new List + Scores = new List { - new APIScoreInfo + new APIScore { User = new APIUser { @@ -74,7 +74,7 @@ namespace osu.Game.Tests.Visual.Online TotalScore = 1234567890, Accuracy = 1, }, - new APIScoreInfo + new APIScore { User = new APIUser { @@ -98,7 +98,7 @@ namespace osu.Game.Tests.Visual.Online TotalScore = 1234789, Accuracy = 0.9997, }, - new APIScoreInfo + new APIScore { User = new APIUser { @@ -121,7 +121,7 @@ namespace osu.Game.Tests.Visual.Online TotalScore = 12345678, Accuracy = 0.9854, }, - new APIScoreInfo + new APIScore { User = new APIUser { @@ -143,7 +143,7 @@ namespace osu.Game.Tests.Visual.Online TotalScore = 1234567, Accuracy = 0.8765, }, - new APIScoreInfo + new APIScore { User = new APIUser { @@ -166,7 +166,7 @@ namespace osu.Game.Tests.Visual.Online var myBestScore = new APIScoreWithPosition { - Score = new APIScoreInfo + Score = new APIScore { User = new APIUser { @@ -189,7 +189,7 @@ namespace osu.Game.Tests.Visual.Online var myBestScoreWithNullPosition = new APIScoreWithPosition { - Score = new APIScoreInfo + Score = new APIScore { User = new APIUser { @@ -212,9 +212,9 @@ namespace osu.Game.Tests.Visual.Online var oneScore = new APIScoresCollection { - Scores = new List + Scores = new List { - new APIScoreInfo + new APIScore { User = new APIUser { diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs index 19e06beaad..52d5eb2c65 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserPanel.cs @@ -24,7 +24,7 @@ namespace osu.Game.Tests.Visual.Online private TestUserListPanel evast; [Resolved] - private RulesetStore rulesetStore { get; set; } + private IRulesetStore rulesetStore { get; set; } [SetUp] public void SetUp() => Schedule(() => diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs index 76997bded7..dda9543159 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileHeader.cs @@ -2,9 +2,10 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Linq; +using NUnit.Framework; using osu.Framework.Allocation; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; +using osu.Framework.Testing; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Profile; @@ -14,72 +15,77 @@ namespace osu.Game.Tests.Visual.Online { public class TestSceneUserProfileHeader : OsuTestScene { - protected override bool UseOnlineAPI => true; - [Cached] private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Green); - [Resolved] - private IAPIProvider api { get; set; } + private ProfileHeader header; - private readonly ProfileHeader header; - - public TestSceneUserProfileHeader() + [SetUpSteps] + public void SetUpSteps() { - header = new ProfileHeader(); - Add(header); + AddStep("create header", () => Child = header = new ProfileHeader()); + } - AddStep("Show test dummy", () => header.User.Value = TestSceneUserProfileOverlay.TEST_USER); + [Test] + public void TestBasic() + { + AddStep("Show example user", () => header.User.Value = TestSceneUserProfileOverlay.TEST_USER); + } - AddStep("Show null dummy", () => header.User.Value = new APIUser - { - Username = "Null" - }); - - AddStep("Show online dummy", () => header.User.Value = new APIUser + [Test] + public void TestOnlineState() + { + AddStep("Show online user", () => header.User.Value = new APIUser { + Id = 1001, Username = "IAmOnline", LastVisit = DateTimeOffset.Now, IsOnline = true, }); - AddStep("Show offline dummy", () => header.User.Value = new APIUser + AddStep("Show offline user", () => header.User.Value = new APIUser { + Id = 1002, Username = "IAmOffline", - LastVisit = DateTimeOffset.Now, + LastVisit = DateTimeOffset.Now.AddDays(-10), IsOnline = false, }); - - addOnlineStep("Show ppy", new APIUser - { - Username = @"peppy", - Id = 2, - IsSupporter = true, - Country = new Country { FullName = @"Australia", FlagName = @"AU" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c3.jpg" - }); - - addOnlineStep("Show flyte", new APIUser - { - Username = @"flyte", - Id = 3103765, - Country = new Country { FullName = @"Japan", FlagName = @"JP" }, - CoverUrl = @"https://osu.ppy.sh/images/headers/profile-covers/c6.jpg" - }); } - private void addOnlineStep(string name, APIUser fallback) + [Test] + public void TestRankedState() { - AddStep(name, () => + AddStep("Show ranked user", () => header.User.Value = new APIUser { - if (api.IsLoggedIn) + Id = 2001, + Username = "RankedUser", + Statistics = new UserStatistics { - var request = new GetUserRequest(fallback.Id); - request.Success += user => header.User.Value = user; - api.Queue(request); + IsRanked = true, + GlobalRank = 15000, + CountryRank = 1500, + RankHistory = new APIRankHistory + { + Mode = @"osu", + Data = Enumerable.Range(2345, 45).Concat(Enumerable.Range(2109, 40)).ToArray() + }, + } + }); + + AddStep("Show unranked user", () => header.User.Value = new APIUser + { + Id = 2002, + Username = "UnrankedUser", + Statistics = new UserStatistics + { + IsRanked = false, + // web will sometimes return non-empty rank history even for unranked users. + RankHistory = new APIRankHistory + { + Mode = @"osu", + Data = Enumerable.Range(2345, 85).ToArray() + }, } - else - header.User.Value = fallback; }); } } diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs index ce8136199f..78e2ceb45b 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileOverlay.cs @@ -4,8 +4,6 @@ using System; using System.Linq; using NUnit.Framework; -using osu.Framework.Allocation; -using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays; using osu.Game.Overlays.Profile; @@ -20,9 +18,6 @@ namespace osu.Game.Tests.Visual.Online private readonly TestUserProfileOverlay profile; - [Resolved] - private IAPIProvider api { get; set; } - public static readonly APIUser TEST_USER = new APIUser { Username = @"Somebody", @@ -34,6 +29,7 @@ namespace osu.Game.Tests.Visual.Online ProfileOrder = new[] { "me" }, Statistics = new UserStatistics { + IsRanked = true, GlobalRank = 2148, CountryRank = 1, PP = 4567.89m, diff --git a/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs b/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs index 9c2cc13416..7dfdca8276 100644 --- a/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs +++ b/osu.Game.Tests/Visual/Online/TestSceneUserProfileScores.cs @@ -19,7 +19,7 @@ namespace osu.Game.Tests.Visual.Online { public TestSceneUserProfileScores() { - var firstScore = new APIScoreInfo + var firstScore = new APIScore { PP = 1047.21, Rank = ScoreRank.SH, @@ -42,7 +42,7 @@ namespace osu.Game.Tests.Visual.Online Accuracy = 0.9813 }; - var secondScore = new APIScoreInfo + var secondScore = new APIScore { PP = 134.32, Rank = ScoreRank.A, @@ -64,7 +64,7 @@ namespace osu.Game.Tests.Visual.Online Accuracy = 0.998546 }; - var thirdScore = new APIScoreInfo + var thirdScore = new APIScore { PP = 96.83, Rank = ScoreRank.S, @@ -81,7 +81,7 @@ namespace osu.Game.Tests.Visual.Online Accuracy = 0.9726 }; - var noPPScore = new APIScoreInfo + var noPPScore = new APIScore { Rank = ScoreRank.B, Beatmap = new APIBeatmap diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs index 4284bc6358..e9210496ca 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsResultsScreen.cs @@ -22,14 +22,17 @@ using osu.Game.Scoring; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Ranking; using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Playlists { public class TestScenePlaylistsResultsScreen : ScreenTestScene { private const int scores_per_result = 10; + private const int real_user_position = 200; private TestResultsScreen resultsScreen; + private int currentScoreId; private bool requestComplete; private int totalCount; @@ -37,7 +40,7 @@ namespace osu.Game.Tests.Visual.Playlists [SetUp] public void Setup() => Schedule(() => { - currentScoreId = 0; + currentScoreId = 1; requestComplete = false; totalCount = 0; bindHandler(); @@ -50,13 +53,17 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("bind user score info handler", () => { - userScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { OnlineScoreID = currentScoreId++ }; + userScore = TestResources.CreateTestScoreInfo(); + userScore.OnlineID = currentScoreId++; + bindHandler(userScore: userScore); }); createResults(() => userScore); - AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineScoreID == userScore.OnlineScoreID).State == PanelState.Expanded); + AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineID == userScore.OnlineID).State == PanelState.Expanded); + AddAssert($"score panel position is {real_user_position}", + () => this.ChildrenOfType().Single(p => p.Score.OnlineID == userScore.OnlineID).ScorePosition.Value == real_user_position); } [Test] @@ -74,14 +81,16 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("bind user score info handler", () => { - userScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { OnlineScoreID = currentScoreId++ }; + userScore = TestResources.CreateTestScoreInfo(); + userScore.OnlineID = currentScoreId++; + bindHandler(true, userScore); }); createResults(() => userScore); AddAssert("more than 1 panel displayed", () => this.ChildrenOfType().Count() > 1); - AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineScoreID == userScore.OnlineScoreID).State == PanelState.Expanded); + AddAssert("user score selected", () => this.ChildrenOfType().Single(p => p.Score.OnlineID == userScore.OnlineID).State == PanelState.Expanded); } [Test] @@ -123,7 +132,9 @@ namespace osu.Game.Tests.Visual.Playlists AddStep("bind user score info handler", () => { - userScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { OnlineScoreID = currentScoreId++ }; + userScore = TestResources.CreateTestScoreInfo(); + userScore.OnlineID = currentScoreId++; + bindHandler(userScore: userScore); }); @@ -157,12 +168,13 @@ namespace osu.Game.Tests.Visual.Playlists })); }); + AddUntilStep("wait for screen to load", () => resultsScreen.IsLoaded); waitForDisplay(); } private void waitForDisplay() { - AddUntilStep("wait for load to complete", () => + AddUntilStep("wait for scores loaded", () => requestComplete && resultsScreen.ScorePanelList.GetScorePanels().Count() == totalCount && resultsScreen.ScorePanelList.AllPanelsVisible); @@ -230,12 +242,12 @@ namespace osu.Game.Tests.Visual.Playlists { var multiplayerUserScore = new MultiplayerScore { - ID = (int)(userScore.OnlineScoreID ?? currentScoreId++), + ID = (int)(userScore.OnlineID > 0 ? userScore.OnlineID : currentScoreId++), Accuracy = userScore.Accuracy, EndedAt = userScore.Date, Passed = userScore.Passed, Rank = userScore.Rank, - Position = 200, + Position = real_user_position, MaxCombo = userScore.MaxCombo, TotalScore = userScore.TotalScore, User = userScore.User, diff --git a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs index c5287d4257..a426f075e1 100644 --- a/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs +++ b/osu.Game.Tests/Visual/Playlists/TestScenePlaylistsRoomCreation.cs @@ -15,9 +15,11 @@ using osu.Game.Database; using osu.Game.Online.Rooms; using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; +using osu.Game.Rulesets.Osu.Objects; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Play; +using osu.Game.Tests.Beatmaps; using osu.Game.Tests.Visual.OnlinePlay; using osuTK.Input; @@ -112,37 +114,80 @@ namespace osu.Game.Tests.Visual.Playlists [Test] public void TestBeatmapUpdatedOnReImport() { - BeatmapSetInfo importedSet = null; + string realHash = null; + int realOnlineId = 0; + int realOnlineSetId = 0; - AddStep("import altered beatmap", () => + AddStep("store real beatmap values", () => { - IBeatmap beatmap = CreateBeatmap(new OsuRuleset().RulesetInfo); - - beatmap.BeatmapInfo.BaseDifficulty.CircleSize = 1; - - // intentionally increment online IDs to clash with import below. - beatmap.BeatmapInfo.OnlineID++; - beatmap.BeatmapInfo.BeatmapSet.OnlineID++; - - importedSet = manager.Import(beatmap.BeatmapInfo.BeatmapSet).Result.Value; + realHash = importedBeatmap.Value.Beatmaps[0].MD5Hash; + realOnlineId = importedBeatmap.Value.Beatmaps[0].OnlineID ?? -1; + realOnlineSetId = importedBeatmap.Value.OnlineID ?? -1; }); + AddStep("import modified beatmap", () => + { + var modifiedBeatmap = new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = + { + OnlineID = realOnlineId, + BeatmapSet = + { + OnlineID = realOnlineSetId + } + }, + }; + + modifiedBeatmap.HitObjects.Clear(); + modifiedBeatmap.HitObjects.Add(new HitCircle { StartTime = 5000 }); + + manager.Import(modifiedBeatmap.BeatmapInfo.BeatmapSet).Wait(); + }); + + // Create the room using the real beatmap values. setupAndCreateRoom(room => { room.Name.Value = "my awesome room"; room.Host.Value = API.LocalUser.Value; room.Playlist.Add(new PlaylistItem { - Beatmap = { Value = importedSet.Beatmaps[0] }, + Beatmap = + { + Value = new BeatmapInfo + { + MD5Hash = realHash, + OnlineID = realOnlineId, + BeatmapSet = new BeatmapSetInfo + { + OnlineID = realOnlineSetId, + } + } + }, Ruleset = { Value = new OsuRuleset().RulesetInfo } }); }); - AddAssert("match has altered beatmap", () => match.Beatmap.Value.Beatmap.Difficulty.CircleSize == 1); + AddAssert("match has default beatmap", () => match.Beatmap.IsDefault); - importBeatmap(); + AddStep("reimport original beatmap", () => + { + var originalBeatmap = new TestBeatmap(new OsuRuleset().RulesetInfo) + { + BeatmapInfo = + { + OnlineID = realOnlineId, + BeatmapSet = + { + OnlineID = realOnlineSetId + } + }, + }; - AddAssert("match has original beatmap", () => match.Beatmap.Value.Beatmap.Difficulty.CircleSize != 1); + manager.Import(originalBeatmap.BeatmapInfo.BeatmapSet).Wait(); + }); + + AddUntilStep("match has correct beatmap", () => realHash == match.Beatmap.Value.BeatmapInfo.MD5Hash); } private void setupAndCreateRoom(Action room) diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs index acacdf8644..85306b9354 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneContractedPanelMiddleContent.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Linq; using NUnit.Framework; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -9,30 +10,32 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Game.Beatmaps; -using osu.Game.Rulesets; using osu.Game.Rulesets.Osu; using osu.Game.Scoring; using osu.Game.Screens.Ranking; using osu.Game.Screens.Ranking.Contracted; +using osu.Game.Tests.Resources; using osuTK; namespace osu.Game.Tests.Visual.Ranking { public class TestSceneContractedPanelMiddleContent : OsuTestScene { - [Resolved] - private RulesetStore rulesetStore { get; set; } - [Test] public void TestShowPanel() { - AddStep("show example score", () => showPanel(CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)), new TestScoreInfo(new OsuRuleset().RulesetInfo))); + AddStep("show example score", () => showPanel(CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)), TestResources.CreateTestScoreInfo())); } [Test] public void TestExcessMods() { - AddStep("show excess mods score", () => showPanel(CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)), new TestScoreInfo(new OsuRuleset().RulesetInfo, true))); + AddStep("show excess mods score", () => + { + var score = TestResources.CreateTestScoreInfo(); + score.Mods = score.BeatmapInfo.Ruleset.CreateInstance().CreateAllMods().ToArray(); + showPanel(CreateWorkingBeatmap(CreateBeatmap(new OsuRuleset().RulesetInfo)), score); + }); } private void showPanel(WorkingBeatmap workingBeatmap, ScoreInfo score) diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs index 9983993d9c..2cb4fb6b6b 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelMiddleContent.cs @@ -20,6 +20,7 @@ using osu.Game.Scoring; using osu.Game.Screens.Ranking; using osu.Game.Screens.Ranking.Expanded; using osu.Game.Tests.Beatmaps; +using osu.Game.Tests.Resources; using osuTK; namespace osu.Game.Tests.Visual.Ranking @@ -34,21 +35,21 @@ namespace osu.Game.Tests.Visual.Ranking { var author = new APIUser { Username = "mapper_name" }; - AddStep("show example score", () => showPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo) - { - BeatmapInfo = createTestBeatmap(author) - })); + AddStep("show example score", () => showPanel(TestResources.CreateTestScoreInfo(createTestBeatmap(author)))); } [Test] public void TestExcessMods() { - var author = new APIUser { Username = "mapper_name" }; - - AddStep("show excess mods score", () => showPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo, true) + AddStep("show excess mods score", () => { - BeatmapInfo = createTestBeatmap(author) - })); + var author = new APIUser { Username = "mapper_name" }; + + var score = TestResources.CreateTestScoreInfo(createTestBeatmap(author)); + score.Mods = score.BeatmapInfo.Ruleset.CreateInstance().CreateAllMods().ToArray(); + + showPanel(score); + }); AddAssert("mapper name present", () => this.ChildrenOfType().Any(spriteText => spriteText.Current.Value == "mapper_name")); } @@ -56,10 +57,7 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestMapWithUnknownMapper() { - AddStep("show example score", () => showPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo) - { - BeatmapInfo = createTestBeatmap(new APIUser()) - })); + AddStep("show example score", () => showPanel(TestResources.CreateTestScoreInfo(createTestBeatmap(new APIUser())))); AddAssert("mapped by text not present", () => this.ChildrenOfType().All(spriteText => !containsAny(spriteText.Text.ToString(), "mapped", "by"))); @@ -77,12 +75,12 @@ namespace osu.Game.Tests.Visual.Ranking var mods = new Mod[] { ruleset.GetAutoplayMod() }; var beatmap = createTestBeatmap(new APIUser()); - showPanel(new TestScoreInfo(ruleset.RulesetInfo) - { - Mods = mods, - BeatmapInfo = beatmap, - Date = default, - }); + var score = TestResources.CreateTestScoreInfo(beatmap); + + score.Mods = mods; + score.Date = default; + + showPanel(score); }); AddAssert("play time not displayed", () => !this.ChildrenOfType().Any()); diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelTopContent.cs b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelTopContent.cs index a32bcbe7f0..a2fa142896 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelTopContent.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneExpandedPanelTopContent.cs @@ -5,8 +5,8 @@ using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Game.Rulesets.Osu; using osu.Game.Screens.Ranking.Expanded; +using osu.Game.Tests.Resources; using osuTK; namespace osu.Game.Tests.Visual.Ranking @@ -27,7 +27,7 @@ namespace osu.Game.Tests.Visual.Ranking RelativeSizeAxes = Axes.Both, Colour = Color4Extensions.FromHex("#444"), }, - new ExpandedPanelTopContent(new TestScoreInfo(new OsuRuleset().RulesetInfo).User), + new ExpandedPanelTopContent(TestResources.CreateTestScoreInfo().User), } }; } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs index 94700bac6a..d0bd5a6e66 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneResultsScreen.cs @@ -15,12 +15,12 @@ using osu.Framework.Utils; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; using osu.Game.Online.API; -using osu.Game.Rulesets.Osu; using osu.Game.Scoring; using osu.Game.Screens; using osu.Game.Screens.Play; using osu.Game.Screens.Ranking; using osu.Game.Screens.Ranking.Statistics; +using osu.Game.Tests.Resources; using osuTK; using osuTK.Input; @@ -72,11 +72,10 @@ namespace osu.Game.Tests.Visual.Ranking { TestResultsScreen screen = null; - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) - { - Accuracy = accuracy, - Rank = rank - }; + var score = TestResources.CreateTestScoreInfo(); + + score.Accuracy = accuracy; + score.Rank = rank; AddStep("load results", () => Child = new TestResultsContainer(screen = createResultsScreen(score))); AddUntilStep("wait for loaded", () => screen.IsLoaded); @@ -204,7 +203,7 @@ namespace osu.Game.Tests.Visual.Ranking { DelayedFetchResultsScreen screen = null; - AddStep("load results", () => Child = new TestResultsContainer(screen = new DelayedFetchResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo), 3000))); + AddStep("load results", () => Child = new TestResultsContainer(screen = new DelayedFetchResultsScreen(TestResources.CreateTestScoreInfo(), 3000))); AddUntilStep("wait for loaded", () => screen.IsLoaded); AddStep("click expanded panel", () => { @@ -237,9 +236,9 @@ namespace osu.Game.Tests.Visual.Ranking AddAssert("download button is enabled", () => screen.ChildrenOfType().Last().Enabled.Value); } - private TestResultsScreen createResultsScreen(ScoreInfo score = null) => new TestResultsScreen(score ?? new TestScoreInfo(new OsuRuleset().RulesetInfo)); + private TestResultsScreen createResultsScreen(ScoreInfo score = null) => new TestResultsScreen(score ?? TestResources.CreateTestScoreInfo()); - private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(new TestScoreInfo(new OsuRuleset().RulesetInfo)); + private UnrankedSoloResultsScreen createUnrankedSoloResultsScreen() => new UnrankedSoloResultsScreen(TestResources.CreateTestScoreInfo()); private class TestResultsContainer : Container { @@ -282,7 +281,7 @@ namespace osu.Game.Tests.Visual.Ranking for (int i = 0; i < 20; i++) { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + var score = TestResources.CreateTestScoreInfo(); score.TotalScore += 10 - i; score.Hash = $"test{i}"; scores.Add(score); @@ -316,7 +315,7 @@ namespace osu.Game.Tests.Visual.Ranking for (int i = 0; i < 20; i++) { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + var score = TestResources.CreateTestScoreInfo(); score.TotalScore += 10 - i; scores.Add(score); } diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanel.cs index 5af55e99f8..5dbeefd390 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanel.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanel.cs @@ -3,10 +3,10 @@ using NUnit.Framework; using osu.Framework.Graphics; -using osu.Game.Rulesets.Osu; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Screens.Ranking; +using osu.Game.Tests.Resources; namespace osu.Game.Tests.Visual.Ranking { @@ -17,7 +17,9 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestDRank() { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.5, Rank = ScoreRank.D }; + var score = TestResources.CreateTestScoreInfo(); + score.Accuracy = 0.5; + score.Rank = ScoreRank.D; addPanelStep(score); } @@ -25,7 +27,9 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestCRank() { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.75, Rank = ScoreRank.C }; + var score = TestResources.CreateTestScoreInfo(); + score.Accuracy = 0.75; + score.Rank = ScoreRank.C; addPanelStep(score); } @@ -33,7 +37,9 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestBRank() { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.85, Rank = ScoreRank.B }; + var score = TestResources.CreateTestScoreInfo(); + score.Accuracy = 0.85; + score.Rank = ScoreRank.B; addPanelStep(score); } @@ -41,7 +47,9 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestARank() { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.925, Rank = ScoreRank.A }; + var score = TestResources.CreateTestScoreInfo(); + score.Accuracy = 0.925; + score.Rank = ScoreRank.A; addPanelStep(score); } @@ -49,7 +57,9 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestSRank() { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.975, Rank = ScoreRank.S }; + var score = TestResources.CreateTestScoreInfo(); + score.Accuracy = 0.975; + score.Rank = ScoreRank.S; addPanelStep(score); } @@ -57,7 +67,9 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestAlmostSSRank() { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.9999, Rank = ScoreRank.S }; + var score = TestResources.CreateTestScoreInfo(); + score.Accuracy = 0.9999; + score.Rank = ScoreRank.S; addPanelStep(score); } @@ -65,7 +77,9 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestSSRank() { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 1, Rank = ScoreRank.X }; + var score = TestResources.CreateTestScoreInfo(); + score.Accuracy = 1; + score.Rank = ScoreRank.X; addPanelStep(score); } @@ -73,7 +87,9 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestAllHitResults() { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Statistics = { [HitResult.Perfect] = 350, [HitResult.Ok] = 200 } }; + var score = TestResources.CreateTestScoreInfo(); + score.Statistics[HitResult.Perfect] = 350; + score.Statistics[HitResult.Ok] = 200; addPanelStep(score); } @@ -81,7 +97,9 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestContractedPanel() { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.925, Rank = ScoreRank.A }; + var score = TestResources.CreateTestScoreInfo(); + score.Accuracy = 0.925; + score.Rank = ScoreRank.A; addPanelStep(score, PanelState.Contracted); } @@ -89,7 +107,9 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestExpandAndContract() { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) { Accuracy = 0.925, Rank = ScoreRank.A }; + var score = TestResources.CreateTestScoreInfo(); + score.Accuracy = 0.925; + score.Rank = ScoreRank.A; addPanelStep(score, PanelState.Contracted); AddWaitStep("wait for transition", 10); diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs index b7b7407428..f5ad352b9c 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneScorePanelList.cs @@ -7,9 +7,9 @@ using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Testing; using osu.Framework.Utils; -using osu.Game.Rulesets.Osu; using osu.Game.Scoring; using osu.Game.Screens.Ranking; +using osu.Game.Tests.Resources; using osuTK.Input; namespace osu.Game.Tests.Visual.Ranking @@ -29,14 +29,14 @@ namespace osu.Game.Tests.Visual.Ranking { createListStep(() => new ScorePanelList { - SelectedScore = { Value = new TestScoreInfo(new OsuRuleset().RulesetInfo) } + SelectedScore = { Value = TestResources.CreateTestScoreInfo() } }); } [Test] public void TestAddPanelAfterSelectingScore() { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + var score = TestResources.CreateTestScoreInfo(); createListStep(() => new ScorePanelList { @@ -52,7 +52,7 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestAddPanelBeforeSelectingScore() { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + var score = TestResources.CreateTestScoreInfo(); createListStep(() => new ScorePanelList()); @@ -75,7 +75,7 @@ namespace osu.Game.Tests.Visual.Ranking AddStep("add many scores", () => { for (int i = 0; i < 20; i++) - list.AddScore(new TestScoreInfo(new OsuRuleset().RulesetInfo)); + list.AddScore(TestResources.CreateTestScoreInfo()); }); assertFirstPanelCentred(); @@ -84,7 +84,7 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestAddManyScoresAfterExpandedPanel() { - var initialScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + var initialScore = TestResources.CreateTestScoreInfo(); createListStep(() => new ScorePanelList()); @@ -97,7 +97,7 @@ namespace osu.Game.Tests.Visual.Ranking AddStep("add many scores", () => { for (int i = 0; i < 20; i++) - list.AddScore(new TestScoreInfo(new OsuRuleset().RulesetInfo) { TotalScore = initialScore.TotalScore - i - 1 }); + list.AddScore(createScoreForTotalScore(initialScore.TotalScore - i - 1)); }); assertScoreState(initialScore, true); @@ -107,7 +107,7 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestAddManyScoresBeforeExpandedPanel() { - var initialScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + var initialScore = TestResources.CreateTestScoreInfo(); createListStep(() => new ScorePanelList()); @@ -120,7 +120,7 @@ namespace osu.Game.Tests.Visual.Ranking AddStep("add scores", () => { for (int i = 0; i < 20; i++) - list.AddScore(new TestScoreInfo(new OsuRuleset().RulesetInfo) { TotalScore = initialScore.TotalScore + i + 1 }); + list.AddScore(createScoreForTotalScore(initialScore.TotalScore + i + 1)); }); assertScoreState(initialScore, true); @@ -130,7 +130,7 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestAddManyPanelsOnBothSidesOfExpandedPanel() { - var initialScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + var initialScore = TestResources.CreateTestScoreInfo(); createListStep(() => new ScorePanelList()); @@ -143,10 +143,10 @@ namespace osu.Game.Tests.Visual.Ranking AddStep("add scores after", () => { for (int i = 0; i < 20; i++) - list.AddScore(new TestScoreInfo(new OsuRuleset().RulesetInfo) { TotalScore = initialScore.TotalScore - i - 1 }); + list.AddScore(createScoreForTotalScore(initialScore.TotalScore - i - 1)); for (int i = 0; i < 20; i++) - list.AddScore(new TestScoreInfo(new OsuRuleset().RulesetInfo) { TotalScore = initialScore.TotalScore + i + 1 }); + list.AddScore(createScoreForTotalScore(initialScore.TotalScore + i + 1)); }); assertScoreState(initialScore, true); @@ -156,11 +156,11 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestSelectMultipleScores() { - var firstScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); - var secondScore = new TestScoreInfo(new OsuRuleset().RulesetInfo); + var firstScore = TestResources.CreateTestScoreInfo(); + var secondScore = TestResources.CreateTestScoreInfo(); - firstScore.User.Username = "A"; - secondScore.User.Username = "B"; + firstScore.UserString = "A"; + secondScore.UserString = "B"; createListStep(() => new ScorePanelList()); @@ -190,7 +190,7 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestAddScoreImmediately() { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo); + var score = TestResources.CreateTestScoreInfo(); createListStep(() => { @@ -206,9 +206,14 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestKeyboardNavigation() { - var lowestScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { MaxCombo = 100 }; - var middleScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { MaxCombo = 200 }; - var highestScore = new TestScoreInfo(new OsuRuleset().RulesetInfo) { MaxCombo = 300 }; + var lowestScore = TestResources.CreateTestScoreInfo(); + lowestScore.MaxCombo = 100; + + var middleScore = TestResources.CreateTestScoreInfo(); + middleScore.MaxCombo = 200; + + var highestScore = TestResources.CreateTestScoreInfo(); + highestScore.MaxCombo = 300; createListStep(() => new ScorePanelList()); @@ -270,6 +275,13 @@ namespace osu.Game.Tests.Visual.Ranking assertExpandedPanelCentred(); } + private ScoreInfo createScoreForTotalScore(long totalScore) + { + var score = TestResources.CreateTestScoreInfo(); + score.TotalScore = totalScore; + return score; + } + private void createListStep(Func creationFunc) { AddStep("create list", () => Child = list = creationFunc().With(d => diff --git a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs index d91aec753c..f64b7b2b65 100644 --- a/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs +++ b/osu.Game.Tests/Visual/Ranking/TestSceneStatisticsPanel.cs @@ -6,11 +6,11 @@ using System.Collections.Generic; using NUnit.Framework; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Rulesets.Osu; using osu.Game.Scoring; using osu.Game.Screens.Ranking.Statistics; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Scoring; +using osu.Game.Tests.Resources; using osuTK; namespace osu.Game.Tests.Visual.Ranking @@ -20,10 +20,8 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestScoreWithTimeStatistics() { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) - { - HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents() - }; + var score = TestResources.CreateTestScoreInfo(); + score.HitEvents = TestSceneHitEventTimingDistributionGraph.CreateDistributedHitEvents(); loadPanel(score); } @@ -31,10 +29,8 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestScoreWithPositionStatistics() { - var score = new TestScoreInfo(new OsuRuleset().RulesetInfo) - { - HitEvents = createPositionDistributedHitEvents() - }; + var score = TestResources.CreateTestScoreInfo(); + score.HitEvents = createPositionDistributedHitEvents(); loadPanel(score); } @@ -42,7 +38,7 @@ namespace osu.Game.Tests.Visual.Ranking [Test] public void TestScoreWithoutStatistics() { - loadPanel(new TestScoreInfo(new OsuRuleset().RulesetInfo)); + loadPanel(TestResources.CreateTestScoreInfo()); } [Test] 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 @@ namespace osu.Game.Tests.Visual.SongSelect beatmaps.Add(testBeatmap); - AddStep("set ruleset", () => Ruleset.Value = rulesetInfo); + setRuleset(rulesetInfo); selectBeatmap(testBeatmap); @@ -167,6 +167,22 @@ namespace osu.Game.Tests.Visual.SongSelect 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.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs index 0494d1de3c..912d3f838c 100644 --- a/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs +++ b/osu.Game.Tests/Visual/SongSelect/TestScenePlaySongSelect.cs @@ -466,7 +466,9 @@ namespace osu.Game.Tests.Visual.SongSelect public void TestExternalBeatmapChangeWhileFiltered(bool differentRuleset) { createSongSelect(); - addManyTestMaps(); + // ensure there is at least 1 difficulty for each of the rulesets + // (catch is excluded inside of addManyTestMaps). + addManyTestMaps(3); changeRuleset(0); @@ -488,8 +490,9 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select beatmap externally", () => { target = manager.GetAllUsableBeatmapSets() - .Where(b => b.Beatmaps.Any(bi => bi.RulesetID == targetRuleset)) - .ElementAt(5).Beatmaps.First(bi => bi.RulesetID == targetRuleset); + .First(b => b.Beatmaps.Any(bi => bi.RulesetID == targetRuleset)) + .Beatmaps + .First(bi => bi.RulesetID == targetRuleset); Beatmap.Value = manager.GetWorkingBeatmap(target); }); @@ -518,7 +521,9 @@ namespace osu.Game.Tests.Visual.SongSelect public void TestExternalBeatmapChangeWhileFilteredThenRefilter() { createSongSelect(); - addManyTestMaps(); + // ensure there is at least 1 difficulty for each of the rulesets + // (catch is excluded inside of addManyTestMaps). + addManyTestMaps(3); changeRuleset(0); @@ -534,8 +539,10 @@ namespace osu.Game.Tests.Visual.SongSelect AddStep("select beatmap externally", () => { - target = manager.GetAllUsableBeatmapSets().Where(b => b.Beatmaps.Any(bi => bi.RulesetID == 1)) - .ElementAt(5).Beatmaps.First(); + target = manager + .GetAllUsableBeatmapSets() + .First(b => b.Beatmaps.Any(bi => bi.RulesetID == 1)) + .Beatmaps.First(); Beatmap.Value = manager.GetWorkingBeatmap(target); }); @@ -835,12 +842,7 @@ namespace osu.Game.Tests.Visual.SongSelect // this beatmap change should be overridden by the present. Beatmap.Value = manager.GetWorkingBeatmap(getSwitchBeatmap()); - songSelect.PresentScore(new ScoreInfo - { - User = new APIUser { Username = "woo" }, - BeatmapInfo = getPresentBeatmap(), - Ruleset = getPresentBeatmap().Ruleset - }); + songSelect.PresentScore(TestResources.CreateTestScoreInfo(getPresentBeatmap())); }); AddUntilStep("wait for results screen presented", () => !songSelect.IsCurrentScreen()); @@ -882,14 +884,21 @@ namespace osu.Game.Tests.Visual.SongSelect AddUntilStep("wait for carousel loaded", () => songSelect.Carousel.IsAlive); } - private void addManyTestMaps() + /// + /// Imports test beatmap sets to show in the carousel. + /// + /// + /// The exact count of difficulties to create for each beatmap set. + /// A value causes the count of difficulties to be selected randomly. + /// + private void addManyTestMaps(int? difficultyCountPerSet = null) { AddStep("import test maps", () => { var usableRulesets = rulesets.AvailableRulesets.Where(r => r.OnlineID != 2).ToArray(); - for (int i = 0; i < 100; i += 10) - manager.Import(TestResources.CreateTestBeatmapSetInfo(rulesets: usableRulesets)).Wait(); + for (int i = 0; i < 10; i++) + manager.Import(TestResources.CreateTestBeatmapSetInfo(difficultyCountPerSet, usableRulesets)).Wait(); }); } diff --git a/osu.Game.Tests/Visual/TestMultiplayerScreenStack.cs b/osu.Game.Tests/Visual/TestMultiplayerComponents.cs similarity index 88% rename from osu.Game.Tests/Visual/TestMultiplayerScreenStack.cs rename to osu.Game.Tests/Visual/TestMultiplayerComponents.cs index 7f1171db1f..cd7a936778 100644 --- a/osu.Game.Tests/Visual/TestMultiplayerScreenStack.cs +++ b/osu.Game.Tests/Visual/TestMultiplayerComponents.cs @@ -26,7 +26,7 @@ namespace osu.Game.Tests.Visual /// ///

///
- public class TestMultiplayerScreenStack : OsuScreen + public class TestMultiplayerComponents : OsuScreen { public Screens.OnlinePlay.Multiplayer.Multiplayer MultiplayerScreen => multiplayerScreen; @@ -42,14 +42,18 @@ namespace osu.Game.Tests.Visual private readonly OsuScreenStack screenStack; private readonly TestMultiplayer multiplayerScreen; - public TestMultiplayerScreenStack() + public TestMultiplayerComponents() { multiplayerScreen = new TestMultiplayer(); InternalChildren = new Drawable[] { Client = new TestMultiplayerClient(RoomManager), - screenStack = new OsuScreenStack { RelativeSizeAxes = Axes.Both } + screenStack = new OsuScreenStack + { + Name = nameof(TestMultiplayerComponents), + RelativeSizeAxes = Axes.Both + } }; screenStack.Push(multiplayerScreen); @@ -61,7 +65,7 @@ namespace osu.Game.Tests.Visual ((DummyAPIAccess)api).HandleRequest = request => multiplayerScreen.RequestsHandler.HandleRequest(request, api.LocalUser.Value, game); } - public override bool OnBackButton() => multiplayerScreen.OnBackButton(); + public override bool OnBackButton() => (screenStack.CurrentScreen as OsuScreen)?.OnBackButton() ?? base.OnBackButton(); public override bool OnExiting(IScreen next) { diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs index e5bcc08924..ede89c6096 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneBeatSyncedContainer.cs @@ -135,6 +135,35 @@ namespace osu.Game.Tests.Visual.UserInterface AddAssert("bpm is default", () => lastBpm != null && Precision.AlmostEquals(lastBpm.Value, 60)); } + [TestCase(true)] + [TestCase(false)] + public void TestEarlyActivationEffectPoint(bool earlyActivating) + { + double earlyActivationMilliseconds = earlyActivating ? 100 : 0; + ControlPoint actualEffectPoint = null; + + AddStep($"set early activation to {earlyActivationMilliseconds}", () => beatContainer.EarlyActivationMilliseconds = earlyActivationMilliseconds); + + AddStep("seek before kiai effect point", () => + { + ControlPoint expectedEffectPoint = Beatmap.Value.Beatmap.ControlPointInfo.EffectPoints.First(ep => ep.KiaiMode); + actualEffectPoint = null; + beatContainer.AllowMistimedEventFiring = false; + + beatContainer.NewBeat = (i, timingControlPoint, effectControlPoint, channelAmplitudes) => + { + if (Precision.AlmostEquals(gameplayClockContainer.CurrentTime + earlyActivationMilliseconds, expectedEffectPoint.Time, BeatSyncedContainer.MISTIMED_ALLOWANCE)) + actualEffectPoint = effectControlPoint; + }; + + gameplayClockContainer.Seek(expectedEffectPoint.Time - earlyActivationMilliseconds); + }); + + AddUntilStep("wait for effect point", () => actualEffectPoint != null); + + AddAssert("effect has kiai", () => actualEffectPoint != null && ((EffectControlPoint)actualEffectPoint).KiaiMode); + } + private class TestBeatSyncedContainer : BeatSyncedContainer { private const int flash_layer_height = 150; @@ -145,6 +174,12 @@ namespace osu.Game.Tests.Visual.UserInterface set => base.AllowMistimedEventFiring = value; } + public new double EarlyActivationMilliseconds + { + get => base.EarlyActivationMilliseconds; + set => base.EarlyActivationMilliseconds = value; + } + private readonly InfoString timingPointCount; private readonly InfoString currentTimingPoint; private readonly InfoString beatCount; diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs index 9f0f4a6b8b..2363bbbfcf 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneDeleteLocalScore.cs @@ -91,7 +91,7 @@ namespace osu.Game.Tests.Visual.UserInterface { var score = new ScoreInfo { - OnlineScoreID = i, + OnlineID = i, BeatmapInfo = beatmapInfo, BeatmapInfoID = beatmapInfo.ID, Accuracy = RNG.NextDouble(), @@ -163,7 +163,7 @@ namespace osu.Game.Tests.Visual.UserInterface }); AddUntilStep("wait for fetch", () => leaderboard.Scores != null); - AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != scoreBeingDeleted.OnlineScoreID)); + AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineID != scoreBeingDeleted.OnlineID)); } [Test] @@ -171,7 +171,7 @@ namespace osu.Game.Tests.Visual.UserInterface { AddStep("delete top score", () => scoreManager.Delete(importedScores[0])); AddUntilStep("wait for fetch", () => leaderboard.Scores != null); - AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineScoreID != importedScores[0].OnlineScoreID)); + AddUntilStep("score removed from leaderboard", () => leaderboard.Scores.All(s => s.OnlineID != importedScores[0].OnlineID)); } } } diff --git a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs index d30f1e8889..3fa9b8b877 100644 --- a/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs +++ b/osu.Game.Tests/Visual/UserInterface/TestSceneUpdateableBeatmapBackgroundSprite.cs @@ -26,9 +26,6 @@ namespace osu.Game.Tests.Visual.UserInterface private BeatmapSetInfo testBeatmap; private IAPIProvider api; - [Resolved] - private BeatmapManager beatmaps { get; set; } - [BackgroundDependencyLoader] private void load(OsuGameBase osu, IAPIProvider api, RulesetStore rulesets) { diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs index 8139387a96..b678f69b8f 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentBeatmapPanel.cs @@ -6,19 +6,20 @@ using osu.Framework.Graphics; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets; +using osu.Game.Tests.Visual; using osu.Game.Tournament.Components; namespace osu.Game.Tournament.Tests.Components { public class TestSceneTournamentBeatmapPanel : TournamentTestScene { + /// + /// Warning: the below API instance is actually the online API, rather than the dummy API provided by the test. + /// It cannot be trivially replaced because setting to causes to no longer be usable. + /// [Resolved] private IAPIProvider api { get; set; } - [Resolved] - private RulesetStore rulesets { get; set; } - [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs b/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs index 3cd13df0d3..9feef36a02 100644 --- a/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs +++ b/osu.Game.Tournament.Tests/Components/TestSceneTournamentModDisplay.cs @@ -19,7 +19,7 @@ namespace osu.Game.Tournament.Tests.Components private IAPIProvider api { get; set; } [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } private FillFlowContainer fillFlow; diff --git a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs index d149ec145b..3619aae7e0 100644 --- a/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs +++ b/osu.Game.Tournament.Tests/NonVisual/CustomTourneyDirectoryTest.cs @@ -96,7 +96,7 @@ namespace osu.Game.Tournament.Tests.NonVisual Directory.CreateDirectory(flagsPath); // Define testing files corresponding to the specific file migrations that are needed - string bracketFile = Path.Combine(osuRoot, "bracket.json"); + string bracketFile = Path.Combine(osuRoot, TournamentGameBase.BRACKET_FILENAME); string drawingsConfig = Path.Combine(osuRoot, "drawings.ini"); string drawingsFile = Path.Combine(osuRoot, "drawings.txt"); @@ -133,7 +133,7 @@ namespace osu.Game.Tournament.Tests.NonVisual Assert.That(storage.GetFullPath("."), Is.EqualTo(migratedPath)); - Assert.True(storage.Exists("bracket.json")); + Assert.True(storage.Exists(TournamentGameBase.BRACKET_FILENAME)); Assert.True(storage.Exists("drawings.txt")); Assert.True(storage.Exists("drawings_results.txt")); diff --git a/osu.Game.Tournament/Components/TournamentModIcon.cs b/osu.Game.Tournament/Components/TournamentModIcon.cs index 0fde263bc8..ed8a36c220 100644 --- a/osu.Game.Tournament/Components/TournamentModIcon.cs +++ b/osu.Game.Tournament/Components/TournamentModIcon.cs @@ -21,7 +21,7 @@ namespace osu.Game.Tournament.Components private readonly string modAcronym; [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } public TournamentModIcon(string modAcronym) { diff --git a/osu.Game.Tournament/IO/TournamentStorage.cs b/osu.Game.Tournament/IO/TournamentStorage.cs index 02cf567837..347d368a04 100644 --- a/osu.Game.Tournament/IO/TournamentStorage.cs +++ b/osu.Game.Tournament/IO/TournamentStorage.cs @@ -86,7 +86,7 @@ namespace osu.Game.Tournament.IO DeleteRecursive(source); } - moveFileIfExists("bracket.json", destination); + moveFileIfExists(TournamentGameBase.BRACKET_FILENAME, destination); moveFileIfExists("drawings.txt", destination); moveFileIfExists("drawings_results.txt", destination); moveFileIfExists("drawings.ini", destination); diff --git a/osu.Game.Tournament/IPC/FileBasedIPC.cs b/osu.Game.Tournament/IPC/FileBasedIPC.cs index a57f9fd691..5278d538d2 100644 --- a/osu.Game.Tournament/IPC/FileBasedIPC.cs +++ b/osu.Game.Tournament/IPC/FileBasedIPC.cs @@ -28,7 +28,7 @@ namespace osu.Game.Tournament.IPC protected IAPIProvider API { get; private set; } [Resolved] - protected RulesetStore Rulesets { get; private set; } + protected IRulesetStore Rulesets { get; private set; } [Resolved] private GameHost host { get; set; } diff --git a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs index 9abf1d3adb..5d2fddffd9 100644 --- a/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs +++ b/osu.Game.Tournament/Screens/Editors/SeedingEditorScreen.cs @@ -25,9 +25,6 @@ namespace osu.Game.Tournament.Screens.Editors protected override BindableList Storage => team.SeedingResults; - [Resolved(canBeNull: true)] - private TournamentSceneManager sceneManager { get; set; } - public SeedingEditorScreen(TournamentTeam team, TournamentScreen parentScreen) : base(parentScreen) { @@ -38,9 +35,6 @@ namespace osu.Game.Tournament.Screens.Editors { public SeedingResult Model { get; } - [Resolved] - private LadderInfo ladderInfo { get; set; } - public SeedingResultRow(TournamentTeam team, SeedingResult round) { Model = round; diff --git a/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs b/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs index 8e9b32231f..5a1ceecd01 100644 --- a/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs +++ b/osu.Game.Tournament/Screens/Setup/StablePathSelectScreen.cs @@ -21,9 +21,6 @@ namespace osu.Game.Tournament.Screens.Setup { public class StablePathSelectScreen : TournamentScreen { - [Resolved] - private GameHost host { get; set; } - [Resolved(canBeNull: true)] private TournamentSceneManager sceneManager { get; set; } diff --git a/osu.Game.Tournament/TournamentGame.cs b/osu.Game.Tournament/TournamentGame.cs index f03f815b83..5d613894d4 100644 --- a/osu.Game.Tournament/TournamentGame.cs +++ b/osu.Game.Tournament/TournamentGame.cs @@ -71,7 +71,7 @@ namespace osu.Game.Tournament loadingSpinner.Expire(); Logger.Error(t.Exception, "Couldn't load bracket with error"); - Add(new WarningBox("Your bracket.json file could not be parsed. Please check runtime.log for more details.")); + Add(new WarningBox($"Your {BRACKET_FILENAME} file could not be parsed. Please check runtime.log for more details.")); }); return; diff --git a/osu.Game.Tournament/TournamentGameBase.cs b/osu.Game.Tournament/TournamentGameBase.cs index d2f146c4c2..d08322a3e8 100644 --- a/osu.Game.Tournament/TournamentGameBase.cs +++ b/osu.Game.Tournament/TournamentGameBase.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Textures; using osu.Framework.Input; using osu.Framework.IO.Stores; +using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Graphics; using osu.Game.Online.API.Requests; @@ -26,15 +27,15 @@ namespace osu.Game.Tournament [Cached(typeof(TournamentGameBase))] public class TournamentGameBase : OsuGameBase { - private const string bracket_filename = "bracket.json"; + public const string BRACKET_FILENAME = @"bracket.json"; private LadderInfo ladder; private TournamentStorage storage; private DependencyContainer dependencies; private FileBasedIPC ipc; - protected Task BracketLoadTask => taskCompletionSource.Task; + protected Task BracketLoadTask => bracketLoadTaskCompletionSource.Task; - private readonly TaskCompletionSource taskCompletionSource = new TaskCompletionSource(); + private readonly TaskCompletionSource bracketLoadTaskCompletionSource = new TaskCompletionSource(); protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) { @@ -71,9 +72,9 @@ namespace osu.Game.Tournament { try { - if (storage.Exists(bracket_filename)) + if (storage.Exists(BRACKET_FILENAME)) { - using (Stream stream = storage.GetStream(bracket_filename, FileAccess.Read, FileMode.Open)) + using (Stream stream = storage.GetStream(BRACKET_FILENAME, FileAccess.Read, FileMode.Open)) using (var sr = new StreamReader(stream)) ladder = JsonConvert.DeserializeObject(sr.ReadToEnd(), new JsonPointConverter()); } @@ -144,7 +145,7 @@ namespace osu.Game.Tournament } catch (Exception e) { - taskCompletionSource.SetException(e); + bracketLoadTaskCompletionSource.SetException(e); return; } @@ -156,7 +157,7 @@ namespace osu.Game.Tournament dependencies.CacheAs(ipc = new FileBasedIPC()); Add(ipc); - taskCompletionSource.SetResult(true); + bracketLoadTaskCompletionSource.SetResult(true); initialisationText.Expire(); }); @@ -292,6 +293,12 @@ namespace osu.Game.Tournament protected virtual void SaveChanges() { + if (!bracketLoadTaskCompletionSource.Task.IsCompletedSuccessfully) + { + Logger.Log("Inhibiting bracket save as bracket parsing failed"); + return; + } + foreach (var r in ladder.Rounds) r.Matches = ladder.Matches.Where(p => p.Round.Value == r).Select(p => p.ID).ToList(); @@ -309,7 +316,7 @@ namespace osu.Game.Tournament Converters = new JsonConverter[] { new JsonPointConverter() } }); - using (var stream = storage.GetStream(bracket_filename, FileAccess.Write, FileMode.Create)) + using (var stream = storage.GetStream(BRACKET_FILENAME, FileAccess.Write, FileMode.Create)) using (var sw = new StreamWriter(stream)) sw.Write(serialisedLadder); } diff --git a/osu.Game/Audio/PreviewTrackManager.cs b/osu.Game/Audio/PreviewTrackManager.cs index e631d35180..6d56d152f1 100644 --- a/osu.Game/Audio/PreviewTrackManager.cs +++ b/osu.Game/Audio/PreviewTrackManager.cs @@ -18,9 +18,6 @@ namespace osu.Game.Audio private readonly BindableDouble muteBindable = new BindableDouble(); - [Resolved] - private AudioManager audio { get; set; } - private ITrackStore trackStore; protected TrackManagerPreviewTrack CurrentTrack; diff --git a/osu.Game/Beatmaps/BeatmapDifficulty.cs b/osu.Game/Beatmaps/BeatmapDifficulty.cs index dfd21469fa..65d1fb8286 100644 --- a/osu.Game/Beatmaps/BeatmapDifficulty.cs +++ b/osu.Game/Beatmaps/BeatmapDifficulty.cs @@ -15,6 +15,8 @@ namespace osu.Game.Beatmaps public int ID { get; set; } + public bool IsManaged => ID > 0; + public float DrainRate { get; set; } = DEFAULT_DIFFICULTY; public float CircleSize { get; set; } = DEFAULT_DIFFICULTY; public float OverallDifficulty { get; set; } = DEFAULT_DIFFICULTY; diff --git a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs index 61717c18d5..119906cadc 100644 --- a/osu.Game/Beatmaps/BeatmapDifficultyCache.cs +++ b/osu.Game/Beatmaps/BeatmapDifficultyCache.cs @@ -135,7 +135,7 @@ namespace osu.Game.Beatmaps var localRulesetInfo = rulesetInfo as RulesetInfo; // Difficulty can only be computed if the beatmap and ruleset are locally available. - if (localBeatmapInfo == null || localBeatmapInfo.ID == 0 || localRulesetInfo == null) + if (localBeatmapInfo?.IsManaged != true || localRulesetInfo == null) { // If not, fall back to the existing star difficulty (e.g. from an online source). return Task.FromResult(new StarDifficulty(beatmapInfo.StarRating, (beatmapInfo as IBeatmapOnlineInfo)?.MaxCombo ?? 0)); diff --git a/osu.Game/Beatmaps/BeatmapInfo.cs b/osu.Game/Beatmaps/BeatmapInfo.cs index 36f82ac56c..4175d7ff6b 100644 --- a/osu.Game/Beatmaps/BeatmapInfo.cs +++ b/osu.Game/Beatmaps/BeatmapInfo.cs @@ -20,6 +20,8 @@ namespace osu.Game.Beatmaps { public int ID { get; set; } + public bool IsManaged => ID > 0; + public int BeatmapVersion; private int? onlineID; diff --git a/osu.Game/Beatmaps/BeatmapMetadata.cs b/osu.Game/Beatmaps/BeatmapMetadata.cs index b395f16c24..5da0264893 100644 --- a/osu.Game/Beatmaps/BeatmapMetadata.cs +++ b/osu.Game/Beatmaps/BeatmapMetadata.cs @@ -20,6 +20,8 @@ namespace osu.Game.Beatmaps { public int ID { get; set; } + public bool IsManaged => ID > 0; + public string Title { get; set; } = string.Empty; [JsonProperty("title_unicode")] diff --git a/osu.Game/Beatmaps/BeatmapSetFileInfo.cs b/osu.Game/Beatmaps/BeatmapSetFileInfo.cs index ce50463f05..29dcf4d6aa 100644 --- a/osu.Game/Beatmaps/BeatmapSetFileInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetFileInfo.cs @@ -11,6 +11,8 @@ namespace osu.Game.Beatmaps { public int ID { get; set; } + public bool IsManaged => ID > 0; + public int BeatmapSetInfoID { get; set; } public int FileInfoID { get; set; } diff --git a/osu.Game/Beatmaps/BeatmapSetInfo.cs b/osu.Game/Beatmaps/BeatmapSetInfo.cs index ac7067edda..a3a8f8555f 100644 --- a/osu.Game/Beatmaps/BeatmapSetInfo.cs +++ b/osu.Game/Beatmaps/BeatmapSetInfo.cs @@ -18,6 +18,8 @@ namespace osu.Game.Beatmaps { public int ID { get; set; } + public bool IsManaged => ID > 0; + private int? onlineID; [Column("OnlineBeatmapSetID")] diff --git a/osu.Game/Beatmaps/DifficultyRecommender.cs b/osu.Game/Beatmaps/DifficultyRecommender.cs index 8b00d0f7f2..3949e84f4a 100644 --- a/osu.Game/Beatmaps/DifficultyRecommender.cs +++ b/osu.Game/Beatmaps/DifficultyRecommender.cs @@ -25,7 +25,7 @@ namespace osu.Game.Beatmaps private IAPIProvider api { get; set; } [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } [Resolved] private Bindable ruleset { get; set; } @@ -35,7 +35,7 @@ namespace osu.Game.Beatmaps /// private int? requestedUserId; - private readonly Dictionary recommendedDifficultyMapping = new Dictionary(); + private readonly Dictionary recommendedDifficultyMapping = new Dictionary(); private readonly IBindable apiState = new Bindable(); @@ -101,7 +101,7 @@ namespace osu.Game.Beatmaps /// Rulesets ordered descending by their respective recommended difficulties. /// The currently selected ruleset will always be first. /// - private IEnumerable orderedRulesets + private IEnumerable orderedRulesets { get { diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs index 37c1bacda4..1e24501426 100644 --- a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCard.cs @@ -31,10 +31,12 @@ namespace osu.Game.Beatmaps.Drawables.Cards public class BeatmapCard : OsuClickableContainer { public const float TRANSITION_DURATION = 400; + public const float CORNER_RADIUS = 10; + + public IBindable Expanded { get; } private const float width = 408; private const float height = 100; - private const float corner_radius = 10; private const float icon_area_width = 30; private readonly APIBeatmapSet beatmapSet; @@ -42,6 +44,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards private readonly BeatmapDownloadTracker downloadTracker; + private BeatmapCardContent content = null!; + private BeatmapCardThumbnail thumbnail = null!; private Container rightAreaBackground = null!; @@ -60,9 +64,11 @@ namespace osu.Game.Beatmaps.Drawables.Cards [Resolved] private OverlayColourProvider colourProvider { get; set; } = null!; - public BeatmapCard(APIBeatmapSet beatmapSet) + public BeatmapCard(APIBeatmapSet beatmapSet, bool allowExpansion = true) : base(HoverSampleSet.Submit) { + Expanded = new BindableBool { Disabled = !allowExpansion }; + this.beatmapSet = beatmapSet; favouriteState = new Bindable(new BeatmapSetFavouriteState(beatmapSet.HasFavourited, beatmapSet.FavouriteCount)); downloadTracker = new BeatmapDownloadTracker(beatmapSet); @@ -73,242 +79,247 @@ namespace osu.Game.Beatmaps.Drawables.Cards { Width = width; Height = height; - CornerRadius = corner_radius; - Masking = true; FillFlowContainer leftIconArea; GridContainer titleContainer; GridContainer artistContainer; - InternalChildren = new Drawable[] + InternalChild = content = new BeatmapCardContent(height) { - downloadTracker, - rightAreaBackground = new Container + MainContent = new Container { - RelativeSizeAxes = Axes.Y, - Width = icon_area_width + 2 * corner_radius, - Anchor = Anchor.CentreRight, - Origin = Anchor.CentreRight, - // workaround for masking artifacts at the top & bottom of card, - // which become especially visible on downloaded beatmaps (when the icon area has a lime background). - Padding = new MarginPadding { Vertical = 1 }, - Child = new Box - { - RelativeSizeAxes = Axes.Both, - Colour = Colour4.White - }, - }, - thumbnail = new BeatmapCardThumbnail(beatmapSet) - { - Name = @"Left (icon) area", - Size = new Vector2(height), - Padding = new MarginPadding { Right = corner_radius }, - Child = leftIconArea = new FillFlowContainer - { - Margin = new MarginPadding(5), - AutoSizeAxes = Axes.Both, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(1) - } - }, - new Container - { - Name = @"Right (button) area", - Width = 30, - RelativeSizeAxes = Axes.Y, - Origin = Anchor.TopRight, - Anchor = Anchor.TopRight, - Padding = new MarginPadding { Vertical = 17.5f }, - Child = rightAreaButtons = new Container - { - RelativeSizeAxes = Axes.Both, - Children = new BeatmapCardIconButton[] - { - new FavouriteButton(beatmapSet) - { - Current = favouriteState, - Anchor = Anchor.TopCentre, - Origin = Anchor.TopCentre - }, - new DownloadButton(beatmapSet) - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - State = { BindTarget = downloadTracker.State } - }, - new GoToBeatmapButton(beatmapSet) - { - Anchor = Anchor.BottomCentre, - Origin = Anchor.BottomCentre, - State = { BindTarget = downloadTracker.State } - } - } - } - }, - mainContent = new Container - { - Name = @"Main content", - X = height - corner_radius, - Height = height, - CornerRadius = corner_radius, - Masking = true, + RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - mainContentBackground = new BeatmapCardContentBackground(beatmapSet) + downloadTracker, + rightAreaBackground = new Container { - RelativeSizeAxes = Axes.Both, - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.Both, - Padding = new MarginPadding + RelativeSizeAxes = Axes.Y, + Width = icon_area_width + 2 * CORNER_RADIUS, + Anchor = Anchor.CentreRight, + Origin = Anchor.CentreRight, + // workaround for masking artifacts at the top & bottom of card, + // which become especially visible on downloaded beatmaps (when the icon area has a lime background). + Padding = new MarginPadding { Vertical = 1 }, + Child = new Box { - Horizontal = 10, - Vertical = 4 + RelativeSizeAxes = Axes.Both, + Colour = Colour4.White }, - Direction = FillDirection.Vertical, - Children = new Drawable[] + }, + thumbnail = new BeatmapCardThumbnail(beatmapSet) + { + Name = @"Left (icon) area", + Size = new Vector2(height), + Padding = new MarginPadding { Right = CORNER_RADIUS }, + Child = leftIconArea = new FillFlowContainer { - titleContainer = new GridContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.AutoSize) - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new[] - { - new OsuSpriteText - { - Text = new RomanisableString(beatmapSet.TitleUnicode, beatmapSet.Title), - Font = OsuFont.Default.With(size: 22.5f, weight: FontWeight.SemiBold), - RelativeSizeAxes = Axes.X, - Truncate = true - }, - Empty() - } - } - }, - artistContainer = new GridContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - ColumnDimensions = new[] - { - new Dimension(), - new Dimension(GridSizeMode.AutoSize) - }, - RowDimensions = new[] - { - new Dimension(GridSizeMode.AutoSize) - }, - Content = new[] - { - new[] - { - new OsuSpriteText - { - Text = createArtistText(), - Font = OsuFont.Default.With(size: 17.5f, weight: FontWeight.SemiBold), - RelativeSizeAxes = Axes.X, - Truncate = true - }, - Empty() - }, - } - }, - new LinkFlowContainer(s => - { - s.Shadow = false; - s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold); - }).With(d => - { - d.AutoSizeAxes = Axes.Both; - d.Margin = new MarginPadding { Top = 2 }; - d.AddText("mapped by ", t => t.Colour = colourProvider.Content2); - d.AddUserLink(beatmapSet.Author); - }), + Margin = new MarginPadding(5), + AutoSizeAxes = Axes.Both, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(1) } }, new Container { - Name = @"Bottom content", - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Anchor = Anchor.BottomLeft, - Origin = Anchor.BottomLeft, - Padding = new MarginPadding + Name = @"Right (button) area", + Width = 30, + RelativeSizeAxes = Axes.Y, + Origin = Anchor.TopRight, + Anchor = Anchor.TopRight, + Padding = new MarginPadding { Vertical = 17.5f }, + Child = rightAreaButtons = new Container { - Horizontal = 10, - Vertical = 4 - }, + RelativeSizeAxes = Axes.Both, + Children = new BeatmapCardIconButton[] + { + new FavouriteButton(beatmapSet) + { + Current = favouriteState, + Anchor = Anchor.TopCentre, + Origin = Anchor.TopCentre + }, + new DownloadButton(beatmapSet) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + State = { BindTarget = downloadTracker.State } + }, + new GoToBeatmapButton(beatmapSet) + { + Anchor = Anchor.BottomCentre, + Origin = Anchor.BottomCentre, + State = { BindTarget = downloadTracker.State } + } + } + } + }, + mainContent = new Container + { + Name = @"Main content", + X = height - CORNER_RADIUS, + Height = height, + CornerRadius = CORNER_RADIUS, + Masking = true, Children = new Drawable[] { - idleBottomContent = new FillFlowContainer + mainContentBackground = new BeatmapCardContentBackground(beatmapSet) { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, + RelativeSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding + { + Horizontal = 10, + Vertical = 4 + }, Direction = FillDirection.Vertical, - Spacing = new Vector2(0, 3), - AlwaysPresent = true, Children = new Drawable[] { - statisticsContainer = new FillFlowContainer + titleContainer = new GridContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(10, 0), - Alpha = 0, - AlwaysPresent = true, - ChildrenEnumerable = createStatistics() - }, - new FillFlowContainer - { - RelativeSizeAxes = Axes.X, - AutoSizeAxes = Axes.Y, - Direction = FillDirection.Horizontal, - Spacing = new Vector2(4, 0), - Children = new Drawable[] + ColumnDimensions = new[] { - new BeatmapSetOnlineStatusPill + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new[] { - AutoSizeAxes = Axes.Both, - Status = beatmapSet.Status, - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft - }, - new DifficultySpectrumDisplay(beatmapSet) - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - DotSize = new Vector2(6, 12) + new OsuSpriteText + { + Text = new RomanisableString(beatmapSet.TitleUnicode, beatmapSet.Title), + Font = OsuFont.Default.With(size: 22.5f, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + Truncate = true + }, + Empty() } } - } + }, + artistContainer = new GridContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + ColumnDimensions = new[] + { + new Dimension(), + new Dimension(GridSizeMode.AutoSize) + }, + RowDimensions = new[] + { + new Dimension(GridSizeMode.AutoSize) + }, + Content = new[] + { + new[] + { + new OsuSpriteText + { + Text = createArtistText(), + Font = OsuFont.Default.With(size: 17.5f, weight: FontWeight.SemiBold), + RelativeSizeAxes = Axes.X, + Truncate = true + }, + Empty() + }, + } + }, + new LinkFlowContainer(s => + { + s.Shadow = false; + s.Font = OsuFont.GetFont(size: 14, weight: FontWeight.SemiBold); + }).With(d => + { + d.AutoSizeAxes = Axes.Both; + d.Margin = new MarginPadding { Top = 2 }; + d.AddText("mapped by ", t => t.Colour = colourProvider.Content2); + d.AddUserLink(beatmapSet.Author); + }), } }, - downloadProgressBar = new BeatmapCardDownloadProgressBar + new Container { + Name = @"Bottom content", RelativeSizeAxes = Axes.X, - Height = 6, - Anchor = Anchor.Centre, - Origin = Anchor.Centre, - State = { BindTarget = downloadTracker.State }, - Progress = { BindTarget = downloadTracker.Progress } + AutoSizeAxes = Axes.Y, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + Padding = new MarginPadding + { + Horizontal = 10, + Vertical = 4 + }, + Children = new Drawable[] + { + idleBottomContent = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Vertical, + Spacing = new Vector2(0, 3), + AlwaysPresent = true, + Children = new Drawable[] + { + statisticsContainer = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(10, 0), + Alpha = 0, + AlwaysPresent = true, + ChildrenEnumerable = createStatistics() + }, + new BeatmapCardExtraInfoRow(beatmapSet) + { + Hovered = _ => + { + content.ExpandAfterDelay(); + return false; + }, + Unhovered = _ => + { + // Handles the case where a user has not shown explicit intent to view expanded info. + // ie. quickly moved over the info row area but didn't remain within it. + if (!Expanded.Value) + content.CancelExpand(); + } + } + } + }, + downloadProgressBar = new BeatmapCardDownloadProgressBar + { + RelativeSizeAxes = Axes.X, + Height = 6, + Anchor = Anchor.Centre, + Origin = Anchor.Centre, + State = { BindTarget = downloadTracker.State }, + Progress = { BindTarget = downloadTracker.Progress } + } + } } } } } - } + }, + ExpandedContent = new Container + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Padding = new MarginPadding { Horizontal = 10, Vertical = 13 }, + Child = new BeatmapCardDifficultyList(beatmapSet) + }, + Expanded = { BindTarget = Expanded } }; if (beatmapSet.HasVideo) @@ -344,7 +355,8 @@ namespace osu.Game.Beatmaps.Drawables.Cards { base.LoadComplete(); - downloadTracker.State.BindValueChanged(_ => updateState(), true); + downloadTracker.State.BindValueChanged(_ => updateState()); + Expanded.BindValueChanged(_ => updateState(), true); FinishTransforms(true); } @@ -386,19 +398,25 @@ namespace osu.Game.Beatmaps.Drawables.Cards private void updateState() { - float targetWidth = width - height; - if (IsHovered) - targetWidth = targetWidth - icon_area_width + corner_radius; + bool showDetails = IsHovered || Expanded.Value; - thumbnail.Dimmed.Value = IsHovered; + float targetWidth = width - height; + if (showDetails) + targetWidth = targetWidth - icon_area_width + CORNER_RADIUS; + + thumbnail.Dimmed.Value = showDetails; + + // Scale value is intentionally chosen to fit in the spacing of listing displays, as to not overlap horizontally with adjacent cards. + // This avoids depth issues where a hovered (scaled) card to the right of another card would be beneath the card to the left. + content.ScaleTo(Expanded.Value ? 1.03f : 1, 500, Easing.OutQuint); mainContent.ResizeWidthTo(targetWidth, TRANSITION_DURATION, Easing.OutQuint); - mainContentBackground.Dimmed.Value = IsHovered; + mainContentBackground.Dimmed.Value = showDetails; - statisticsContainer.FadeTo(IsHovered ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint); + statisticsContainer.FadeTo(showDetails ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint); rightAreaBackground.FadeColour(downloadTracker.State.Value == DownloadState.LocallyAvailable ? colours.Lime0 : colourProvider.Background3, TRANSITION_DURATION, Easing.OutQuint); - rightAreaButtons.FadeTo(IsHovered ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint); + rightAreaButtons.FadeTo(showDetails ? 1 : 0, TRANSITION_DURATION, Easing.OutQuint); foreach (var button in rightAreaButtons) { diff --git a/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContent.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContent.cs new file mode 100644 index 0000000000..286e03e700 --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardContent.cs @@ -0,0 +1,205 @@ +// 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 osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Framework.Graphics.Effects; +using osu.Framework.Graphics.Shapes; +using osu.Framework.Input.Events; +using osu.Framework.Threading; +using osu.Framework.Utils; +using osu.Game.Graphics.Containers; +using osu.Game.Overlays; +using osuTK; + +namespace osu.Game.Beatmaps.Drawables.Cards +{ + public class BeatmapCardContent : CompositeDrawable + { + public Drawable MainContent + { + set => bodyContent.Child = value; + } + + public Drawable ExpandedContent + { + set => dropdownScroll.Child = value; + } + + public IBindable Expanded => expanded; + + private readonly BindableBool expanded = new BindableBool(); + + private readonly Box background; + private readonly Container content; + private readonly Container bodyContent; + private readonly Container dropdownContent; + private readonly OsuScrollContainer dropdownScroll; + private readonly Container borderContainer; + + public BeatmapCardContent(float height) + { + RelativeSizeAxes = Axes.X; + Height = height; + + Anchor = Anchor.Centre; + Origin = Anchor.Centre; + + InternalChild = content = new HoverHandlingContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Masking = true, + Unhovered = _ => updateFromHoverChange(), + Children = new Drawable[] + { + background = new Box + { + RelativeSizeAxes = Axes.Both + }, + bodyContent = new Container + { + RelativeSizeAxes = Axes.X, + Height = height, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Masking = true, + }, + dropdownContent = new HoverHandlingContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Margin = new MarginPadding { Top = height }, + Alpha = 0, + Hovered = _ => + { + updateFromHoverChange(); + return true; + }, + Unhovered = _ => updateFromHoverChange(), + Child = dropdownScroll = new ExpandedContentScrollContainer + { + RelativeSizeAxes = Axes.X, + ScrollbarVisible = false + } + }, + borderContainer = new Container + { + RelativeSizeAxes = Axes.Both, + CornerRadius = BeatmapCard.CORNER_RADIUS, + Masking = true, + BorderThickness = 3, + Child = new Box + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + AlwaysPresent = true + } + } + } + }; + } + + [BackgroundDependencyLoader] + private void load(OverlayColourProvider colourProvider) + { + background.Colour = colourProvider.Background2; + borderContainer.BorderColour = colourProvider.Highlight1; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + Expanded.BindValueChanged(_ => updateState(), true); + FinishTransforms(true); + } + + private ScheduledDelegate? scheduledExpandedChange; + + public void ExpandAfterDelay() => queueExpandedStateChange(true, 100); + + public void CancelExpand() => scheduledExpandedChange?.Cancel(); + + private void updateFromHoverChange() => + queueExpandedStateChange(content.IsHovered || dropdownContent.IsHovered, 100); + + private void queueExpandedStateChange(bool newState, int delay = 0) + { + if (Expanded.Disabled) + return; + + scheduledExpandedChange?.Cancel(); + scheduledExpandedChange = Scheduler.AddDelayed(() => expanded.Value = newState, delay); + } + + private void updateState() + { + background.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + dropdownContent.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + borderContainer.FadeTo(Expanded.Value ? 1 : 0, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + + content.TweenEdgeEffectTo(new EdgeEffectParameters + { + Type = EdgeEffectType.Shadow, + Offset = new Vector2(0, 2), + Radius = 10, + Colour = Colour4.Black.Opacity(Expanded.Value ? 0.3f : 0f), + Hollow = true, + }, BeatmapCard.TRANSITION_DURATION, Easing.OutQuint); + } + + private class ExpandedContentScrollContainer : OsuScrollContainer + { + public ExpandedContentScrollContainer() + { + ScrollbarVisible = false; + } + + protected override void Update() + { + base.Update(); + + Height = Math.Min(Content.DrawHeight, 400); + } + + private bool allowScroll => !Precision.AlmostEquals(DrawSize, Content.DrawSize); + + protected override bool OnDragStart(DragStartEvent e) + { + if (!allowScroll) + return false; + + return base.OnDragStart(e); + } + + protected override void OnDrag(DragEvent e) + { + if (!allowScroll) + return; + + base.OnDrag(e); + } + + protected override void OnDragEnd(DragEndEvent e) + { + if (!allowScroll) + return; + + base.OnDragEnd(e); + } + + protected override bool OnScroll(ScrollEvent e) + { + if (!allowScroll) + return false; + + return base.OnScroll(e); + } + } + } +} 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/Cards/BeatmapCardExtraInfoRow.cs b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs new file mode 100644 index 0000000000..0a9d98e621 --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/Cards/BeatmapCardExtraInfoRow.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API.Requests.Responses; +using osuTK; + +namespace osu.Game.Beatmaps.Drawables.Cards +{ + public class BeatmapCardExtraInfoRow : HoverHandlingContainer + { + public BeatmapCardExtraInfoRow(APIBeatmapSet beatmapSet) + { + RelativeSizeAxes = Axes.X; + AutoSizeAxes = Axes.Y; + + Child = new FillFlowContainer + { + RelativeSizeAxes = Axes.X, + AutoSizeAxes = Axes.Y, + Direction = FillDirection.Horizontal, + Spacing = new Vector2(4, 0), + Children = new Drawable[] + { + new BeatmapSetOnlineStatusPill + { + AutoSizeAxes = Axes.Both, + Status = beatmapSet.Status, + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft + }, + new DifficultySpectrumDisplay(beatmapSet) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + DotSize = new Vector2(6, 12) + } + } + }; + } + } +} diff --git a/osu.Game/Beatmaps/Drawables/Cards/HoverHandlingContainer.cs b/osu.Game/Beatmaps/Drawables/Cards/HoverHandlingContainer.cs new file mode 100644 index 0000000000..fe37616755 --- /dev/null +++ b/osu.Game/Beatmaps/Drawables/Cards/HoverHandlingContainer.cs @@ -0,0 +1,29 @@ +// 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 osu.Framework.Graphics.Containers; +using osu.Framework.Input.Events; + +namespace osu.Game.Beatmaps.Drawables.Cards +{ + public class HoverHandlingContainer : Container + { + public Func? Hovered { get; set; } + public Action? Unhovered { get; set; } + + protected override bool OnHover(HoverEvent e) + { + bool handledByBase = base.OnHover(e); + return Hovered?.Invoke(e) ?? handledByBase; + } + + protected override void OnHoverLost(HoverLostEvent e) + { + base.OnHoverLost(e); + Unhovered?.Invoke(e); + } + } +} diff --git a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs index 6e573cc2a0..82be0559a7 100644 --- a/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs +++ b/osu.Game/Beatmaps/Drawables/DifficultyIcon.cs @@ -86,7 +86,7 @@ namespace osu.Game.Beatmaps.Drawables } [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } [BackgroundDependencyLoader] private void load(OsuColour colours) 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 @@ namespace osu.Game.Beatmaps.Drawables // 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/Beatmaps/FlatFileWorkingBeatmap.cs b/osu.Game/Beatmaps/FlatFileWorkingBeatmap.cs new file mode 100644 index 0000000000..8c915e2872 --- /dev/null +++ b/osu.Game/Beatmaps/FlatFileWorkingBeatmap.cs @@ -0,0 +1,52 @@ +// 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.IO; +using osu.Framework.Audio.Track; +using osu.Framework.Graphics.Textures; +using osu.Game.Beatmaps.Formats; +using osu.Game.IO; +using osu.Game.Rulesets; +using osu.Game.Skinning; + +namespace osu.Game.Beatmaps +{ + /// + /// A which can be constructed directly from a .osu file, providing an implementation for + /// . + /// + public class FlatFileWorkingBeatmap : WorkingBeatmap + { + private readonly Beatmap beatmap; + + public FlatFileWorkingBeatmap(string file, Func rulesetProvider, int? beatmapId = null) + : this(readFromFile(file), rulesetProvider, beatmapId) + { + } + + private FlatFileWorkingBeatmap(Beatmap beatmap, Func rulesetProvider, int? beatmapId = null) + : base(beatmap.BeatmapInfo, null) + { + this.beatmap = beatmap; + + beatmap.BeatmapInfo.Ruleset = rulesetProvider(beatmap.BeatmapInfo.RulesetID).RulesetInfo; + + if (beatmapId.HasValue) + beatmap.BeatmapInfo.OnlineID = beatmapId; + } + + private static Beatmap readFromFile(string filename) + { + using (var stream = File.OpenRead(filename)) + using (var reader = new LineBufferedReader(stream)) + return Decoder.GetDecoder(reader).Decode(reader); + } + + protected override IBeatmap GetBeatmap() => beatmap; + protected override Texture GetBackground() => throw new NotImplementedException(); + protected override Track GetBeatmapTrack() => throw new NotImplementedException(); + protected internal override ISkin GetSkin() => throw new NotImplementedException(); + public override Stream GetStream(string storagePath) => throw new NotImplementedException(); + } +} diff --git a/osu.Game/Beatmaps/WorkingBeatmapCache.cs b/osu.Game/Beatmaps/WorkingBeatmapCache.cs index 559685d3c4..449406eadf 100644 --- a/osu.Game/Beatmaps/WorkingBeatmapCache.cs +++ b/osu.Game/Beatmaps/WorkingBeatmapCache.cs @@ -15,6 +15,7 @@ using osu.Framework.Platform; using osu.Framework.Statistics; using osu.Framework.Testing; using osu.Game.Beatmaps.Formats; +using osu.Game.Database; using osu.Game.IO; using osu.Game.Skinning; using osu.Game.Storyboards; @@ -108,6 +109,7 @@ namespace osu.Game.Beatmaps TextureStore IBeatmapResourceProvider.LargeTextureStore => largeTextureStore; ITrackStore IBeatmapResourceProvider.Tracks => trackStore; AudioManager IStorageResourceProvider.AudioManager => audioManager; + RealmContextFactory IStorageResourceProvider.RealmContextFactory => null; IResourceStore IStorageResourceProvider.Files => files; IResourceStore IStorageResourceProvider.Resources => resources; IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host?.CreateTextureLoaderStore(underlyingStore); diff --git a/osu.Game/Collections/CollectionFilterDropdown.cs b/osu.Game/Collections/CollectionFilterDropdown.cs index ad23874b2e..77bda00107 100644 --- a/osu.Game/Collections/CollectionFilterDropdown.cs +++ b/osu.Game/Collections/CollectionFilterDropdown.cs @@ -13,7 +13,6 @@ using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Framework.Localisation; using osu.Game.Beatmaps; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osuTK; @@ -193,9 +192,6 @@ namespace osu.Game.Collections [NotNull] protected new CollectionFilterMenuItem Item => ((DropdownMenuItem)base.Item).Value; - [Resolved] - private OsuColour colours { get; set; } - [Resolved] private IBindable beatmap { get; set; } diff --git a/osu.Game/Collections/CollectionManager.cs b/osu.Game/Collections/CollectionManager.cs index 9ff92032b7..c4f991094c 100644 --- a/osu.Game/Collections/CollectionManager.cs +++ b/osu.Game/Collections/CollectionManager.cs @@ -40,9 +40,6 @@ namespace osu.Game.Collections public readonly BindableList Collections = new BindableList(); - [Resolved] - private GameHost host { get; set; } - [Resolved] private BeatmapManager beatmaps { get; set; } diff --git a/osu.Game/Configuration/DatabasedSetting.cs b/osu.Game/Configuration/DatabasedSetting.cs index fe1d51d57f..65d9f7799d 100644 --- a/osu.Game/Configuration/DatabasedSetting.cs +++ b/osu.Game/Configuration/DatabasedSetting.cs @@ -11,6 +11,8 @@ namespace osu.Game.Configuration { public int ID { get; set; } + public bool IsManaged => ID > 0; + public int? RulesetID { get; set; } public int? Variant { get; set; } diff --git a/osu.Game/Configuration/OsuConfigManager.cs b/osu.Game/Configuration/OsuConfigManager.cs index 84da3f666d..c6a2abecd7 100644 --- a/osu.Game/Configuration/OsuConfigManager.cs +++ b/osu.Game/Configuration/OsuConfigManager.cs @@ -17,6 +17,7 @@ using osu.Game.Overlays; using osu.Game.Rulesets.Scoring; using osu.Game.Screens.Select; using osu.Game.Screens.Select.Filter; +using osu.Game.Skinning; namespace osu.Game.Configuration { @@ -27,7 +28,7 @@ namespace osu.Game.Configuration { // UI/selection defaults SetDefault(OsuSetting.Ruleset, string.Empty); - SetDefault(OsuSetting.Skin, 0, -1, int.MaxValue); + SetDefault(OsuSetting.Skin, SkinInfo.DEFAULT_SKIN.ToString()); SetDefault(OsuSetting.BeatmapDetailTab, PlayBeatmapDetailArea.TabType.Details); SetDefault(OsuSetting.BeatmapDetailModsFilter, false); @@ -210,9 +211,12 @@ namespace osu.Game.Configuration value: scalingMode.GetLocalisableDescription() ) ), - new TrackedSetting(OsuSetting.Skin, skin => + new TrackedSetting(OsuSetting.Skin, skin => { - string skinName = LookupSkinName(skin) ?? string.Empty; + string skinName = string.Empty; + + if (Guid.TryParse(skin, out var id)) + skinName = LookupSkinName(id) ?? string.Empty; return new SettingDescription( rawValue: skinName, @@ -233,7 +237,7 @@ namespace osu.Game.Configuration }; } - public Func LookupSkinName { private get; set; } + public Func LookupSkinName { private get; set; } public Func LookupKeyBindings { get; set; } } diff --git a/osu.Game/Database/ArchiveModelManager.cs b/osu.Game/Database/ArchiveModelManager.cs index 8cabb55cc3..9c26451d40 100644 --- a/osu.Game/Database/ArchiveModelManager.cs +++ b/osu.Game/Database/ArchiveModelManager.cs @@ -476,7 +476,7 @@ namespace osu.Game.Database { Files.Dereference(file.FileInfo); - if (file.ID > 0) + if (file.IsManaged) { // This shouldn't be required, but here for safety in case the provided TModel is not being change tracked // Definitely can be removed once we rework the database backend. @@ -505,7 +505,7 @@ namespace osu.Game.Database }); } - if (model.ID > 0) + if (model.IsManaged) Update(model); } @@ -737,7 +737,7 @@ namespace osu.Game.Database /// The usable items present in the store. /// Whether the exists. protected virtual bool CheckLocalAvailability(TModel model, IQueryable items) - => model.ID > 0 && items.Any(i => i.ID == model.ID && i.Files.Any()); + => model.IsManaged && items.Any(i => i.ID == model.ID && i.Files.Any()); /// /// Whether import can be skipped after finding an existing import early in the process. 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/EFToRealmMigrator.cs b/osu.Game/Database/EFToRealmMigrator.cs new file mode 100644 index 0000000000..b79a982460 --- /dev/null +++ b/osu.Game/Database/EFToRealmMigrator.cs @@ -0,0 +1,148 @@ +// 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 Microsoft.EntityFrameworkCore; +using osu.Game.Configuration; +using osu.Game.Models; +using osu.Game.Skinning; + +#nullable enable + +namespace osu.Game.Database +{ + internal class EFToRealmMigrator + { + private readonly DatabaseContextFactory efContextFactory; + private readonly RealmContextFactory realmContextFactory; + private readonly OsuConfigManager config; + + public EFToRealmMigrator(DatabaseContextFactory efContextFactory, RealmContextFactory realmContextFactory, OsuConfigManager config) + { + this.efContextFactory = efContextFactory; + this.realmContextFactory = realmContextFactory; + this.config = config; + } + + public void Run() + { + using (var db = efContextFactory.GetForWrite()) + { + migrateSettings(db); + migrateSkins(db); + } + } + + private void migrateSkins(DatabaseWriteUsage db) + { + // can be removed 20220530. + var existingSkins = db.Context.SkinInfo + .Include(s => s.Files) + .ThenInclude(f => f.FileInfo) + .ToList(); + + // previous entries in EF are removed post migration. + if (!existingSkins.Any()) + return; + + var userSkinChoice = config.GetBindable(OsuSetting.Skin); + int.TryParse(userSkinChoice.Value, out int userSkinInt); + + switch (userSkinInt) + { + case EFSkinInfo.DEFAULT_SKIN: + userSkinChoice.Value = SkinInfo.DEFAULT_SKIN.ToString(); + break; + + case EFSkinInfo.CLASSIC_SKIN: + userSkinChoice.Value = SkinInfo.CLASSIC_SKIN.ToString(); + break; + } + + using (var realm = realmContextFactory.CreateContext()) + using (var transaction = realm.BeginWrite()) + { + // only migrate data if the realm database is empty. + // note that this cannot be written as: `realm.All().All(s => s.Protected)`, because realm does not support `.All()`. + if (!realm.All().Any(s => !s.Protected)) + { + foreach (var skin in existingSkins) + { + var realmSkin = new SkinInfo + { + Name = skin.Name, + Creator = skin.Creator, + Hash = skin.Hash, + Protected = false, + InstantiationInfo = skin.InstantiationInfo, + }; + + foreach (var file in skin.Files) + { + var realmFile = realm.Find(file.FileInfo.Hash); + + if (realmFile == null) + realm.Add(realmFile = new RealmFile { Hash = file.FileInfo.Hash }); + + realmSkin.Files.Add(new RealmNamedFileUsage(realmFile, file.Filename)); + } + + realm.Add(realmSkin); + + if (skin.ID == userSkinInt) + userSkinChoice.Value = realmSkin.ID.ToString(); + } + } + + db.Context.RemoveRange(existingSkins); + // Intentionally don't clean up the files, so they don't get purged by EF. + + transaction.Commit(); + } + } + + private void migrateSettings(DatabaseWriteUsage db) + { + // migrate ruleset settings. can be removed 20220315. + var existingSettings = db.Context.DatabasedSetting; + + // previous entries in EF are removed post migration. + if (!existingSettings.Any()) + return; + + using (var realm = realmContextFactory.CreateContext()) + using (var transaction = realm.BeginWrite()) + { + // only migrate data if the realm database is empty. + if (!realm.All().Any()) + { + foreach (var dkb in existingSettings) + { + if (dkb.RulesetID == null) + continue; + + string? shortName = getRulesetShortNameFromLegacyID(dkb.RulesetID.Value); + + if (string.IsNullOrEmpty(shortName)) + continue; + + realm.Add(new RealmRulesetSetting + { + Key = dkb.Key, + Value = dkb.StringValue, + RulesetName = shortName, + Variant = dkb.Variant ?? 0, + }); + } + } + + db.Context.RemoveRange(existingSettings); + + transaction.Commit(); + } + } + + private string? getRulesetShortNameFromLegacyID(long rulesetId) => + efContextFactory.Get().RulesetInfo.FirstOrDefault(r => r.ID == rulesetId)?.ShortName; + } +} diff --git a/osu.Game/Database/IHasPrimaryKey.cs b/osu.Game/Database/IHasPrimaryKey.cs index 3c0fc94418..51a49948fe 100644 --- a/osu.Game/Database/IHasPrimaryKey.cs +++ b/osu.Game/Database/IHasPrimaryKey.cs @@ -11,5 +11,7 @@ namespace osu.Game.Database [JsonIgnore] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] int ID { get; set; } + + bool IsManaged { get; } } } 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 @@ namespace osu.Game.Database 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 4bc1e2d29b..390be4a69d 100644 --- a/osu.Game/Database/IModelFileManager.cs +++ b/osu.Game/Database/IModelFileManager.cs @@ -25,7 +25,7 @@ namespace osu.Game.Database void DeleteFile(TModel model, TFileModel file); /// - /// Add a new file. + /// Add a new file. If the file already exists, it is overwritten. /// /// The item to operate on. /// The new file contents. diff --git a/osu.Game/Database/OsuDbContext.cs b/osu.Game/Database/OsuDbContext.cs index d8d2cb8981..7cd9ae2885 100644 --- a/osu.Game/Database/OsuDbContext.cs +++ b/osu.Game/Database/OsuDbContext.cs @@ -25,7 +25,7 @@ namespace osu.Game.Database public DbSet BeatmapSetInfo { get; set; } public DbSet FileInfo { get; set; } public DbSet RulesetInfo { get; set; } - public DbSet SkinInfo { get; set; } + public DbSet SkinInfo { get; set; } public DbSet ScoreInfo { get; set; } // migrated to realm @@ -133,8 +133,9 @@ namespace osu.Game.Database modelBuilder.Entity().HasIndex(b => b.DeletePending); modelBuilder.Entity().HasIndex(b => b.Hash).IsUnique(); - modelBuilder.Entity().HasIndex(b => b.Hash).IsUnique(); - modelBuilder.Entity().HasIndex(b => b.DeletePending); + modelBuilder.Entity().HasIndex(b => b.Hash).IsUnique(); + modelBuilder.Entity().HasIndex(b => b.DeletePending); + modelBuilder.Entity().HasMany(s => s.Files).WithOne(f => f.SkinInfo); modelBuilder.Entity().HasIndex(b => new { b.RulesetID, b.Variant }); @@ -146,7 +147,7 @@ namespace osu.Game.Database modelBuilder.Entity().HasOne(b => b.BaseDifficulty); - modelBuilder.Entity().HasIndex(b => b.OnlineScoreID).IsUnique(); + modelBuilder.Entity().HasIndex(b => b.OnlineID).IsUnique(); } private class OsuDbLoggerFactory : ILoggerFactory diff --git a/osu.Game/Database/RealmContextFactory.cs b/osu.Game/Database/RealmContextFactory.cs index 2bc77934a8..96c24837a1 100644 --- a/osu.Game/Database/RealmContextFactory.cs +++ b/osu.Game/Database/RealmContextFactory.cs @@ -14,6 +14,7 @@ using osu.Framework.Statistics; using osu.Game.Configuration; using osu.Game.Input.Bindings; using osu.Game.Models; +using osu.Game.Skinning; using osu.Game.Stores; using Realms; @@ -52,6 +53,8 @@ namespace osu.Game.Database /// 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)"); @@ -99,10 +102,6 @@ namespace osu.Game.Database // This method triggers the first `CreateContext` call, which will implicitly run realm migrations and bring the schema up-to-date. cleanupPendingDeletions(); - - // Data migration is handled separately from schema migrations. - // This is required as the user may be initialising realm for the first time ever, which would result in no schema migrations running. - migrateDataFromEF(); } private void cleanupPendingDeletions() @@ -120,6 +119,11 @@ namespace osu.Game.Database realm.Remove(s); } + var pendingDeleteSkins = realm.All().Where(s => s.DeletePending); + + foreach (var s in pendingDeleteSkins) + realm.Remove(s); + transaction.Commit(); } @@ -151,9 +155,22 @@ namespace osu.Game.Database 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 +178,11 @@ namespace osu.Game.Database } finally { - contextCreationLock.Release(); + if (tookSemaphoreLock) + { + contextCreationLock.Release(); + currentThreadCanCreateContexts.Value = false; + } } } @@ -174,53 +195,6 @@ namespace osu.Game.Database }; } - private void migrateDataFromEF() - { - if (efContextFactory == null) - return; - - using (var db = efContextFactory.GetForWrite()) - { - // migrate ruleset settings. can be removed 20220315. - var existingSettings = db.Context.DatabasedSetting; - - // previous entries in EF are removed post migration. - if (!existingSettings.Any()) - return; - - using (var realm = CreateContext()) - using (var transaction = realm.BeginWrite()) - { - // only migrate data if the realm database is empty. - if (!realm.All().Any()) - { - foreach (var dkb in existingSettings) - { - if (dkb.RulesetID == null) - continue; - - string? shortName = getRulesetShortNameFromLegacyID(dkb.RulesetID.Value); - - if (string.IsNullOrEmpty(shortName)) - continue; - - realm.Add(new RealmRulesetSetting - { - Key = dkb.Key, - Value = dkb.StringValue, - RulesetName = shortName, - Variant = dkb.Variant ?? 0, - }); - } - } - - db.Context.RemoveRange(existingSettings); - - transaction.Commit(); - } - } - } - private void onMigration(Migration migration, ulong lastSchemaVersion) { for (ulong i = lastSchemaVersion + 1; i <= schema_version; i++) diff --git a/osu.Game/Database/RealmLive.cs b/osu.Game/Database/RealmLive.cs index 5ee40f5b4d..90b8814c24 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 @@ -19,27 +19,22 @@ namespace osu.Game.Database public bool IsManaged => data.IsManaged; - private readonly SynchronizationContext? fetchedContext; - private readonly int fetchedThreadId; - /// /// The original live data used to create this instance. /// private readonly T data; + private readonly RealmContextFactory realmFactory; + /// /// Construct a new instance of live realm data. /// /// The realm data. - public RealmLive(T data) + /// The realm factory the data was sourced from. May be null for an unmanaged object. + public RealmLive(T data, RealmContextFactory realmFactory) { this.data = data; - - if (data.IsManaged) - { - fetchedContext = SynchronizationContext.Current; - fetchedThreadId = Thread.CurrentThread.ManagedThreadId; - } + this.realmFactory = realmFactory; ID = data.ID; } @@ -50,13 +45,13 @@ namespace osu.Game.Database /// The action to perform. public void PerformRead(Action perform) { - if (originalDataValid) + if (!IsManaged) { perform(data); return; } - using (var realm = Realm.GetInstance(data.Realm.Config)) + using (var realm = realmFactory.CreateContext()) perform(realm.Find(ID)); } @@ -67,12 +62,12 @@ namespace osu.Game.Database 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)}."); + 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)) + using (var realm = realmFactory.CreateContext()) return perform(realm.Find(ID)); } @@ -83,7 +78,7 @@ namespace osu.Game.Database public void PerformWrite(Action perform) { if (!IsManaged) - throw new InvalidOperationException("Can't perform writes on a non-managed underlying value"); + throw new InvalidOperationException(@"Can't perform writes on a non-managed underlying value"); PerformRead(t => { @@ -97,27 +92,18 @@ namespace osu.Game.Database { 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); - - if (!retrieved.IsValid) - throw new InvalidOperationException("Attempted to access value without an open context"); - - return retrieved; + return realmFactory.Context.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; + + public override string ToString() => PerformRead(i => i.ToString()); } } diff --git a/osu.Game/Database/RealmLiveUnmanaged.cs b/osu.Game/Database/RealmLiveUnmanaged.cs new file mode 100644 index 0000000000..ea50ccc1ff --- /dev/null +++ b/osu.Game/Database/RealmLiveUnmanaged.cs @@ -0,0 +1,46 @@ +// 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 Realms; + +#nullable enable + +namespace osu.Game.Database +{ + /// + /// Provides a method of working with unmanaged realm objects. + /// Usually used for testing purposes where the instance is never required to be managed. + /// + /// The underlying object type. + public class RealmLiveUnmanaged : ILive where T : RealmObjectBase, IHasGuidPrimaryKey + { + /// + /// Construct a new instance of live realm data. + /// + /// The realm data. + public RealmLiveUnmanaged(T data) + { + Value = data; + } + + public bool Equals(ILive? other) => ID == other?.ID; + + public override string ToString() => Value.ToString(); + + public Guid ID => Value.ID; + + public void PerformRead(Action perform) => perform(Value); + + public TReturn PerformRead(Func perform) => perform(Value); + + public void PerformWrite(Action perform) => throw new InvalidOperationException(@"Can't perform writes on a non-managed underlying value"); + + public bool IsManaged => false; + + /// + /// The original live data used to create this instance. + /// + public T Value { get; } + } +} diff --git a/osu.Game/Database/RealmObjectExtensions.cs b/osu.Game/Database/RealmObjectExtensions.cs index ac4ca436ad..e5177823ba 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,132 @@ namespace osu.Game.Database return mapper.Map(item); } - public static List> ToLive(this IEnumerable realmList) + public static List> ToLiveUnmanaged(this IEnumerable realmList) where T : RealmObject, IHasGuidPrimaryKey { - return realmList.Select(l => new RealmLive(l)).Cast>().ToList(); + return realmList.Select(l => new RealmLiveUnmanaged(l)).Cast>().ToList(); } - public static ILive ToLive(this T realmObject) + public static ILive ToLiveUnmanaged(this T realmObject) where T : RealmObject, IHasGuidPrimaryKey { - return new RealmLive(realmObject); + return new RealmLiveUnmanaged(realmObject); + } + + public static List> ToLive(this IEnumerable realmList, RealmContextFactory realmContextFactory) + where T : RealmObject, IHasGuidPrimaryKey + { + return realmList.Select(l => new RealmLive(l, realmContextFactory)).Cast>().ToList(); + } + + public static ILive ToLive(this T realmObject, RealmContextFactory realmContextFactory) + where T : RealmObject, IHasGuidPrimaryKey + { + return new RealmLive(realmObject, realmContextFactory); + } + + /// + /// 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 @@ namespace osu.Game.Database } } + if (userTasks.Count == 0) + return; + // Query the users. var request = new GetUsersRequest(userTasks.Keys.ToArray()); diff --git a/osu.Game/Extensions/ModelExtensions.cs b/osu.Game/Extensions/ModelExtensions.cs index 2274da0fd4..f178a5c97b 100644 --- a/osu.Game/Extensions/ModelExtensions.cs +++ b/osu.Game/Extensions/ModelExtensions.cs @@ -104,6 +104,14 @@ namespace osu.Game.Extensions /// Whether online IDs match. If either instance is missing an online ID, this will return false. public static bool MatchesOnlineID(this APIUser? instance, APIUser? other) => matchesOnlineID(instance, other); + /// + /// Check whether the online ID of two s match. + /// + /// The instance to compare. + /// The other instance to compare against. + /// Whether online IDs match. If either instance is missing an online ID, this will return false. + public static bool MatchesOnlineID(this IScoreInfo? instance, IScoreInfo? other) => matchesOnlineID(instance, other); + private static bool matchesOnlineID(this IHasOnlineID? instance, IHasOnlineID? other) { if (instance == null || other == null) diff --git a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs index 6e4901ab1a..2024d18570 100644 --- a/osu.Game/Graphics/Containers/BeatSyncedContainer.cs +++ b/osu.Game/Graphics/Containers/BeatSyncedContainer.cs @@ -111,7 +111,7 @@ namespace osu.Game.Graphics.Containers if (clock == null) return; - double currentTrackTime = clock.CurrentTime; + double currentTrackTime = clock.CurrentTime + EarlyActivationMilliseconds; if (Beatmap.Value.TrackLoaded && Beatmap.Value.BeatmapLoaded) { @@ -132,13 +132,11 @@ namespace osu.Game.Graphics.Containers { // this may be the case where the beat syncing clock has been paused. // we still want to show an idle animation, so use this container's time instead. - currentTrackTime = Clock.CurrentTime; + currentTrackTime = Clock.CurrentTime + EarlyActivationMilliseconds; timingPoint = TimingControlPoint.DEFAULT; effectPoint = EffectControlPoint.DEFAULT; } - currentTrackTime += EarlyActivationMilliseconds; - double beatLength = timingPoint.BeatLength / Divisor; while (beatLength < MinimumBeatLength) diff --git a/osu.Game/Graphics/Cursor/MenuCursor.cs b/osu.Game/Graphics/Cursor/MenuCursor.cs index 3fa90e2330..8e272f637f 100644 --- a/osu.Game/Graphics/Cursor/MenuCursor.cs +++ b/osu.Game/Graphics/Cursor/MenuCursor.cs @@ -72,18 +72,21 @@ namespace osu.Game.Graphics.Cursor protected override bool OnMouseDown(MouseDownEvent e) { - // only trigger animation for main mouse buttons - activeCursor.Scale = new Vector2(1); - activeCursor.ScaleTo(0.90f, 800, Easing.OutQuint); - - activeCursor.AdditiveLayer.Alpha = 0; - activeCursor.AdditiveLayer.FadeInFromZero(800, Easing.OutQuint); - - if (cursorRotate.Value && dragRotationState != DragRotationState.Rotating) + if (State.Value == Visibility.Visible) { - // if cursor is already rotating don't reset its rotate origin - dragRotationState = DragRotationState.DragStarted; - positionMouseDown = e.MousePosition; + // only trigger animation for main mouse buttons + activeCursor.Scale = new Vector2(1); + activeCursor.ScaleTo(0.90f, 800, Easing.OutQuint); + + activeCursor.AdditiveLayer.Alpha = 0; + activeCursor.AdditiveLayer.FadeInFromZero(800, Easing.OutQuint); + + if (cursorRotate.Value && dragRotationState != DragRotationState.Rotating) + { + // if cursor is already rotating don't reset its rotate origin + dragRotationState = DragRotationState.DragStarted; + positionMouseDown = e.MousePosition; + } } return base.OnMouseDown(e); diff --git a/osu.Game/Graphics/DateTooltip.cs b/osu.Game/Graphics/DateTooltip.cs index 3094f9cc2b..d5768b259a 100644 --- a/osu.Game/Graphics/DateTooltip.cs +++ b/osu.Game/Graphics/DateTooltip.cs @@ -65,8 +65,10 @@ namespace osu.Game.Graphics public void SetContent(DateTimeOffset date) { - dateText.Text = $"{date:d MMMM yyyy} "; - timeText.Text = $"{date:HH:mm:ss \"UTC\"z}"; + DateTimeOffset localDate = date.ToLocalTime(); + + dateText.Text = $"{localDate:d MMMM yyyy} "; + timeText.Text = $"{localDate:HH:mm:ss \"UTC\"z}"; } public void Move(Vector2 pos) => Position = pos; diff --git a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs index 36288c745a..3d565a4464 100644 --- a/osu.Game/Graphics/UserInterface/OsuNumberBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuNumberBox.cs @@ -1,10 +1,12 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using osu.Framework.Extensions; + namespace osu.Game.Graphics.UserInterface { public class OsuNumberBox : OsuTextBox { - protected override bool CanAddCharacter(char character) => char.IsNumber(character); + protected override bool CanAddCharacter(char character) => character.IsAsciiDigit(); } } 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 @@ namespace osu.Game.Graphics.UserInterface 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/Graphics/UserInterface/OsuTextBox.cs b/osu.Game/Graphics/UserInterface/OsuTextBox.cs index 96319b9fdd..6db3068d84 100644 --- a/osu.Game/Graphics/UserInterface/OsuTextBox.cs +++ b/osu.Game/Graphics/UserInterface/OsuTextBox.cs @@ -93,7 +93,7 @@ namespace osu.Game.Graphics.UserInterface if (added.Any(char.IsUpper) && AllowUniqueCharacterSamples) capsTextAddedSample?.Play(); else - textAddedSamples[RNG.Next(0, 3)]?.Play(); + playTextAddedSample(); } protected override void OnUserTextRemoved(string removed) @@ -117,6 +117,70 @@ namespace osu.Game.Graphics.UserInterface caretMovedSample?.Play(); } + protected override void OnImeComposition(string newComposition, int removedTextLength, int addedTextLength, bool caretMoved) + { + base.OnImeComposition(newComposition, removedTextLength, addedTextLength, caretMoved); + + if (string.IsNullOrEmpty(newComposition)) + { + switch (removedTextLength) + { + case 0: + // empty composition event, composition wasn't changed, don't play anything. + return; + + case 1: + // composition probably ended by pressing backspace, or was cancelled. + textRemovedSample?.Play(); + return; + + default: + // longer text removed, composition ended because it was cancelled. + // could be a different sample if desired. + textRemovedSample?.Play(); + return; + } + } + + if (addedTextLength > 0) + { + // some text was added, probably due to typing new text or by changing the candidate. + playTextAddedSample(); + return; + } + + if (removedTextLength > 0) + { + // text was probably removed by backspacing. + // it's also possible that a candidate that only removed text was changed to. + textRemovedSample?.Play(); + return; + } + + if (caretMoved) + { + // only the caret/selection was moved. + caretMovedSample?.Play(); + } + } + + protected override void OnImeResult(string result, bool successful) + { + base.OnImeResult(result, successful); + + if (successful) + { + // composition was successfully completed, usually by pressing the enter key. + textCommittedSample?.Play(); + } + else + { + // composition was prematurely ended, eg. by clicking inside the textbox. + // could be a different sample if desired. + textCommittedSample?.Play(); + } + } + protected override void OnFocus(FocusEvent e) { BorderThickness = 3; @@ -142,6 +206,8 @@ namespace osu.Game.Graphics.UserInterface SelectionColour = SelectionColour, }; + private void playTextAddedSample() => textAddedSamples[RNG.Next(0, textAddedSamples.Length)]?.Play(); + private class OsuCaret : Caret { private const float caret_move_time = 60; diff --git a/osu.Game/IO/FileInfo.cs b/osu.Game/IO/FileInfo.cs index 277ad0adac..148afba40d 100644 --- a/osu.Game/IO/FileInfo.cs +++ b/osu.Game/IO/FileInfo.cs @@ -9,6 +9,8 @@ namespace osu.Game.IO { public int ID { get; set; } + public bool IsManaged => ID > 0; + public string Hash { get; set; } public int ReferenceCount { get; set; } diff --git a/osu.Game/IO/IStorageResourceProvider.cs b/osu.Game/IO/IStorageResourceProvider.cs index e4c97e18fa..950b5aae09 100644 --- a/osu.Game/IO/IStorageResourceProvider.cs +++ b/osu.Game/IO/IStorageResourceProvider.cs @@ -4,6 +4,7 @@ using osu.Framework.Audio; using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; +using osu.Game.Database; namespace osu.Game.IO { @@ -24,6 +25,11 @@ namespace osu.Game.IO /// IResourceStore Resources { get; } + /// + /// Access realm. + /// + RealmContextFactory RealmContextFactory { get; } + /// /// Create a texture loader store based on an underlying data store. /// diff --git a/osu.Game/IPC/ArchiveImportIPCChannel.cs b/osu.Game/IPC/ArchiveImportIPCChannel.cs index d9d0e4c0ea..f381aad39a 100644 --- a/osu.Game/IPC/ArchiveImportIPCChannel.cs +++ b/osu.Game/IPC/ArchiveImportIPCChannel.cs @@ -18,6 +18,7 @@ namespace osu.Game.IPC : base(host) { this.importer = importer; + MessageReceived += msg => { Debug.Assert(importer != null); @@ -25,6 +26,8 @@ namespace osu.Game.IPC { if (t.Exception != null) throw t.Exception; }, TaskContinuationOptions.OnlyOnFaulted); + + return null; }; } 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.Allocation; using osu.Framework.Input.Bindings; using osu.Game.Database; using osu.Game.Rulesets; -using Realms; namespace osu.Game.Input.Bindings { @@ -56,7 +55,7 @@ namespace osu.Game.Input.Bindings .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/Input/RealmKeyBindingStore.cs b/osu.Game/Input/RealmKeyBindingStore.cs index 3bdb0a180d..cb51797685 100644 --- a/osu.Game/Input/RealmKeyBindingStore.cs +++ b/osu.Game/Input/RealmKeyBindingStore.cs @@ -81,20 +81,37 @@ namespace osu.Game.Input // compare counts in database vs defaults for each action type. foreach (var defaultsForAction in defaults.GroupBy(k => k.Action)) { - // avoid performing redundant queries when the database is empty and needs to be re-filled. - int existingCount = existingBindings.Count(k => k.RulesetName == rulesetName && k.Variant == variant && k.ActionInt == (int)defaultsForAction.Key); + IEnumerable existing = existingBindings.Where(k => + k.RulesetName == rulesetName + && k.Variant == variant + && k.ActionInt == (int)defaultsForAction.Key); - if (defaultsForAction.Count() <= existingCount) - continue; + int defaultsCount = defaultsForAction.Count(); + int existingCount = existing.Count(); - // insert any defaults which are missing. - realm.Add(defaultsForAction.Skip(existingCount).Select(k => new RealmKeyBinding + if (defaultsCount > existingCount) { - KeyCombinationString = k.KeyCombination.ToString(), - ActionInt = (int)k.Action, - RulesetName = rulesetName, - Variant = variant - })); + // insert any defaults which are missing. + realm.Add(defaultsForAction.Skip(existingCount).Select(k => new RealmKeyBinding + { + KeyCombinationString = k.KeyCombination.ToString(), + ActionInt = (int)k.Action, + RulesetName = rulesetName, + Variant = variant + })); + } + else if (defaultsCount < existingCount) + { + // generally this shouldn't happen, but if the user has more key bindings for an action than we expect, + // remove the last entries until the count matches for sanity. + foreach (var k in existing.TakeLast(existingCount - defaultsCount).ToArray()) + { + realm.Remove(k); + + // Remove from the local flattened/cached list so future lookups don't query now deleted rows. + existingBindings.Remove(k); + } + } } } diff --git a/osu.Game/Localisation/MouseSettingsStrings.cs b/osu.Game/Localisation/MouseSettingsStrings.cs index 5e894c4e0b..fd7225ad2e 100644 --- a/osu.Game/Localisation/MouseSettingsStrings.cs +++ b/osu.Game/Localisation/MouseSettingsStrings.cs @@ -35,9 +35,14 @@ namespace osu.Game.Localisation public static LocalisableString ConfineMouseMode => new TranslatableString(getKey(@"confine_mouse_mode"), @"Confine mouse cursor to window"); /// - /// "Disable mouse wheel during gameplay" + /// "Disable mouse wheel adjusting volume during gameplay" /// - public static LocalisableString DisableMouseWheel => new TranslatableString(getKey(@"disable_mouse_wheel"), @"Disable mouse wheel during gameplay"); + public static LocalisableString DisableMouseWheelVolumeAdjust => new TranslatableString(getKey(@"disable_mouse_wheel_volume_adjust"), @"Disable mouse wheel adjusting volume during gameplay"); + + /// + /// "Volume can still be adjusted using the mouse wheel by holding "Alt"" + /// + public static LocalisableString DisableMouseWheelVolumeAdjustTooltip => new TranslatableString(getKey(@"disable_mouse_wheel_volume_adjust_tooltip"), @"Volume can still be adjusted using the mouse wheel by holding ""Alt"""); /// /// "Disable mouse buttons during gameplay" diff --git a/osu.Game/Models/RealmRuleset.cs b/osu.Game/Models/RealmRuleset.cs index 9a7488fda2..b959d0b4dc 100644 --- a/osu.Game/Models/RealmRuleset.cs +++ b/osu.Game/Models/RealmRuleset.cs @@ -50,6 +50,8 @@ namespace osu.Game.Models public bool Equals(RealmRuleset? other) => other != null && OnlineID == other.OnlineID && Available == other.Available && Name == other.Name && InstantiationInfo == other.InstantiationInfo; + public bool Equals(IRulesetInfo? other) => other is RealmRuleset b && Equals(b); + public override string ToString() => Name; public RealmRuleset Clone() => new RealmRuleset diff --git a/osu.Game/Online/API/APIRequest.cs b/osu.Game/Online/API/APIRequest.cs index 43195811dc..91148c177f 100644 --- a/osu.Game/Online/API/APIRequest.cs +++ b/osu.Game/Online/API/APIRequest.cs @@ -38,7 +38,12 @@ namespace osu.Game.Online.API 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", LoggingTarget.Network); + } } internal void TriggerSuccess(T result) diff --git a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs index 6cd45a41df..671f543422 100644 --- a/osu.Game/Online/API/Requests/GetBeatmapRequest.cs +++ b/osu.Game/Online/API/Requests/GetBeatmapRequest.cs @@ -26,9 +26,12 @@ namespace osu.Game.Online.API.Requests { var request = base.CreateWebRequest(); - request.AddParameter(@"id", beatmapInfo.OnlineID.ToString()); - request.AddParameter(@"checksum", beatmapInfo.MD5Hash); - request.AddParameter(@"filename", filename); + if (beatmapInfo.OnlineID > 0) + request.AddParameter(@"id", beatmapInfo.OnlineID.ToString()); + if (!string.IsNullOrEmpty(beatmapInfo.MD5Hash)) + request.AddParameter(@"checksum", beatmapInfo.MD5Hash); + if (!string.IsNullOrEmpty(filename)) + request.AddParameter(@"filename", filename); return request; } 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/API/Requests/GetUserRequest.cs b/osu.Game/Online/API/Requests/GetUserRequest.cs index e32451fc2f..28da5222f9 100644 --- a/osu.Game/Online/API/Requests/GetUserRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserRequest.cs @@ -9,7 +9,7 @@ namespace osu.Game.Online.API.Requests public class GetUserRequest : APIRequest { public readonly string Lookup; - public readonly RulesetInfo Ruleset; + public readonly IRulesetInfo Ruleset; private readonly LookupType lookupType; /// @@ -24,7 +24,7 @@ namespace osu.Game.Online.API.Requests /// /// The user to get. /// The ruleset to get the user's info for. - public GetUserRequest(long? userId = null, RulesetInfo ruleset = null) + public GetUserRequest(long? userId = null, IRulesetInfo ruleset = null) { Lookup = userId.ToString(); lookupType = LookupType.Id; @@ -36,7 +36,7 @@ namespace osu.Game.Online.API.Requests /// /// The user to get. /// The ruleset to get the user's info for. - public GetUserRequest(string username = null, RulesetInfo ruleset = null) + public GetUserRequest(string username = null, IRulesetInfo ruleset = null) { Lookup = username; lookupType = LookupType.Username; diff --git a/osu.Game/Online/API/Requests/GetUserScoresRequest.cs b/osu.Game/Online/API/Requests/GetUserScoresRequest.cs index e13ac8e539..653abf7427 100644 --- a/osu.Game/Online/API/Requests/GetUserScoresRequest.cs +++ b/osu.Game/Online/API/Requests/GetUserScoresRequest.cs @@ -8,7 +8,7 @@ using osu.Game.Rulesets; namespace osu.Game.Online.API.Requests { - public class GetUserScoresRequest : PaginatedAPIRequest> + public class GetUserScoresRequest : PaginatedAPIRequest> { private readonly long userId; private readonly ScoreType type; diff --git a/osu.Game/Online/API/Requests/Responses/APIScoreInfo.cs b/osu.Game/Online/API/Requests/Responses/APIScore.cs similarity index 96% rename from osu.Game/Online/API/Requests/Responses/APIScoreInfo.cs rename to osu.Game/Online/API/Requests/Responses/APIScore.cs index 467d5a9f23..4f795bee6c 100644 --- a/osu.Game/Online/API/Requests/Responses/APIScoreInfo.cs +++ b/osu.Game/Online/API/Requests/Responses/APIScore.cs @@ -13,10 +13,11 @@ using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Scoring; using osu.Game.Scoring.Legacy; +using osu.Game.Users; namespace osu.Game.Online.API.Requests.Responses { - public class APIScoreInfo : IScoreInfo + public class APIScore : IScoreInfo { [JsonProperty(@"score")] public long TotalScore { get; set; } @@ -101,7 +102,7 @@ namespace osu.Game.Online.API.Requests.Responses BeatmapInfo = beatmap, User = User, Accuracy = Accuracy, - OnlineScoreID = OnlineID, + OnlineID = OnlineID, Date = Date, PP = PP, RulesetID = RulesetID, @@ -150,6 +151,11 @@ namespace osu.Game.Online.API.Requests.Responses public IRulesetInfo Ruleset => new RulesetInfo { OnlineID = RulesetID }; IEnumerable IHasNamedFiles.Files => throw new NotImplementedException(); + #region Implementation of IScoreInfo + IBeatmapInfo IScoreInfo.Beatmap => Beatmap; + IUser IScoreInfo.User => User; + + #endregion } } diff --git a/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs b/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs index 48b7134901..d3c9ba0c7e 100644 --- a/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs +++ b/osu.Game/Online/API/Requests/Responses/APIScoreWithPosition.cs @@ -14,7 +14,7 @@ namespace osu.Game.Online.API.Requests.Responses public int? Position; [JsonProperty(@"score")] - public APIScoreInfo Score; + public APIScore Score; public ScoreInfo CreateScoreInfo(RulesetStore rulesets, BeatmapInfo beatmap = null) { diff --git a/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs b/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs index 5304664bf8..283ebf2411 100644 --- a/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs +++ b/osu.Game/Online/API/Requests/Responses/APIScoresCollection.cs @@ -9,7 +9,7 @@ namespace osu.Game.Online.API.Requests.Responses public class APIScoresCollection { [JsonProperty(@"scores")] - public List Scores; + public List Scores; [JsonProperty(@"userScore")] public APIScoreWithPosition UserScore; diff --git a/osu.Game/Online/Chat/MessageNotifier.cs b/osu.Game/Online/Chat/MessageNotifier.cs index ca6317566f..a11af7b305 100644 --- a/osu.Game/Online/Chat/MessageNotifier.cs +++ b/osu.Game/Online/Chat/MessageNotifier.cs @@ -1,10 +1,10 @@ // 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.Collections.Specialized; using System.Linq; +using System.Text.RegularExpressions; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; @@ -120,16 +120,21 @@ namespace osu.Game.Online.Chat private void checkForMentions(Channel channel, Message message) { - if (!notifyOnUsername.Value || !checkContainsUsername(message.Content, localUser.Value.Username)) return; + if (!notifyOnUsername.Value || !CheckContainsUsername(message.Content, localUser.Value.Username)) return; notifications.Post(new MentionNotification(message.Sender.Username, channel)); } /// - /// Checks if contains . + /// Checks if mentions . /// This will match against the case where underscores are used instead of spaces (which is how osu-stable handles usernames with spaces). /// - private static bool checkContainsUsername(string message, string username) => message.Contains(username, StringComparison.OrdinalIgnoreCase) || message.Contains(username.Replace(' ', '_'), StringComparison.OrdinalIgnoreCase); + public static bool CheckContainsUsername(string message, string username) + { + string fullName = Regex.Escape(username); + string underscoreName = Regex.Escape(username.Replace(' ', '_')); + return Regex.IsMatch(message, $@"(^|\W)({fullName}|{underscoreName})($|\W)", RegexOptions.IgnoreCase); + } public class PrivateMessageNotification : OpenChannelNotification { diff --git a/osu.Game/Online/Leaderboards/LeaderboardScore.cs b/osu.Game/Online/Leaderboards/LeaderboardScore.cs index e01c7c9e49..14eec8b388 100644 --- a/osu.Game/Online/Leaderboards/LeaderboardScore.cs +++ b/osu.Game/Online/Leaderboards/LeaderboardScore.cs @@ -65,9 +65,6 @@ namespace osu.Game.Online.Leaderboards [Resolved(CanBeNull = true)] private SongSelect songSelect { get; set; } - [Resolved] - private ScoreManager scoreManager { get; set; } - [Resolved] private Storage storage { get; set; } @@ -114,7 +111,7 @@ namespace osu.Game.Online.Leaderboards background = new Box { RelativeSizeAxes = Axes.Both, - Colour = user.Id == api.LocalUser.Value.Id && allowHighlight ? colour.Green : Color4.Black, + Colour = user.OnlineID == api.LocalUser.Value.Id && allowHighlight ? colour.Green : Color4.Black, Alpha = background_alpha, }, }, diff --git a/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs b/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs index ab4210251e..3db497bd6a 100644 --- a/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs +++ b/osu.Game/Online/Leaderboards/UserTopScoreContainer.cs @@ -3,13 +3,11 @@ using System; using System.Threading; -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Rulesets; using osuTK; namespace osu.Game.Online.Leaderboards @@ -25,9 +23,6 @@ namespace osu.Game.Online.Leaderboards protected override bool StartHidden => true; - [Resolved] - private RulesetStore rulesets { get; set; } - public UserTopScoreContainer(Func createScoreDelegate) { this.createScoreDelegate = createScoreDelegate; diff --git a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs index 3e84e4b904..073d512f90 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs @@ -77,10 +77,27 @@ namespace osu.Game.Online.Multiplayer /// If an attempt to start the game occurs when the game's (or users') state disallows it. Task StartMatch(); + /// + /// Aborts an ongoing gameplay load. + /// + Task AbortGameplay(); + /// /// Adds an item to the playlist. /// /// The item to add. Task AddPlaylistItem(MultiplayerPlaylistItem item); + + /// + /// Edits an existing playlist item with new values. + /// + /// The item to edit, containing new properties. Must have an ID. + Task EditPlaylistItem(MultiplayerPlaylistItem item); + + /// + /// Removes an item from the playlist. + /// + /// The item to remove. + Task RemovePlaylistItem(long playlistItemId); } } diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index 4c472164d6..903aaa89e3 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -32,12 +32,36 @@ namespace osu.Game.Online.Multiplayer /// public event Action? RoomUpdated; + /// + /// Invoked when a new user joins the room. + /// public event Action? UserJoined; + /// + /// Invoked when a user leaves the room of their own accord. + /// public event Action? UserLeft; + /// + /// Invoked when a user was kicked from the room forcefully. + /// public event Action? UserKicked; + /// + /// Invoked when a new item is added to the playlist. + /// + public event Action? ItemAdded; + + /// + /// Invoked when a playlist item is removed from the playlist. The provided long is the playlist's item ID. + /// + public event Action? ItemRemoved; + + /// + /// Invoked when a playlist item's details change. + /// + public event Action? ItemChanged; + /// /// Invoked when the multiplayer server requests the current beatmap to be loaded into play. /// @@ -71,8 +95,6 @@ namespace osu.Game.Online.Multiplayer protected readonly BindableList PlayingUserIds = new BindableList(); - public readonly Bindable CurrentMatchPlayingItem = new Bindable(); - /// /// The corresponding to the local player, if available. /// @@ -94,7 +116,7 @@ namespace osu.Game.Online.Multiplayer protected IAPIProvider API { get; private set; } = null!; [Resolved] - protected RulesetStore Rulesets { get; private set; } = null!; + protected IRulesetStore Rulesets { get; private set; } = null!; [Resolved] private UserLookupCache userLookupCache { get; set; } = null!; @@ -138,9 +160,6 @@ namespace osu.Game.Online.Multiplayer var joinedRoom = await JoinRoom(room.RoomID.Value.Value, password ?? room.Password.Value).ConfigureAwait(false); Debug.Assert(joinedRoom != null); - // Populate playlist items. - var playlistItems = await Task.WhenAll(joinedRoom.Playlist.Select(createPlaylistItem)).ConfigureAwait(false); - // Populate users. Debug.Assert(joinedRoom.Users != null); await Task.WhenAll(joinedRoom.Users.Select(PopulateUser)).ConfigureAwait(false); @@ -152,7 +171,7 @@ namespace osu.Game.Online.Multiplayer APIRoom = room; APIRoom.Playlist.Clear(); - APIRoom.Playlist.AddRange(playlistItems); + APIRoom.Playlist.AddRange(joinedRoom.Playlist.Select(createPlaylistItem)); Debug.Assert(LocalUser != null); addUserToAPIRoom(LocalUser); @@ -195,7 +214,6 @@ namespace osu.Game.Online.Multiplayer { APIRoom = null; Room = null; - CurrentMatchPlayingItem.Value = null; PlayingUserIds.Clear(); RoomUpdated?.Invoke(); @@ -309,8 +327,14 @@ namespace osu.Game.Online.Multiplayer public abstract Task StartMatch(); + public abstract Task AbortGameplay(); + public abstract Task AddPlaylistItem(MultiplayerPlaylistItem item); + public abstract Task EditPlaylistItem(MultiplayerPlaylistItem item); + + public abstract Task RemovePlaylistItem(long playlistItemId); + Task IMultiplayerClient.RoomStateChanged(MultiplayerRoomState state) { if (Room == null) @@ -446,7 +470,11 @@ namespace osu.Game.Online.Multiplayer Task IMultiplayerClient.SettingsChanged(MultiplayerRoomSettings newSettings) { + Debug.Assert(APIRoom != null); + Debug.Assert(Room != null); + Scheduler.Add(() => updateLocalRoomSettings(newSettings)); + return Task.CompletedTask; } @@ -600,12 +628,10 @@ namespace osu.Game.Online.Multiplayer return Task.CompletedTask; } - public async Task PlaylistItemAdded(MultiplayerPlaylistItem item) + public Task PlaylistItemAdded(MultiplayerPlaylistItem item) { if (Room == null) - return; - - var playlistItem = await createPlaylistItem(item).ConfigureAwait(false); + return Task.CompletedTask; Scheduler.Add(() => { @@ -615,10 +641,13 @@ namespace osu.Game.Online.Multiplayer Debug.Assert(APIRoom != null); Room.Playlist.Add(item); - APIRoom.Playlist.Add(playlistItem); + APIRoom.Playlist.Add(createPlaylistItem(item)); + ItemAdded?.Invoke(item); RoomUpdated?.Invoke(); }); + + return Task.CompletedTask; } public Task PlaylistItemRemoved(long playlistItemId) @@ -636,18 +665,17 @@ namespace osu.Game.Online.Multiplayer Room.Playlist.Remove(Room.Playlist.Single(existing => existing.ID == playlistItemId)); APIRoom.Playlist.RemoveAll(existing => existing.ID == playlistItemId); + ItemRemoved?.Invoke(playlistItemId); RoomUpdated?.Invoke(); }); return Task.CompletedTask; } - public async Task PlaylistItemChanged(MultiplayerPlaylistItem item) + public Task PlaylistItemChanged(MultiplayerPlaylistItem item) { if (Room == null) - return; - - var playlistItem = await createPlaylistItem(item).ConfigureAwait(false); + return Task.CompletedTask; Scheduler.Add(() => { @@ -660,14 +688,13 @@ namespace osu.Game.Online.Multiplayer int existingIndex = APIRoom.Playlist.IndexOf(APIRoom.Playlist.Single(existing => existing.ID == item.ID)); APIRoom.Playlist.RemoveAt(existingIndex); - APIRoom.Playlist.Insert(existingIndex, playlistItem); - - // If the currently-selected item was the one that got replaced, update the selected item to the new one. - if (CurrentMatchPlayingItem.Value?.ID == playlistItem.ID) - CurrentMatchPlayingItem.Value = playlistItem; + APIRoom.Playlist.Insert(existingIndex, createPlaylistItem(item)); + ItemChanged?.Invoke(item); RoomUpdated?.Invoke(); }); + + return Task.CompletedTask; } /// @@ -694,34 +721,29 @@ namespace osu.Game.Online.Multiplayer 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(); - CurrentMatchPlayingItem.Value = APIRoom.Playlist.SingleOrDefault(p => p.ID == settings.PlaylistItemId); + RoomUpdated?.Invoke(); } - private async Task createPlaylistItem(MultiplayerPlaylistItem item) + private PlaylistItem 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 ruleset = Rulesets.GetRuleset(item.RulesetID); + + Debug.Assert(ruleset != null); + var rulesetInstance = ruleset.CreateInstance(); var playlistItem = new PlaylistItem { ID = item.ID, + BeatmapID = item.BeatmapID, OwnerID = item.OwnerID, - Beatmap = { Value = beatmap }, Ruleset = { Value = ruleset }, - Expired = item.Expired + Expired = item.Expired, + PlaylistOrder = item.PlaylistOrder, + PlayedAt = item.PlayedAt }; playlistItem.RequiredMods.AddRange(item.RequiredMods.Select(m => m.ToMod(rulesetInstance))); @@ -731,12 +753,12 @@ namespace osu.Game.Online.Multiplayer } /// - /// 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. + public 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..3794bec228 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -9,8 +9,8 @@ using System.Threading.Tasks; 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 @@ namespace osu.Game.Online.Multiplayer private HubConnection? connection => connector?.CurrentConnection; + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } = null!; + public OnlineMultiplayerClient(EndpointConfiguration endpoints) { endpoint = endpoints.MultiplayerEndpointUrl; @@ -151,6 +154,14 @@ namespace osu.Game.Online.Multiplayer return connection.InvokeAsync(nameof(IMultiplayerServer.StartMatch)); } + public override Task AbortGameplay() + { + if (!IsConnected.Value) + return Task.CompletedTask; + + return connection.InvokeAsync(nameof(IMultiplayerServer.AbortGameplay)); + } + public override Task AddPlaylistItem(MultiplayerPlaylistItem item) { if (!IsConnected.Value) @@ -159,27 +170,25 @@ namespace osu.Game.Online.Multiplayer return connection.InvokeAsync(nameof(IMultiplayerServer.AddPlaylistItem), item); } - protected override Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default) + public override Task EditPlaylistItem(MultiplayerPlaylistItem item) { - var tcs = new TaskCompletionSource(); - var req = new GetBeatmapSetRequest(beatmapId, BeatmapSetLookupType.BeatmapId); + if (!IsConnected.Value) + return Task.CompletedTask; - req.Success += res => - { - if (cancellationToken.IsCancellationRequested) - { - tcs.SetCanceled(); - return; - } + return connection.InvokeAsync(nameof(IMultiplayerServer.EditPlaylistItem), item); + } - tcs.SetResult(res); - }; + public override Task RemovePlaylistItem(long playlistItemId) + { + if (!IsConnected.Value) + return Task.CompletedTask; - req.Failure += e => tcs.SetException(e); + return connection.InvokeAsync(nameof(IMultiplayerServer.RemovePlaylistItem), playlistItemId); + } - API.Queue(req); - - return tcs.Task; + public override Task GetAPIBeatmap(int beatmapId, CancellationToken cancellationToken = default) + { + return beatmapLookupCache.GetBeatmapAsync(beatmapId, cancellationToken); } protected override void Dispose(bool isDisposing) diff --git a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs index 6ca0b822f3..8ec073ff1e 100644 --- a/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs +++ b/osu.Game/Online/Rooms/MultiplayerPlaylistItem.cs @@ -39,6 +39,22 @@ namespace osu.Game.Online.Rooms [Key(7)] public bool Expired { get; set; } + /// + /// The order in which this will be played relative to others. + /// Playlist items should be played in increasing order (lower values are played first). + /// + /// + /// This is only valid for items which are not . The value for expired items is undefined and should not be used. + /// + [Key(8)] + public ushort PlaylistOrder { get; set; } + + /// + /// The date when this was played. + /// + [Key(9)] + public DateTimeOffset? PlayedAt { get; set; } + public MultiplayerPlaylistItem() { } @@ -46,12 +62,15 @@ namespace osu.Game.Online.Rooms public MultiplayerPlaylistItem(PlaylistItem item) { ID = item.ID; + OwnerID = item.OwnerID; BeatmapID = item.BeatmapID; BeatmapChecksum = item.Beatmap.Value?.MD5Hash ?? string.Empty; RulesetID = item.RulesetID; RequiredMods = item.RequiredMods.Select(m => new APIMod(m)).ToArray(); AllowedMods = item.AllowedMods.Select(m => new APIMod(m)).ToArray(); Expired = item.Expired; + PlaylistOrder = item.PlaylistOrder ?? 0; + PlayedAt = item.PlayedAt; } } } diff --git a/osu.Game/Online/Rooms/MultiplayerScore.cs b/osu.Game/Online/Rooms/MultiplayerScore.cs index 7bc3377ad9..05c9a1b6cf 100644 --- a/osu.Game/Online/Rooms/MultiplayerScore.cs +++ b/osu.Game/Online/Rooms/MultiplayerScore.cs @@ -69,7 +69,7 @@ namespace osu.Game.Online.Rooms var scoreInfo = new ScoreInfo { - OnlineScoreID = ID, + OnlineID = ID, TotalScore = TotalScore, MaxCombo = MaxCombo, BeatmapInfo = beatmap, diff --git a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs index aa0e37363b..a32f069470 100644 --- a/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs +++ b/osu.Game/Online/Rooms/OnlinePlayBeatmapAvailabilityTracker.cs @@ -40,6 +40,11 @@ namespace osu.Game.Online.Rooms private BeatmapDownloadTracker downloadTracker; + /// + /// The beatmap matching the required hash (and providing a final state). + /// + private BeatmapInfo matchingHash; + protected override void LoadComplete() { base.LoadComplete(); @@ -71,13 +76,34 @@ namespace osu.Game.Online.Rooms progressUpdate = Scheduler.AddDelayed(updateAvailability, progressUpdate == null ? 0 : 500); }, true); }, true); + + // These events are needed for a fringe case where a modified/altered beatmap is imported with matching OnlineIDs. + // During the import process this will cause the existing beatmap set to be silently deleted and replaced with the new one. + // This is not exposed to us via `BeatmapDownloadTracker` so we have to take it into our own hands (as we care about the hash matching). + beatmapManager.ItemUpdated += itemUpdated; + beatmapManager.ItemRemoved += itemRemoved; } + private void itemUpdated(BeatmapSetInfo item) => Schedule(() => + { + if (matchingHash?.BeatmapSet.ID == item.ID || SelectedItem.Value?.Beatmap.Value.BeatmapSet?.OnlineID == item.OnlineID) + updateAvailability(); + }); + + private void itemRemoved(BeatmapSetInfo item) => Schedule(() => + { + if (matchingHash?.BeatmapSet.ID == item.ID) + updateAvailability(); + }); + private void updateAvailability() { if (downloadTracker == null) return; + // will be repopulated below if still valid. + matchingHash = null; + switch (downloadTracker.State.Value) { case DownloadState.NotDownloaded: @@ -93,7 +119,9 @@ namespace osu.Game.Online.Rooms break; case DownloadState.LocallyAvailable: - bool hashMatches = checkHashValidity(); + matchingHash = findMatchingHash(); + + bool hashMatches = matchingHash != null; availability.Value = hashMatches ? BeatmapAvailability.LocallyAvailable() : BeatmapAvailability.NotDownloaded(); @@ -108,12 +136,23 @@ namespace osu.Game.Online.Rooms } } - private bool checkHashValidity() + private BeatmapInfo findMatchingHash() { int onlineId = SelectedItem.Value.Beatmap.Value.OnlineID; string checksum = SelectedItem.Value.Beatmap.Value.MD5Hash; - return beatmapManager.QueryBeatmap(b => b.OnlineID == onlineId && b.MD5Hash == checksum && !b.BeatmapSet.DeletePending) != null; + return beatmapManager.QueryBeatmap(b => b.OnlineID == onlineId && b.MD5Hash == checksum && !b.BeatmapSet.DeletePending); + } + + protected override void Dispose(bool isDisposing) + { + base.Dispose(isDisposing); + + if (beatmapManager != null) + { + beatmapManager.ItemUpdated -= itemUpdated; + beatmapManager.ItemRemoved -= itemRemoved; + } } } } diff --git a/osu.Game/Online/Rooms/PlaylistItem.cs b/osu.Game/Online/Rooms/PlaylistItem.cs index a1480865b8..b8700fd067 100644 --- a/osu.Game/Online/Rooms/PlaylistItem.cs +++ b/osu.Game/Online/Rooms/PlaylistItem.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.Linq; using Newtonsoft.Json; using osu.Framework.Bindables; @@ -33,6 +34,12 @@ namespace osu.Game.Online.Rooms [JsonProperty("expired")] public bool Expired { get; set; } + [JsonProperty("playlist_order")] + public ushort? PlaylistOrder { get; set; } + + [JsonProperty("played_at")] + public DateTimeOffset? PlayedAt { get; set; } + [JsonIgnore] public IBindable Valid => valid; @@ -79,11 +86,13 @@ namespace osu.Game.Online.Rooms public void MarkInvalid() => valid.Value = false; - public void MapObjects(RulesetStore rulesets) + public void MapObjects(IRulesetStore rulesets) { Beatmap.Value ??= apiBeatmap; Ruleset.Value ??= rulesets.GetRuleset(RulesetID); + Debug.Assert(Ruleset.Value != null); + Ruleset rulesetInstance = Ruleset.Value.CreateInstance(); if (allowedModsBacking != null) diff --git a/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs b/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs index d5da6c401c..e24d113822 100644 --- a/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs +++ b/osu.Game/Online/Rooms/SubmitRoomScoreRequest.cs @@ -31,6 +31,7 @@ namespace osu.Game.Online.Rooms req.ContentType = "application/json"; req.Method = HttpMethod.Put; + req.Timeout = 30000; req.AddRaw(JsonConvert.SerializeObject(score, new JsonSerializerSettings { diff --git a/osu.Game/Online/ScoreDownloadTracker.cs b/osu.Game/Online/ScoreDownloadTracker.cs index e09cc7c9cd..68932cc388 100644 --- a/osu.Game/Online/ScoreDownloadTracker.cs +++ b/osu.Game/Online/ScoreDownloadTracker.cs @@ -3,6 +3,7 @@ using System; using osu.Framework.Allocation; +using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Scoring; @@ -35,7 +36,7 @@ namespace osu.Game.Online var scoreInfo = new ScoreInfo { ID = TrackedItem.ID, - OnlineScoreID = TrackedItem.OnlineScoreID + OnlineID = TrackedItem.OnlineID }; if (Manager.IsAvailableLocally(scoreInfo)) @@ -113,7 +114,7 @@ namespace osu.Game.Online UpdateState(DownloadState.NotDownloaded); }); - private bool checkEquality(IScoreInfo x, IScoreInfo y) => x.OnlineID == y.OnlineID; + private bool checkEquality(IScoreInfo x, IScoreInfo y) => x.MatchesOnlineID(y); #region Disposal diff --git a/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs b/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs index 25c2e5a61f..99cf5ceff5 100644 --- a/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs +++ b/osu.Game/Online/Solo/SubmitSoloScoreRequest.cs @@ -31,6 +31,7 @@ namespace osu.Game.Online.Solo req.ContentType = "application/json"; req.Method = HttpMethod.Put; + req.Timeout = 30000; req.AddRaw(JsonConvert.SerializeObject(score, new JsonSerializerSettings { diff --git a/osu.Game/Online/Solo/SubmittableScore.cs b/osu.Game/Online/Solo/SubmittableScore.cs index 373c302844..5ca5ad9619 100644 --- a/osu.Game/Online/Solo/SubmittableScore.cs +++ b/osu.Game/Online/Solo/SubmittableScore.cs @@ -16,7 +16,7 @@ namespace osu.Game.Online.Solo { /// /// A class specifically for sending scores to the API during score submission. - /// This is used instead of due to marginally different serialisation naming requirements. + /// This is used instead of due to marginally different serialisation naming requirements. /// [Serializable] public class SubmittableScore diff --git a/osu.Game/Online/Spectator/SpectatorClient.cs b/osu.Game/Online/Spectator/SpectatorClient.cs index 6b95d288c5..4da9bace70 100644 --- a/osu.Game/Online/Spectator/SpectatorClient.cs +++ b/osu.Game/Online/Spectator/SpectatorClient.cs @@ -136,30 +136,32 @@ namespace osu.Game.Online.Spectator public void BeginPlaying(GameplayState state, Score score) { - Debug.Assert(ThreadSafety.IsUpdateThread); + // This schedule is only here to match the one below in `EndPlaying`. + Schedule(() => + { + if (IsPlaying) + throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing"); - if (IsPlaying) - throw new InvalidOperationException($"Cannot invoke {nameof(BeginPlaying)} when already playing"); + IsPlaying = true; - IsPlaying = true; + // transfer state at point of beginning play + currentState.BeatmapID = score.ScoreInfo.BeatmapInfo.OnlineID; + currentState.RulesetID = score.ScoreInfo.RulesetID; + currentState.Mods = score.ScoreInfo.Mods.Select(m => new APIMod(m)).ToArray(); - // transfer state at point of beginning play - currentState.BeatmapID = score.ScoreInfo.BeatmapInfo.OnlineID; - currentState.RulesetID = score.ScoreInfo.RulesetID; - currentState.Mods = score.ScoreInfo.Mods.Select(m => new APIMod(m)).ToArray(); + currentBeatmap = state.Beatmap; + currentScore = score; - currentBeatmap = state.Beatmap; - currentScore = score; - - BeginPlayingInternal(currentState); + BeginPlayingInternal(currentState); + }); } public void SendFrames(FrameDataBundle data) => lastSend = SendFramesInternal(data); public void EndPlaying() { - // This method is most commonly called via Dispose(), which is asynchronous. - // Todo: This should not be a thing, but requires framework changes. + // This method is most commonly called via Dispose(), which is can be asynchronous (via the AsyncDisposalQueue). + // We probably need to find a better way to handle this... Schedule(() => { if (!IsPlaying) diff --git a/osu.Game/OsuGame.cs b/osu.Game/OsuGame.cs index 99b67976e3..a4471b56b9 100644 --- a/osu.Game/OsuGame.cs +++ b/osu.Game/OsuGame.cs @@ -103,7 +103,7 @@ namespace osu.Game private Container topMostOverlayContent; - private ScalingContainer screenContainer; + protected ScalingContainer ScreenContainer { get; private set; } protected Container ScreenOffsetContainer { get; private set; } @@ -161,7 +161,7 @@ namespace osu.Game private Bindable uiScale; - private Bindable configSkin; + private Bindable configSkin; private readonly string[] args; @@ -179,7 +179,7 @@ namespace osu.Game } private void updateBlockingOverlayFade() => - screenContainer.FadeColour(visibleBlockingOverlays.Any() ? OsuColour.Gray(0.5f) : Color4.White, 500, Easing.OutQuint); + ScreenContainer.FadeColour(visibleBlockingOverlays.Any() ? OsuColour.Gray(0.5f) : Color4.White, 500, Easing.OutQuint); public void AddBlockingOverlay(OverlayContainer overlay) { @@ -243,27 +243,22 @@ namespace osu.Game Ruleset.ValueChanged += r => configRuleset.Value = r.NewValue.ShortName; // bind config int to database SkinInfo - configSkin = LocalConfig.GetBindable(OsuSetting.Skin); - SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID; + configSkin = LocalConfig.GetBindable(OsuSetting.Skin); + SkinManager.CurrentSkinInfo.ValueChanged += skin => configSkin.Value = skin.NewValue.ID.ToString(); configSkin.ValueChanged += skinId => { - var skinInfo = SkinManager.Query(s => s.ID == skinId.NewValue); + ILive skinInfo = null; + + if (Guid.TryParse(skinId.NewValue, out var guid)) + skinInfo = SkinManager.Query(s => s.ID == guid); if (skinInfo == null) { - switch (skinId.NewValue) - { - case -1: - skinInfo = DefaultLegacySkin.Info; - break; - - default: - skinInfo = SkinInfo.Default; - break; - } + if (guid == SkinInfo.CLASSIC_SKIN) + skinInfo = DefaultLegacySkin.CreateInfo().ToLiveUnmanaged(); } - SkinManager.CurrentSkinInfo.Value = skinInfo; + SkinManager.CurrentSkinInfo.Value = skinInfo ?? DefaultSkin.CreateInfo().ToLiveUnmanaged(); }; configSkin.TriggerChange(); @@ -492,8 +487,8 @@ namespace osu.Game // to ensure all the required data for presenting a replay are present. ScoreInfo databasedScoreInfo = null; - if (score.OnlineScoreID != null) - databasedScoreInfo = ScoreManager.Query(s => s.OnlineScoreID == score.OnlineScoreID); + if (score.OnlineID > 0) + databasedScoreInfo = ScoreManager.Query(s => s.OnlineID == score.OnlineID); databasedScoreInfo ??= ScoreManager.Query(s => s.Hash == score.Hash); @@ -664,7 +659,7 @@ namespace osu.Game // make config aware of how to lookup skins for on-screen display purposes. // if this becomes a more common thing, tracked settings should be reconsidered to allow local DI. - LocalConfig.LookupSkinName = id => SkinManager.GetAllUsableSkins().FirstOrDefault(s => s.ID == id)?.ToString() ?? "Unknown"; + LocalConfig.LookupSkinName = id => SkinManager.Query(s => s.ID == id)?.ToString() ?? "Unknown"; LocalConfig.LookupKeyBindings = l => { @@ -703,7 +698,7 @@ namespace osu.Game RelativeSizeAxes = Axes.Both, Children = new Drawable[] { - screenContainer = new ScalingContainer(ScalingMode.ExcludeOverlays) + ScreenContainer = new ScalingContainer(ScalingMode.ExcludeOverlays) { RelativeSizeAxes = Axes.Both, Anchor = Anchor.Centre, @@ -806,7 +801,7 @@ namespace osu.Game loadComponentSingleFile(userProfile = new UserProfileOverlay(), overlayContent.Add, true); loadComponentSingleFile(beatmapSetOverlay = new BeatmapSetOverlay(), overlayContent.Add, true); loadComponentSingleFile(wikiOverlay = new WikiOverlay(), overlayContent.Add, true); - loadComponentSingleFile(skinEditor = new SkinEditorOverlay(screenContainer), overlayContent.Add, true); + loadComponentSingleFile(skinEditor = new SkinEditorOverlay(ScreenContainer), overlayContent.Add, true); loadComponentSingleFile(new LoginOverlay { @@ -1154,16 +1149,11 @@ namespace osu.Game } } - private void screenPushed(IScreen lastScreen, IScreen newScreen) - { - ScreenChanged(lastScreen, newScreen); - Logger.Log($"Screen changed → {newScreen}"); - } + private void screenPushed(IScreen lastScreen, IScreen newScreen) => ScreenChanged(lastScreen, newScreen); private void screenExited(IScreen lastScreen, IScreen newScreen) { ScreenChanged(lastScreen, newScreen); - Logger.Log($"Screen changed ← {newScreen}"); if (newScreen == null) Exit(); diff --git a/osu.Game/OsuGameBase.cs b/osu.Game/OsuGameBase.cs index 88c9ab370c..2e266e32ff 100644 --- a/osu.Game/OsuGameBase.cs +++ b/osu.Game/OsuGameBase.cs @@ -142,6 +142,7 @@ namespace osu.Game private BeatmapDifficultyCache difficultyCache; private UserLookupCache userCache; + private BeatmapLookupCache beatmapCache; private FileStore fileStore; @@ -195,9 +196,12 @@ namespace osu.Game runMigrations(); dependencies.Cache(RulesetStore = new RulesetStore(contextFactory, Storage)); + dependencies.CacheAs(RulesetStore); dependencies.Cache(realmFactory = new RealmContextFactory(Storage, "client", contextFactory)); + new EFToRealmMigrator(contextFactory, realmFactory, LocalConfig).Run(); + dependencies.CacheAs(Storage); var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore(Resources, @"Textures"))); @@ -211,17 +215,9 @@ namespace osu.Game Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY; - dependencies.Cache(SkinManager = new SkinManager(Storage, contextFactory, Host, Resources, Audio)); + dependencies.Cache(SkinManager = new SkinManager(Storage, realmFactory, Host, Resources, Audio, Scheduler)); dependencies.CacheAs(SkinManager); - // needs to be done here rather than inside SkinManager to ensure thread safety of CurrentSkinInfo. - SkinManager.ItemRemoved += item => Schedule(() => - { - // check the removed skin is not the current user choice. if it is, switch back to default. - if (item.Equals(SkinManager.CurrentSkinInfo.Value)) - SkinManager.CurrentSkinInfo.Value = SkinInfo.Default; - }); - EndpointConfiguration endpoints = UseDevelopmentServer ? (EndpointConfiguration)new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl; @@ -265,6 +261,9 @@ namespace osu.Game 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 +376,13 @@ namespace osu.Game 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 +448,6 @@ namespace osu.Game 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/BeatmapListing/BeatmapListingFilterControl.cs b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs index 38f2bdb34f..f5b4785264 100644 --- a/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs +++ b/osu.Game/Overlays/BeatmapListing/BeatmapListingFilterControl.cs @@ -15,7 +15,6 @@ using osu.Framework.Threading; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets; using osu.Game.Resources.Localisation.Web; using osuTK; using osuTK.Graphics; @@ -61,9 +60,6 @@ namespace osu.Game.Overlays.BeatmapListing [Resolved] private IAPIProvider api { get; set; } - [Resolved] - private RulesetStore rulesets { get; set; } - public BeatmapListingFilterControl() { RelativeSizeAxes = Axes.X; diff --git a/osu.Game/Overlays/BeatmapListingOverlay.cs b/osu.Game/Overlays/BeatmapListingOverlay.cs index 49f2f5c211..6b27dbf847 100644 --- a/osu.Game/Overlays/BeatmapListingOverlay.cs +++ b/osu.Game/Overlays/BeatmapListingOverlay.cs @@ -151,7 +151,8 @@ namespace osu.Game.Overlays } // spawn new children with the contained so we only clear old content at the last moment. - var content = new FillFlowContainer + // reverse ID flow is required for correct Z-ordering of the cards' expandable content (last card should be front-most). + var content = new ReverseChildIDFillFlowContainer { RelativeSizeAxes = Axes.X, AutoSizeAxes = Axes.Y, diff --git a/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs b/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs index a9723c9c62..25aed4c980 100644 --- a/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs +++ b/osu.Game/Overlays/BeatmapSet/LeaderboardModSelector.cs @@ -47,7 +47,7 @@ namespace osu.Game.Overlays.BeatmapSet } [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } private void onRulesetChanged(ValueChangedEvent ruleset) { @@ -57,8 +57,13 @@ namespace osu.Game.Overlays.BeatmapSet if (ruleset.NewValue == null) return; + var rulesetInstance = rulesets.GetRuleset(ruleset.NewValue.OnlineID)?.CreateInstance(); + + if (rulesetInstance == null) + return; + modsContainer.Add(new ModButton(new ModNoMod())); - modsContainer.AddRange(rulesets.GetRuleset(ruleset.NewValue.OnlineID).CreateInstance().AllMods.Where(m => m.UserPlayable).Select(m => new ModButton(m))); + modsContainer.AddRange(rulesetInstance.AllMods.Where(m => m.UserPlayable).Select(m => new ModButton(m))); modsContainer.ForEach(button => { diff --git a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs index 2fcdc9402d..695661d5c9 100644 --- a/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs +++ b/osu.Game/Overlays/BeatmapSet/Scores/ScoresContainer.cs @@ -94,7 +94,7 @@ namespace osu.Game.Overlays.BeatmapSet.Scores topScoresContainer.Add(new DrawableTopScore(topScore)); - if (userScoreInfo != null && userScoreInfo.OnlineScoreID != topScore.OnlineScoreID) + if (userScoreInfo != null && userScoreInfo.OnlineID != topScore.OnlineID) topScoresContainer.Add(new DrawableTopScore(userScoreInfo, userScore.Position)); }), TaskContinuationOptions.OnlyOnRanToCompletion); }); diff --git a/osu.Game/Overlays/BeatmapSetOverlay.cs b/osu.Game/Overlays/BeatmapSetOverlay.cs index fa5a7c66d0..b9d3854066 100644 --- a/osu.Game/Overlays/BeatmapSetOverlay.cs +++ b/osu.Game/Overlays/BeatmapSetOverlay.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; @@ -12,7 +11,6 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Overlays.BeatmapSet; using osu.Game.Overlays.BeatmapSet.Scores; using osu.Game.Overlays.Comments; -using osu.Game.Rulesets; using osuTK; using osuTK.Graphics; @@ -24,9 +22,6 @@ namespace osu.Game.Overlays public const float Y_PADDING = 25; public const float RIGHT_WIDTH = 275; - [Resolved] - private RulesetStore rulesets { get; set; } - private readonly Bindable beatmapSet = new Bindable(); // receive input outside our bounds so we can trigger a close event on ourselves. diff --git a/osu.Game/Overlays/ChatOverlay.cs b/osu.Game/Overlays/ChatOverlay.cs index cc3ce63bf7..72473d5750 100644 --- a/osu.Game/Overlays/ChatOverlay.cs +++ b/osu.Game/Overlays/ChatOverlay.cs @@ -237,10 +237,7 @@ namespace osu.Game.Overlays Schedule(() => { // TODO: consider scheduling bindable callbacks to not perform when overlay is not present. - channelManager.JoinedChannels.CollectionChanged += joinedChannelsChanged; - - foreach (Channel channel in channelManager.JoinedChannels) - ChannelTabControl.AddChannel(channel); + channelManager.JoinedChannels.BindCollectionChanged(joinedChannelsChanged, true); channelManager.AvailableChannels.CollectionChanged += availableChannelsChanged; availableChannelsChanged(null, null); @@ -436,12 +433,19 @@ namespace osu.Game.Overlays { case NotifyCollectionChangedAction.Add: foreach (Channel channel in args.NewItems.Cast()) - ChannelTabControl.AddChannel(channel); + { + if (channel.Type != ChannelType.Multiplayer) + ChannelTabControl.AddChannel(channel); + } + break; case NotifyCollectionChangedAction.Remove: foreach (Channel channel in args.OldItems.Cast()) { + if (!ChannelTabControl.Items.Contains(channel)) + continue; + ChannelTabControl.RemoveChannel(channel); var loaded = loadedChannels.Find(c => c.Channel == channel); diff --git a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs index 269ed81bb5..0844975906 100644 --- a/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs +++ b/osu.Game/Overlays/Dashboard/CurrentlyPlayingDisplay.cs @@ -43,9 +43,6 @@ namespace osu.Game.Overlays.Dashboard }; } - [Resolved] - private IAPIProvider api { get; set; } - [Resolved] private UserLookupCache users { get; set; } diff --git a/osu.Game/Overlays/OSD/TrackedSettingToast.cs b/osu.Game/Overlays/OSD/TrackedSettingToast.cs index 51214fe460..9939ba024e 100644 --- a/osu.Game/Overlays/OSD/TrackedSettingToast.cs +++ b/osu.Game/Overlays/OSD/TrackedSettingToast.cs @@ -5,12 +5,14 @@ using System; using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; +using osu.Framework.Bindables; using osu.Framework.Configuration.Tracking; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Effects; using osu.Framework.Graphics.Shapes; +using osu.Game.Configuration; using osu.Game.Graphics; using osuTK; using osuTK.Graphics; @@ -28,6 +30,8 @@ namespace osu.Game.Overlays.OSD private Sample sampleOff; private Sample sampleChange; + private Bindable lastPlaybackTime; + public TrackedSettingToast(SettingDescription description) : base(description.Name, description.Value, description.Shortcut) { @@ -75,10 +79,28 @@ namespace osu.Game.Overlays.OSD optionLights.Add(new OptionLight { Glowing = i == selectedOption }); } + [Resolved] + private SessionStatics statics { get; set; } + protected override void LoadComplete() { base.LoadComplete(); + playSound(); + } + + private void playSound() + { + // This debounce code roughly follows what we're using in HoverSampleDebounceComponent. + // We're sharing the existing static for hover sounds because it doesn't really matter if they block each other. + // This is a simple solution, but if this ever becomes a problem (or other performance issues arise), + // the whole toast system should be rewritten to avoid recreating this drawable each time a value changes. + lastPlaybackTime = statics.GetBindable(Static.LastHoverSoundPlaybackTime); + + bool enoughTimePassedSinceLastPlayback = !lastPlaybackTime.Value.HasValue || Time.Current - lastPlaybackTime.Value >= OsuGameBase.SAMPLE_DEBOUNCE_TIME; + + if (!enoughTimePassedSinceLastPlayback) return; + if (optionCount == 1) { if (selectedOption == 0) @@ -93,6 +115,8 @@ namespace osu.Game.Overlays.OSD sampleChange.Frequency.Value = 1 + (double)selectedOption / (optionCount - 1) * 0.25f; sampleChange.Play(); } + + lastPlaybackTime.Value = Time.Current; } [BackgroundDependencyLoader] diff --git a/osu.Game/Overlays/OnScreenDisplay.cs b/osu.Game/Overlays/OnScreenDisplay.cs index af6d24fc65..6b3696ced9 100644 --- a/osu.Game/Overlays/OnScreenDisplay.cs +++ b/osu.Game/Overlays/OnScreenDisplay.cs @@ -95,13 +95,13 @@ namespace osu.Game.Overlays /// 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) => Scheduler.AddOnce(Display, new TrackedSettingToast(description)); private TransformSequence fadeIn; private ScheduledDelegate fadeOut; diff --git a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs index d6e515d8a1..d195babcbf 100644 --- a/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs +++ b/osu.Game/Overlays/Profile/Header/Components/RankGraph.cs @@ -42,7 +42,9 @@ namespace osu.Game.Overlays.Profile.Header.Components private void updateStatistics(UserStatistics statistics) { - int[] userRanks = statistics?.RankHistory?.Data; + // checking both IsRanked and RankHistory is required. + // see https://github.com/ppy/osu-web/blob/154ceafba0f35a1dd935df53ec98ae2ea5615f9f/resources/assets/lib/profile-page/rank-chart.tsx#L46 + int[] userRanks = statistics?.IsRanked == true ? statistics.RankHistory?.Data : null; Data = userRanks?.Select((x, index) => new KeyValuePair(index, x)).Where(x => x.Value != 0).ToArray(); } diff --git a/osu.Game/Overlays/Profile/ProfileSection.cs b/osu.Game/Overlays/Profile/ProfileSection.cs index 6223b32814..fc6fce0d8e 100644 --- a/osu.Game/Overlays/Profile/ProfileSection.cs +++ b/osu.Game/Overlays/Profile/ProfileSection.cs @@ -11,6 +11,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Localisation; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; +using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Online.API.Requests.Responses; @@ -22,7 +23,7 @@ namespace osu.Game.Overlays.Profile public abstract string Identifier { get; } - private readonly FillFlowContainer content; + private readonly FillFlowContainer content; private readonly Box background; private readonly Box underscore; @@ -79,7 +80,9 @@ namespace osu.Game.Overlays.Profile } } }, - content = new FillFlowContainer + // reverse ID flow is required for correct Z-ordering of the content (last item should be front-most). + // particularly important in BeatmapsSection, as it uses beatmap cards, which have expandable overhanging content. + content = new ReverseChildIDFillFlowContainer { Direction = FillDirection.Vertical, AutoSizeAxes = Axes.Y, diff --git a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs index affe9ecb0c..9dcbf6142d 100644 --- a/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs +++ b/osu.Game/Overlays/Profile/Sections/PaginatedProfileSubsection.cs @@ -1,21 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osuTK; +using System.Collections.Generic; +using System.Linq; +using System.Threading; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; -using osu.Game.Online.API; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using osu.Game.Graphics.UserInterface; -using osu.Game.Rulesets; -using osu.Game.Graphics.Sprites; -using osu.Game.Graphics; using osu.Framework.Localisation; +using osu.Game.Graphics; +using osu.Game.Graphics.Sprites; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Graphics.Containers; using osu.Game.Online.API.Requests.Responses; +using osuTK; namespace osu.Game.Overlays.Profile.Sections { @@ -24,13 +24,10 @@ namespace osu.Game.Overlays.Profile.Sections [Resolved] private IAPIProvider api { get; set; } - [Resolved] - protected RulesetStore Rulesets { get; private set; } - protected int VisiblePages; protected int ItemsPerPage; - protected FillFlowContainer ItemsContainer { get; private set; } + protected ReverseChildIDFillFlowContainer ItemsContainer { get; private set; } private APIRequest> retrievalRequest; private CancellationTokenSource loadCancellation; @@ -52,11 +49,15 @@ namespace osu.Game.Overlays.Profile.Sections Direction = FillDirection.Vertical, Children = new Drawable[] { - ItemsContainer = new FillFlowContainer + // reverse ID flow is required for correct Z-ordering of the items (last item should be front-most). + // particularly important in PaginatedBeatmapContainer, as it uses beatmap cards, which have expandable overhanging content. + ItemsContainer = new ReverseChildIDFillFlowContainer { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, Spacing = new Vector2(0, 2), + // ensure the container and its contents are in front of the "more" button. + Depth = float.MinValue }, moreButton = new ShowMoreButton { diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs index fb464e1b41..562be0403e 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileScore.cs @@ -28,7 +28,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks private const float performance_background_shear = 0.45f; - protected readonly APIScoreInfo Score; + protected readonly APIScore Score; [Resolved] private OsuColour colours { get; set; } @@ -36,7 +36,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks [Resolved] private OverlayColourProvider colourProvider { get; set; } - public DrawableProfileScore(APIScoreInfo score) + public DrawableProfileScore(APIScore score) { Score = score; diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs index e653be5cfa..78ae0a5634 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/DrawableProfileWeightedScore.cs @@ -16,7 +16,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks { private readonly double weight; - public DrawableProfileWeightedScore(APIScoreInfo score, double weight) + public DrawableProfileWeightedScore(APIScore score, double weight) : base(score) { this.weight = weight; diff --git a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs index c3f10587a9..5532e35cc5 100644 --- a/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs +++ b/osu.Game/Overlays/Profile/Sections/Ranks/PaginatedScoreContainer.cs @@ -15,7 +15,7 @@ using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Overlays.Profile.Sections.Ranks { - public class PaginatedScoreContainer : PaginatedProfileSubsection + public class PaginatedScoreContainer : PaginatedProfileSubsection { private readonly ScoreType type; @@ -51,7 +51,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks } } - protected override void OnItemsReceived(List items) + protected override void OnItemsReceived(List items) { if (VisiblePages == 0) drawableItemIndex = 0; @@ -59,12 +59,12 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks base.OnItemsReceived(items); } - protected override APIRequest> CreateRequest() => + protected override APIRequest> CreateRequest() => new GetUserScoresRequest(User.Value.Id, type, VisiblePages++, ItemsPerPage); private int drawableItemIndex; - protected override Drawable CreateDrawableItem(APIScoreInfo model) + protected override Drawable CreateDrawableItem(APIScore model) { switch (type) { diff --git a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs index cb8dae0bbc..7a27c6e4e1 100644 --- a/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs +++ b/osu.Game/Overlays/Profile/Sections/Recent/DrawableRecentActivity.cs @@ -25,7 +25,7 @@ namespace osu.Game.Overlays.Profile.Sections.Recent private IAPIProvider api { get; set; } [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } private readonly APIRecentActivity activity; diff --git a/osu.Game/Overlays/Rankings/SpotlightsLayout.cs b/osu.Game/Overlays/Rankings/SpotlightsLayout.cs index cc553ad361..bcfc2499b9 100644 --- a/osu.Game/Overlays/Rankings/SpotlightsLayout.cs +++ b/osu.Game/Overlays/Rankings/SpotlightsLayout.cs @@ -1,21 +1,21 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Graphics; -using osu.Framework.Bindables; -using osu.Game.Rulesets; -using osu.Framework.Graphics.Containers; -using osu.Game.Online.API.Requests.Responses; -using osuTK; -using osu.Framework.Allocation; -using osu.Game.Online.API; -using osu.Game.Online.API.Requests; -using osu.Game.Overlays.Rankings.Tables; using System.Linq; using System.Threading; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps.Drawables.Cards; using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Overlays.Rankings.Tables; +using osu.Game.Rulesets; +using osuTK; namespace osu.Game.Overlays.Rankings { @@ -29,9 +29,6 @@ namespace osu.Game.Overlays.Rankings [Resolved] private IAPIProvider api { get; set; } - [Resolved] - private RulesetStore rulesets { get; set; } - private CancellationTokenSource cancellationToken; private GetSpotlightRankingsRequest getRankingsRequest; private GetSpotlightsRequest spotlightsRequest; @@ -138,7 +135,8 @@ namespace osu.Game.Overlays.Rankings Children = new Drawable[] { new ScoresTable(1, response.Users), - new FillFlowContainer + // reverse ID flow is required for correct Z-ordering of the cards' expandable content (last card should be front-most). + new ReverseChildIDFillFlowContainer { AutoSizeAxes = Axes.Y, RelativeSizeAxes = Axes.X, diff --git a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs index 0334167759..4235dc0a05 100644 --- a/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Input/MouseSettings.cs @@ -67,7 +67,8 @@ namespace osu.Game.Overlays.Settings.Sections.Input }, new SettingsCheckbox { - LabelText = MouseSettingsStrings.DisableMouseWheel, + LabelText = MouseSettingsStrings.DisableMouseWheelVolumeAdjust, + TooltipText = MouseSettingsStrings.DisableMouseWheelVolumeAdjustTooltip, Current = osuConfig.GetBindable(OsuSetting.MouseDisableWheel) }, new SettingsCheckbox diff --git a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs index acdf9cdea6..98ccbf85fd 100644 --- a/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs +++ b/osu.Game/Overlays/Settings/Sections/Maintenance/GeneralSettings.cs @@ -106,7 +106,10 @@ namespace osu.Game.Overlays.Settings.Sections.Maintenance dialogOverlay?.Push(new MassDeleteConfirmationDialog(() => { deleteSkinsButton.Enabled.Value = false; - Task.Run(() => skins.Delete(skins.GetAllUserSkins())).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true)); + Task.Run(() => + { + skins.Delete(); + }).ContinueWith(t => Schedule(() => deleteSkinsButton.Enabled.Value = true)); })); } }); diff --git a/osu.Game/Overlays/Settings/Sections/SkinSection.cs b/osu.Game/Overlays/Settings/Sections/SkinSection.cs index 0eb65b4b0f..0fa6d78d4b 100644 --- a/osu.Game/Overlays/Settings/Sections/SkinSection.cs +++ b/osu.Game/Overlays/Settings/Sections/SkinSection.cs @@ -32,32 +32,26 @@ namespace osu.Game.Overlays.Settings.Sections Icon = FontAwesome.Solid.PaintBrush }; - private readonly Bindable dropdownBindable = new Bindable { Default = SkinInfo.Default }; - private readonly Bindable configBindable = new Bindable(); + private readonly Bindable> dropdownBindable = new Bindable> { Default = DefaultSkin.CreateInfo().ToLiveUnmanaged() }; + private readonly Bindable configBindable = new Bindable(); - private static readonly SkinInfo random_skin_info = new SkinInfo + private static readonly ILive random_skin_info = new SkinInfo { ID = SkinInfo.RANDOM_SKIN, Name = "", - }; + }.ToLiveUnmanaged(); - private List skinItems; - - private int firstNonDefaultSkinIndex - { - get - { - int index = skinItems.FindIndex(s => s.ID > 0); - if (index < 0) - index = skinItems.Count; - - return index; - } - } + private List> skinItems; [Resolved] private SkinManager skins { get; set; } + [Resolved] + private RealmContextFactory realmFactory { get; set; } + + private IDisposable realmSubscription; + private IQueryable realmSkins; + [BackgroundDependencyLoader(permitNulls: true)] private void load(OsuConfigManager config, [CanBeNull] SkinEditorOverlay skinEditor) { @@ -75,96 +69,95 @@ namespace osu.Game.Overlays.Settings.Sections new ExportSkinButton(), }; - skins.ItemUpdated += itemUpdated; - skins.ItemRemoved += itemRemoved; - config.BindWith(OsuSetting.Skin, configBindable); + } + + protected override void LoadComplete() + { + base.LoadComplete(); skinDropdown.Current = dropdownBindable; + + realmSkins = realmFactory.Context.All() + .Where(s => !s.DeletePending) + .OrderByDescending(s => s.Protected) // protected skins should be at the top. + .ThenBy(s => s.Name, StringComparer.OrdinalIgnoreCase); + + realmSubscription = realmSkins + .QueryAsyncWithNotifications((sender, changes, error) => + { + if (changes == null) + return; + + // Eventually this should be handling the individual changes rather than refreshing the whole dropdown. + updateItems(); + }); + updateItems(); - // Todo: This should not be necessary when OsuConfigManager is databased - if (skinDropdown.Items.All(s => s.ID != configBindable.Value)) - configBindable.Value = 0; + configBindable.BindValueChanged(id => Scheduler.AddOnce(updateSelectedSkinFromConfig)); + updateSelectedSkinFromConfig(); - configBindable.BindValueChanged(id => Scheduler.AddOnce(updateSelectedSkinFromConfig), true); dropdownBindable.BindValueChanged(skin => { - if (skin.NewValue == random_skin_info) + if (skin.NewValue.Equals(random_skin_info)) { + var skinBefore = skins.CurrentSkinInfo.Value; + skins.SelectRandomSkin(); + + if (skinBefore == skins.CurrentSkinInfo.Value) + { + // the random selection didn't change the skin, so we should manually update the dropdown to match. + dropdownBindable.Value = skins.CurrentSkinInfo.Value; + } + return; } - configBindable.Value = skin.NewValue.ID; + configBindable.Value = skin.NewValue.ID.ToString(); }); } private void updateSelectedSkinFromConfig() { - int id = configBindable.Value; + ILive skin = null; - var skin = skinDropdown.Items.FirstOrDefault(s => s.ID == id); + if (Guid.TryParse(configBindable.Value, out var configId)) + skin = skinDropdown.Items.FirstOrDefault(s => s.ID == configId); - if (skin == null) - { - // there may be a thread race condition where an item is selected that hasn't yet been added to the dropdown. - // to avoid adding complexity, let's just ensure the item is added so we can perform the selection. - skin = skins.Query(s => s.ID == id); - addItem(skin); - } - - dropdownBindable.Value = skin; + dropdownBindable.Value = skin ?? skinDropdown.Items.First(); } private void updateItems() { - skinItems = skins.GetAllUsableSkins(); - skinItems.Insert(firstNonDefaultSkinIndex, random_skin_info); - sortUserSkins(skinItems); + int protectedCount = realmSkins.Count(s => s.Protected); + + skinItems = realmSkins.ToLive(realmFactory); + + skinItems.Insert(protectedCount, random_skin_info); + skinDropdown.Items = skinItems; } - private void itemUpdated(SkinInfo item) => Schedule(() => addItem(item)); - - private void addItem(SkinInfo item) - { - List newDropdownItems = skinDropdown.Items.Where(i => !i.Equals(item)).Append(item).ToList(); - sortUserSkins(newDropdownItems); - skinDropdown.Items = newDropdownItems; - } - - private void itemRemoved(SkinInfo item) => Schedule(() => skinDropdown.Items = skinDropdown.Items.Where(i => !i.Equals(item)).ToArray()); - - private void sortUserSkins(List skinsList) - { - // Sort user skins separately from built-in skins - skinsList.Sort(firstNonDefaultSkinIndex, skinsList.Count - firstNonDefaultSkinIndex, - Comparer.Create((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase))); - } - protected override void Dispose(bool isDisposing) { base.Dispose(isDisposing); - if (skins != null) - { - skins.ItemUpdated -= itemUpdated; - skins.ItemRemoved -= itemRemoved; - } + realmSubscription?.Dispose(); } - private class SkinSettingsDropdown : SettingsDropdown + private class SkinSettingsDropdown : SettingsDropdown> { - protected override OsuDropdown CreateDropdown() => new SkinDropdownControl(); + protected override OsuDropdown> CreateDropdown() => new SkinDropdownControl(); private class SkinDropdownControl : DropdownControl { - protected override LocalisableString GenerateItemText(SkinInfo item) => item.ToString(); + protected override LocalisableString GenerateItemText(ILive item) => item.ToString(); } } - private class ExportSkinButton : SettingsButton + public class ExportSkinButton : SettingsButton { [Resolved] private SkinManager skins { get; set; } @@ -179,16 +172,21 @@ namespace osu.Game.Overlays.Settings.Sections { Text = SkinSettingsStrings.ExportSkinButton; Action = export; + } + + protected override void LoadComplete() + { + base.LoadComplete(); currentSkin = skins.CurrentSkin.GetBoundCopy(); - currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.ID > 0, true); + currentSkin.BindValueChanged(skin => Enabled.Value = skin.NewValue.SkinInfo.PerformRead(s => !s.Protected), true); } private void export() { try { - new LegacySkinExporter(storage).Export(currentSkin.Value.SkinInfo); + currentSkin.Value.SkinInfo.PerformRead(s => new LegacySkinExporter(storage).Export(s)); } catch (Exception e) { diff --git a/osu.Game/Overlays/Settings/SettingsNumberBox.cs b/osu.Game/Overlays/Settings/SettingsNumberBox.cs index cbe9f7fc64..cc4446033a 100644 --- a/osu.Game/Overlays/Settings/SettingsNumberBox.cs +++ b/osu.Game/Overlays/Settings/SettingsNumberBox.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Bindables; +using osu.Framework.Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.UserInterface; @@ -67,7 +68,7 @@ namespace osu.Game.Overlays.Settings private class OutlinedNumberBox : OutlinedTextBox { - protected override bool CanAddCharacter(char character) => char.IsNumber(character); + protected override bool CanAddCharacter(char character) => character.IsAsciiDigit(); public new void NotifyInputError() => base.NotifyInputError(); } diff --git a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs index 6f0b433acb..789ed457a4 100644 --- a/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs +++ b/osu.Game/Overlays/Wiki/Markdown/WikiMarkdownContainer.cs @@ -5,19 +5,14 @@ using System.Linq; using Markdig.Extensions.Yaml; using Markdig.Syntax; using Markdig.Syntax.Inlines; -using osu.Framework.Allocation; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers.Markdown; using osu.Game.Graphics.Containers.Markdown; -using osu.Game.Online.API; namespace osu.Game.Overlays.Wiki.Markdown { public class WikiMarkdownContainer : OsuMarkdownContainer { - [Resolved] - private IAPIProvider api { get; set; } - public string CurrentPath { set => DocumentUrl = value; diff --git a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs index 01b4150030..6b61dd3efb 100644 --- a/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs +++ b/osu.Game/Rulesets/Difficulty/DifficultyCalculator.cs @@ -120,14 +120,14 @@ namespace osu.Game.Rulesets.Difficulty /// Calculates the difficulty of the beatmap using all mod combinations applicable to the beatmap. /// /// A collection of structures describing the difficulty of the beatmap for each mod combination. - public IEnumerable CalculateAll() + public IEnumerable CalculateAll(CancellationToken cancellationToken = default) { foreach (var combination in CreateDifficultyAdjustmentModCombinations()) { if (combination is MultiMod multi) - yield return Calculate(multi.Mods); + yield return Calculate(multi.Mods, cancellationToken); else - yield return Calculate(combination.Yield()); + yield return Calculate(combination.Yield(), cancellationToken); } } @@ -145,7 +145,11 @@ namespace osu.Game.Rulesets.Difficulty { playableMods = mods.Select(m => m.DeepClone()).ToArray(); - Beatmap = beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); + // Only pass through the cancellation token if it's non-default. + // This allows for the default timeout to be applied for playable beatmap construction. + Beatmap = cancellationToken == default + ? beatmap.GetPlayableBeatmap(ruleset, playableMods) + : beatmap.GetPlayableBeatmap(ruleset, playableMods, cancellationToken); var track = new TrackVirtual(10000); playableMods.OfType().ForEach(m => m.ApplyToTrack(track)); diff --git a/osu.Game/Rulesets/IRulesetInfo.cs b/osu.Game/Rulesets/IRulesetInfo.cs index 4e529a73fb..6599e0d59d 100644 --- a/osu.Game/Rulesets/IRulesetInfo.cs +++ b/osu.Game/Rulesets/IRulesetInfo.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using osu.Game.Database; #nullable enable @@ -10,7 +11,7 @@ namespace osu.Game.Rulesets /// /// A representation of a ruleset's metadata. /// - public interface IRulesetInfo : IHasOnlineID + public interface IRulesetInfo : IHasOnlineID, IEquatable { /// /// The user-exposed name of this ruleset. diff --git a/osu.Game/Rulesets/IRulesetStore.cs b/osu.Game/Rulesets/IRulesetStore.cs new file mode 100644 index 0000000000..08d907810b --- /dev/null +++ b/osu.Game/Rulesets/IRulesetStore.cs @@ -0,0 +1,31 @@ +// 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; + +#nullable enable + +namespace osu.Game.Rulesets +{ + public interface IRulesetStore + { + /// + /// Retrieve a ruleset using a known ID. + /// + /// The ruleset's internal ID. + /// A ruleset, if available, else null. + IRulesetInfo? GetRuleset(int id); + + /// + /// Retrieve a ruleset using a known short name. + /// + /// The ruleset's short name. + /// A ruleset, if available, else null. + IRulesetInfo? GetRuleset(string shortName); + + /// + /// All available rulesets. + /// + IEnumerable AvailableRulesets { get; } + } +} diff --git a/osu.Game/Rulesets/Mods/ModCinema.cs b/osu.Game/Rulesets/Mods/ModCinema.cs index c78088ba2d..f28ef1edeb 100644 --- a/osu.Game/Rulesets/Mods/ModCinema.cs +++ b/osu.Game/Rulesets/Mods/ModCinema.cs @@ -17,8 +17,8 @@ namespace osu.Game.Rulesets.Mods drawableRuleset.SetReplayScore(CreateReplayScore(drawableRuleset.Beatmap, drawableRuleset.Mods)); // AlwaysPresent required for hitsounds - drawableRuleset.Playfield.AlwaysPresent = true; - drawableRuleset.Playfield.Hide(); + drawableRuleset.AlwaysPresent = true; + drawableRuleset.Hide(); } } diff --git a/osu.Game/Rulesets/RulesetInfo.cs b/osu.Game/Rulesets/RulesetInfo.cs index 4a146c05bf..d018cc4194 100644 --- a/osu.Game/Rulesets/RulesetInfo.cs +++ b/osu.Game/Rulesets/RulesetInfo.cs @@ -49,6 +49,8 @@ namespace osu.Game.Rulesets public override bool Equals(object obj) => obj is RulesetInfo rulesetInfo && Equals(rulesetInfo); + public bool Equals(IRulesetInfo other) => other is RulesetInfo b && Equals(b); + [SuppressMessage("ReSharper", "NonReadonlyMemberInGetHashCode")] public override int GetHashCode() { diff --git a/osu.Game/Rulesets/RulesetStore.cs b/osu.Game/Rulesets/RulesetStore.cs index 6dd036c0e6..5cc6a75f43 100644 --- a/osu.Game/Rulesets/RulesetStore.cs +++ b/osu.Game/Rulesets/RulesetStore.cs @@ -13,7 +13,7 @@ using osu.Game.Database; namespace osu.Game.Rulesets { - public class RulesetStore : DatabaseBackedStore, IDisposable + public class RulesetStore : DatabaseBackedStore, IRulesetStore, IDisposable { private const string ruleset_library_prefix = "osu.Game.Rulesets"; @@ -236,5 +236,13 @@ namespace osu.Game.Rulesets { AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly; } + + #region Implementation of IRulesetStore + + IRulesetInfo IRulesetStore.GetRuleset(int id) => GetRuleset(id); + IRulesetInfo IRulesetStore.GetRuleset(string shortName) => GetRuleset(shortName); + IEnumerable IRulesetStore.AvailableRulesets => AvailableRulesets; + + #endregion } } diff --git a/osu.Game/Rulesets/UI/Playfield.cs b/osu.Game/Rulesets/UI/Playfield.cs index 52aecb27de..d0bbf859af 100644 --- a/osu.Game/Rulesets/UI/Playfield.cs +++ b/osu.Game/Rulesets/UI/Playfield.cs @@ -19,7 +19,6 @@ using osu.Game.Rulesets.Objects; using osu.Game.Skinning; using osuTK; using System.Diagnostics; -using osu.Framework.Audio.Sample; namespace osu.Game.Rulesets.UI { @@ -88,9 +87,6 @@ namespace osu.Game.Rulesets.UI [Resolved(CanBeNull = true)] private IReadOnlyList mods { get; set; } - [Resolved] - private ISampleStore sampleStore { get; set; } - /// /// Creates a new . /// diff --git a/osu.Game/Scoring/IScoreInfo.cs b/osu.Game/Scoring/IScoreInfo.cs index 8b5b228632..b4ad183cd3 100644 --- a/osu.Game/Scoring/IScoreInfo.cs +++ b/osu.Game/Scoring/IScoreInfo.cs @@ -4,14 +4,14 @@ using System; using osu.Game.Beatmaps; using osu.Game.Database; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; +using osu.Game.Users; namespace osu.Game.Scoring { public interface IScoreInfo : IHasOnlineID, IHasNamedFiles { - APIUser User { get; } + IUser User { get; } long TotalScore { get; } diff --git a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs index f943422389..fefee370b9 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreDecoder.cs @@ -80,12 +80,9 @@ namespace osu.Game.Scoring.Legacy byte[] compressedReplay = sr.ReadByteArray(); if (version >= 20140721) - scoreInfo.OnlineScoreID = sr.ReadInt64(); + scoreInfo.OnlineID = sr.ReadInt64(); else if (version >= 20121008) - scoreInfo.OnlineScoreID = sr.ReadInt32(); - - if (scoreInfo.OnlineScoreID <= 0) - scoreInfo.OnlineScoreID = null; + scoreInfo.OnlineID = sr.ReadInt32(); if (compressedReplay?.Length > 0) { diff --git a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs index 7b8cacb35b..3d67aa9558 100644 --- a/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs +++ b/osu.Game/Scoring/Legacy/LegacyScoreEncoder.cs @@ -46,7 +46,7 @@ namespace osu.Game.Scoring.Legacy sw.Write(LATEST_VERSION); sw.Write(score.ScoreInfo.BeatmapInfo.MD5Hash); sw.Write(score.ScoreInfo.UserString); - sw.Write($"lazer-{score.ScoreInfo.UserString}-{score.ScoreInfo.Date}".ComputeMD5Hash()); + sw.Write(FormattableString.Invariant($"lazer-{score.ScoreInfo.UserString}-{score.ScoreInfo.Date}").ComputeMD5Hash()); sw.Write((ushort)(score.ScoreInfo.GetCount300() ?? 0)); sw.Write((ushort)(score.ScoreInfo.GetCount100() ?? 0)); sw.Write((ushort)(score.ScoreInfo.GetCount50() ?? 0)); @@ -110,7 +110,9 @@ namespace osu.Game.Scoring.Legacy } } - replayData.AppendFormat(@"{0}|{1}|{2}|{3},", -12345, 0, 0, 0); + // Warning: this is purposefully hardcoded as a string rather than interpolating, as in some cultures the minus sign is not encoded as the standard ASCII U+00C2 codepoint, + // which then would break decoding. + replayData.Append(@"-12345|0|0|0"); return replayData.ToString(); } } diff --git a/osu.Game/Scoring/ScoreFileInfo.cs b/osu.Game/Scoring/ScoreFileInfo.cs index b2e81d4b8d..4c88cfa021 100644 --- a/osu.Game/Scoring/ScoreFileInfo.cs +++ b/osu.Game/Scoring/ScoreFileInfo.cs @@ -11,6 +11,8 @@ namespace osu.Game.Scoring { public int ID { get; set; } + public bool IsManaged => ID > 0; + public int FileInfoID { get; set; } public FileInfo FileInfo { get; set; } diff --git a/osu.Game/Scoring/ScoreInfo.cs b/osu.Game/Scoring/ScoreInfo.cs index 564aa3b98c..7acc7bd055 100644 --- a/osu.Game/Scoring/ScoreInfo.cs +++ b/osu.Game/Scoring/ScoreInfo.cs @@ -14,6 +14,7 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Rulesets; using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Scoring; +using osu.Game.Users; using osu.Game.Utils; namespace osu.Game.Scoring @@ -22,6 +23,8 @@ namespace osu.Game.Scoring { public int ID { get; set; } + public bool IsManaged => ID > 0; + public ScoreRank Rank { get; set; } public long TotalScore { get; set; } @@ -134,7 +137,14 @@ namespace osu.Game.Scoring [Column("Beatmap")] public BeatmapInfo BeatmapInfo { get; set; } - public long? OnlineScoreID { get; set; } + private long? onlineID; + + [Column("OnlineScoreID")] + public long? OnlineID + { + get => onlineID; + set => onlineID = value > 0 ? value : null; + } public DateTimeOffset Date { get; set; } @@ -229,24 +239,18 @@ namespace osu.Game.Scoring public bool Equals(ScoreInfo other) { - if (other == null) - return false; + if (ReferenceEquals(this, other)) return true; + if (other == null) return false; if (ID != 0 && other.ID != 0) return ID == other.ID; - if (OnlineScoreID.HasValue && other.OnlineScoreID.HasValue) - return OnlineScoreID == other.OnlineScoreID; - - if (!string.IsNullOrEmpty(Hash) && !string.IsNullOrEmpty(other.Hash)) - return Hash == other.Hash; - - return ReferenceEquals(this, other); + return false; } #region Implementation of IHasOnlineID - public long OnlineID => OnlineScoreID ?? -1; + long IHasOnlineID.OnlineID => OnlineID ?? -1; #endregion @@ -254,6 +258,7 @@ namespace osu.Game.Scoring IBeatmapInfo IScoreInfo.Beatmap => BeatmapInfo; IRulesetInfo IScoreInfo.Ruleset => Ruleset; + IUser IScoreInfo.User => User; bool IScoreInfo.HasReplay => Files.Any(); #endregion diff --git a/osu.Game/Scoring/ScoreManager.cs b/osu.Game/Scoring/ScoreManager.cs index e9cd44ae83..6de6b57066 100644 --- a/osu.Game/Scoring/ScoreManager.cs +++ b/osu.Game/Scoring/ScoreManager.cs @@ -71,7 +71,7 @@ namespace osu.Game.Scoring return scores.Select((score, index) => (score, totalScore: totalScores[index])) .OrderByDescending(g => g.totalScore) - .ThenBy(g => g.score.OnlineScoreID) + .ThenBy(g => g.score.OnlineID) .Select(g => g.score) .ToArray(); } diff --git a/osu.Game/Scoring/ScoreModelDownloader.cs b/osu.Game/Scoring/ScoreModelDownloader.cs index 038a4bc351..514b7a57de 100644 --- a/osu.Game/Scoring/ScoreModelDownloader.cs +++ b/osu.Game/Scoring/ScoreModelDownloader.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using osu.Game.Database; +using osu.Game.Extensions; using osu.Game.Online.API; using osu.Game.Online.API.Requests; @@ -17,6 +18,6 @@ namespace osu.Game.Scoring protected override ArchiveDownloadRequest CreateDownloadRequest(IScoreInfo score, bool minimiseDownload) => new DownloadReplayRequest(score); public override ArchiveDownloadRequest GetExistingDownload(IScoreInfo model) - => CurrentDownloads.Find(r => r.Model.OnlineID == model.OnlineID); + => CurrentDownloads.Find(r => r.Model.MatchesOnlineID(model)); } } diff --git a/osu.Game/Scoring/ScoreModelManager.cs b/osu.Game/Scoring/ScoreModelManager.cs index 2cbd3aded7..44f0fe4fdf 100644 --- a/osu.Game/Scoring/ScoreModelManager.cs +++ b/osu.Game/Scoring/ScoreModelManager.cs @@ -66,6 +66,6 @@ namespace osu.Game.Scoring protected override bool CheckLocalAvailability(ScoreInfo model, IQueryable items) => base.CheckLocalAvailability(model, items) - || (model.OnlineScoreID != null && items.Any(i => i.OnlineScoreID == model.OnlineScoreID)); + || (model.OnlineID > 0 && items.Any(i => i.OnlineID == model.OnlineID)); } } diff --git a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs index 4a922c45b9..452f033dcc 100644 --- a/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs +++ b/osu.Game/Screens/Backgrounds/BackgroundScreenDefault.cs @@ -48,16 +48,19 @@ namespace osu.Game.Screens.Backgrounds AddInternal(seasonalBackgroundLoader); - user.ValueChanged += _ => Next(); - skin.ValueChanged += _ => Next(); - mode.ValueChanged += _ => Next(); - beatmap.ValueChanged += _ => Next(); - introSequence.ValueChanged += _ => Next(); - seasonalBackgroundLoader.SeasonalBackgroundChanged += () => Next(); + user.ValueChanged += _ => Scheduler.AddOnce(loadNextIfRequired); + skin.ValueChanged += _ => Scheduler.AddOnce(loadNextIfRequired); + mode.ValueChanged += _ => Scheduler.AddOnce(loadNextIfRequired); + beatmap.ValueChanged += _ => Scheduler.AddOnce(loadNextIfRequired); + introSequence.ValueChanged += _ => Scheduler.AddOnce(loadNextIfRequired); + seasonalBackgroundLoader.SeasonalBackgroundChanged += () => Scheduler.AddOnce(loadNextIfRequired); currentDisplay = RNG.Next(0, background_count); Next(); + + // helper function required for AddOnce usage. + void loadNextIfRequired() => Next(); } private ScheduledDelegate nextTask; @@ -67,7 +70,7 @@ namespace osu.Game.Screens.Backgrounds /// Request loading the next background. /// /// Whether a new background was queued for load. May return false if the current background is still valid. - public bool Next() + public virtual bool Next() { var nextBackground = createBackground(); diff --git a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs index 4629f9b540..f0e643f805 100644 --- a/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs +++ b/osu.Game/Screens/Edit/Components/Timelines/Summary/Parts/GroupVisualisation.cs @@ -1,20 +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 osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics; namespace osu.Game.Screens.Edit.Components.Timelines.Summary.Parts { public class GroupVisualisation : CompositeDrawable { - [Resolved] - private OsuColour colours { get; set; } - public readonly ControlPointGroup Group; private readonly IBindableList controlPoints = new BindableList(); diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs index b8fa05e7eb..265f56534f 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/Timeline.cs @@ -279,9 +279,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline editorClock.Start(); } - [Resolved] - private EditorBeatmap beatmap { get; set; } - [Resolved] private IBeatSnapProvider beatSnapProvider { get; set; } diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs index 2b2e66fb18..9610f6424c 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineControlPointGroup.cs @@ -1,12 +1,10 @@ // 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.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Beatmaps.ControlPoints; -using osu.Game.Graphics; namespace osu.Game.Screens.Edit.Compose.Components.Timeline { @@ -16,9 +14,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private readonly IBindableList controlPoints = new BindableList(); - [Resolved] - private OsuColour colours { get; set; } - public TimelineControlPointGroup(ControlPointGroup group) { Group = group; diff --git a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs index 80aa6972b1..1839b0507d 100644 --- a/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs +++ b/osu.Game/Screens/Edit/Compose/Components/Timeline/TimelineHitObjectBlueprint.cs @@ -184,9 +184,6 @@ namespace osu.Game.Screens.Edit.Compose.Components.Timeline private SamplePointPiece sampleOverrideDisplay; private DifficultyPointPiece difficultyOverrideDisplay; - [Resolved] - private EditorBeatmap beatmap { get; set; } - private DifficultyControlPoint difficultyControlPoint; private SampleControlPoint sampleControlPoint; diff --git a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs index 3b02d42b41..9386538a78 100644 --- a/osu.Game/Screens/Edit/Compose/ComposeScreen.cs +++ b/osu.Game/Screens/Edit/Compose/ComposeScreen.cs @@ -83,7 +83,9 @@ namespace osu.Game.Screens.Edit.Compose { base.LoadComplete(); EditorBeatmap.SelectedHitObjects.BindCollectionChanged((_, __) => updateClipboardActionAvailability()); - clipboard.BindValueChanged(_ => updateClipboardActionAvailability(), true); + clipboard.BindValueChanged(_ => updateClipboardActionAvailability()); + composer.OnLoadComplete += _ => updateClipboardActionAvailability(); + updateClipboardActionAvailability(); } #region Clipboard operations @@ -131,7 +133,7 @@ namespace osu.Game.Screens.Edit.Compose private void updateClipboardActionAvailability() { CanCut.Value = CanCopy.Value = EditorBeatmap.SelectedHitObjects.Any(); - CanPaste.Value = !string.IsNullOrEmpty(clipboard.Value); + CanPaste.Value = composer.IsLoaded && !string.IsNullOrEmpty(clipboard.Value); } private string formatSelectionAsString() diff --git a/osu.Game/Screens/Edit/Editor.cs b/osu.Game/Screens/Edit/Editor.cs index ac71298f36..48489c60ab 100644 --- a/osu.Game/Screens/Edit/Editor.cs +++ b/osu.Game/Screens/Edit/Editor.cs @@ -109,9 +109,6 @@ namespace osu.Game.Screens.Edit [Resolved] private IAPIProvider api { get; set; } - [Resolved] - private MusicController music { get; set; } - [Cached] public readonly EditorClipboard Clipboard = new EditorClipboard(); diff --git a/osu.Game/Screens/Edit/EditorLoader.cs b/osu.Game/Screens/Edit/EditorLoader.cs index 2a01a5b6b2..15d70e28b6 100644 --- a/osu.Game/Screens/Edit/EditorLoader.cs +++ b/osu.Game/Screens/Edit/EditorLoader.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Graphics; @@ -9,6 +10,7 @@ using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Graphics.UserInterface; +using osu.Game.Rulesets.Mods; using osu.Game.Screens.Menu; using osu.Game.Screens.Play; @@ -53,6 +55,14 @@ namespace osu.Game.Screens.Edit }); } + protected override void LoadComplete() + { + base.LoadComplete(); + + // will be restored via lease, see `DisallowExternalBeatmapRulesetChanges`. + Mods.Value = Array.Empty(); + } + protected virtual Editor CreateEditor() => new Editor(this); protected override void LogoArriving(OsuLogo logo, bool resuming) diff --git a/osu.Game/Screens/Edit/EditorRoundedScreen.cs b/osu.Game/Screens/Edit/EditorRoundedScreen.cs index 7f7b3abc2a..62f40f0325 100644 --- a/osu.Game/Screens/Edit/EditorRoundedScreen.cs +++ b/osu.Game/Screens/Edit/EditorRoundedScreen.cs @@ -5,7 +5,6 @@ using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; -using osu.Game.Graphics; using osu.Game.Overlays; namespace osu.Game.Screens.Edit @@ -14,9 +13,6 @@ namespace osu.Game.Screens.Edit { public const int HORIZONTAL_PADDING = 100; - [Resolved] - private OsuColour colours { get; set; } - private Container roundedContent; protected override Container Content => roundedContent; diff --git a/osu.Game/Screens/Edit/EditorTable.cs b/osu.Game/Screens/Edit/EditorTable.cs index ab8bd6a3bc..a67a060134 100644 --- a/osu.Game/Screens/Edit/EditorTable.cs +++ b/osu.Game/Screens/Edit/EditorTable.cs @@ -62,9 +62,6 @@ namespace osu.Game.Screens.Edit private readonly Box hoveredBackground; - [Resolved] - private EditorClock clock { get; set; } - public RowBackground(object item) { Item = item; diff --git a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs index f833bc49f7..d1e35ae20d 100644 --- a/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs +++ b/osu.Game/Screens/Edit/Setup/FileChooserLabelledTextBox.cs @@ -15,7 +15,6 @@ using osu.Framework.Graphics.Cursor; using osu.Framework.Graphics.UserInterface; using osu.Framework.Input.Events; using osu.Game.Database; -using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterfaceV2; using osuTK; @@ -36,9 +35,6 @@ namespace osu.Game.Screens.Edit.Setup [Resolved] private OsuGameBase game { get; set; } - [Resolved] - private SectionsContainer sectionsContainer { get; set; } - public FileChooserLabelledTextBox(params string[] handledExtensions) { this.handledExtensions = handledExtensions; diff --git a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs index 7a98cf63c3..1e6899e05f 100644 --- a/osu.Game/Screens/Edit/Timing/ControlPointTable.cs +++ b/osu.Game/Screens/Edit/Timing/ControlPointTable.cs @@ -132,9 +132,6 @@ namespace osu.Game.Screens.Edit.Timing controlPoints.BindTo(group.ControlPoints); } - [Resolved] - private OsuColour colours { get; set; } - [BackgroundDependencyLoader] private void load() { diff --git a/osu.Game/Screens/Edit/Verify/IssueList.cs b/osu.Game/Screens/Edit/Verify/IssueList.cs index fd238feeac..cadcdebc6e 100644 --- a/osu.Game/Screens/Edit/Verify/IssueList.cs +++ b/osu.Game/Screens/Edit/Verify/IssueList.cs @@ -23,9 +23,6 @@ namespace osu.Game.Screens.Edit.Verify { private IssueTable table; - [Resolved] - private EditorClock clock { get; set; } - [Resolved] private IBindable workingBeatmap { get; set; } diff --git a/osu.Game/Screens/Menu/StorageErrorDialog.cs b/osu.Game/Screens/Menu/StorageErrorDialog.cs index dcaad4013a..250623ec68 100644 --- a/osu.Game/Screens/Menu/StorageErrorDialog.cs +++ b/osu.Game/Screens/Menu/StorageErrorDialog.cs @@ -15,9 +15,6 @@ namespace osu.Game.Screens.Menu [Resolved] private DialogOverlay dialogOverlay { get; set; } - [Resolved] - private OsuGameBase osuGame { get; set; } - public StorageErrorDialog(OsuStorage storage, OsuStorageError error) { HeaderText = "osu! storage error"; diff --git a/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs b/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs index b013cbafd8..89842e933b 100644 --- a/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs +++ b/osu.Game/Screens/OnlinePlay/Components/MatchBeatmapDetailArea.cs @@ -9,6 +9,7 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online.Rooms; +using osu.Game.Screens.OnlinePlay.Playlists; using osu.Game.Screens.Select; using osuTK; @@ -43,9 +44,9 @@ namespace osu.Game.Screens.OnlinePlay.Components { RelativeSizeAxes = Axes.Both, Padding = new MarginPadding { Bottom = 10 }, - Child = playlist = new DrawableRoomPlaylist(true, false) + Child = playlist = new PlaylistsRoomSettingsPlaylist { - RelativeSizeAxes = Axes.Both, + RelativeSizeAxes = Axes.Both } } }, diff --git a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs index ddfdab18f7..238aa4059d 100644 --- a/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs +++ b/osu.Game/Screens/OnlinePlay/Components/RoomManager.cs @@ -7,9 +7,9 @@ using System.Diagnostics; using System.Linq; using osu.Framework.Allocation; using osu.Framework.Bindables; +using osu.Framework.Development; using osu.Framework.Graphics; using osu.Framework.Logging; -using osu.Game.Beatmaps; using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Rulesets; @@ -28,10 +28,7 @@ namespace osu.Game.Screens.OnlinePlay.Components private readonly Bindable joinedRoom = new Bindable(); [Resolved] - private RulesetStore rulesets { get; set; } - - [Resolved] - private BeatmapManager beatmaps { get; set; } + private IRulesetStore rulesets { get; set; } [Resolved] private IAPIProvider api { get; set; } @@ -111,6 +108,7 @@ namespace osu.Game.Screens.OnlinePlay.Components public void AddOrUpdateRoom(Room room) { + Debug.Assert(ThreadSafety.IsUpdateThread); Debug.Assert(room.RoomID.Value != null); if (ignoredRooms.Contains(room.RoomID.Value.Value)) @@ -140,12 +138,16 @@ namespace osu.Game.Screens.OnlinePlay.Components public void RemoveRoom(Room room) { + Debug.Assert(ThreadSafety.IsUpdateThread); + rooms.Remove(room); notifyRoomsUpdated(); } public void ClearRooms() { + Debug.Assert(ThreadSafety.IsUpdateThread); + rooms.Clear(); notifyRoomsUpdated(); } diff --git a/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs b/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs index b9d2bdf23e..22842fbb9e 100644 --- a/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs +++ b/osu.Game/Screens/OnlinePlay/Components/SelectionPollingComponent.cs @@ -2,7 +2,6 @@ // See the LICENCE file in the repository root for full licence text. using System.Threading.Tasks; -using osu.Framework.Allocation; using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Components @@ -12,9 +11,6 @@ namespace osu.Game.Screens.OnlinePlay.Components /// public class SelectionPollingComponent : RoomPollingComponent { - [Resolved] - private IRoomManager roomManager { get; set; } - private readonly Room room; public SelectionPollingComponent(Room room) diff --git a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs index fc029543bb..edf9c5d155 100644 --- a/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs +++ b/osu.Game/Screens/OnlinePlay/Components/StarRatingRangeDisplay.cs @@ -80,7 +80,7 @@ namespace osu.Game.Screens.OnlinePlay.Components private void updateRange(object sender, NotifyCollectionChangedEventArgs e) { - var orderedDifficulties = Playlist.Select(p => p.Beatmap.Value).OrderBy(b => b.StarRating).ToArray(); + var orderedDifficulties = Playlist.Where(p => p.Beatmap.Value != null).Select(p => p.Beatmap.Value).OrderBy(b => b.StarRating).ToArray(); StarDifficulty minDifficulty = new StarDifficulty(orderedDifficulties.Length > 0 ? orderedDifficulties[0].StarRating : 0, 0); StarDifficulty maxDifficulty = new StarDifficulty(orderedDifficulties.Length > 0 ? orderedDifficulties[^1].StarRating : 0, 0); diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs index 6deca0482a..57bb4253cb 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylist.cs @@ -1,11 +1,9 @@ // 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.Collections.Specialized; +using System; using System.Linq; using osu.Framework.Bindables; -using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics.Containers; @@ -14,38 +12,136 @@ using osuTK; namespace osu.Game.Screens.OnlinePlay { + /// + /// A scrollable list which displays the s in a . + /// public class DrawableRoomPlaylist : OsuRearrangeableListContainer { + /// + /// The currently-selected item. Selection is visually represented with a border. + /// May be updated by clicking playlist items if is true. + /// public readonly Bindable SelectedItem = new Bindable(); - private readonly bool allowEdit; - private readonly bool allowSelection; - private readonly bool showItemOwner; + /// + /// Invoked when an item is requested to be deleted. + /// + public Action RequestDeletion; - public DrawableRoomPlaylist(bool allowEdit, bool allowSelection, bool reverse = false, bool showItemOwner = false) + /// + /// Invoked when an item requests its results to be shown. + /// + public Action RequestResults; + + /// + /// Invoked when an item requests to be edited. + /// + public Action RequestEdit; + + private bool allowReordering; + + /// + /// Whether to allow reordering items in the playlist. + /// + public bool AllowReordering { - this.allowEdit = allowEdit; - this.allowSelection = allowSelection; - this.showItemOwner = showItemOwner; + get => allowReordering; + set + { + allowReordering = value; - ((ReversibleFillFlowContainer)ListContainer).Reverse = reverse; + foreach (var item in ListContainer.OfType()) + item.AllowReordering = value; + } } - protected override void LoadComplete() - { - base.LoadComplete(); + private bool allowDeletion; - // Scheduled since items are removed and re-added upon rearrangement - Items.CollectionChanged += (_, args) => Schedule(() => + /// + /// Whether to allow deleting items from the playlist. + /// If true, requests to delete items may be satisfied via . + /// + public bool AllowDeletion + { + get => allowDeletion; + set { - switch (args.Action) - { - case NotifyCollectionChangedAction.Remove: - if (allowSelection && args.OldItems.Contains(SelectedItem)) - SelectedItem.Value = null; - break; - } - }); + allowDeletion = value; + + foreach (var item in ListContainer.OfType()) + item.AllowDeletion = value; + } + } + + private bool allowSelection; + + /// + /// Whether to allow selecting items from the playlist. + /// If true, clicking on items in the playlist will change the value of . + /// + public bool AllowSelection + { + get => allowSelection; + set + { + allowSelection = value; + + foreach (var item in ListContainer.OfType()) + item.AllowSelection = value; + } + } + + private bool allowShowingResults; + + /// + /// Whether to allow items to request their results to be shown. + /// If true, requests to show the results may be satisfied via . + /// + public bool AllowShowingResults + { + get => allowShowingResults; + set + { + allowShowingResults = value; + + foreach (var item in ListContainer.OfType()) + item.AllowShowingResults = value; + } + } + + private bool allowEditing; + + /// + /// Whether to allow items to be edited. + /// If true, requests to edit items may be satisfied via . + /// + public bool AllowEditing + { + get => allowEditing; + set + { + allowEditing = value; + + foreach (var item in ListContainer.OfType()) + item.AllowEditing = value; + } + } + + private bool showItemOwners; + + /// + /// Whether to show the avatar of users which own each playlist item. + /// + public bool ShowItemOwners + { + get => showItemOwners; + set + { + showItemOwners = value; + + foreach (var item in ListContainer.OfType()) + item.ShowItemOwner = value; + } } protected override ScrollContainer CreateScrollContainer() => base.CreateScrollContainer().With(d => @@ -53,45 +149,25 @@ namespace osu.Game.Screens.OnlinePlay d.ScrollbarVisible = false; }); - protected override FillFlowContainer> CreateListFillFlowContainer() => new ReversibleFillFlowContainer + protected override FillFlowContainer> CreateListFillFlowContainer() => new FillFlowContainer> { Spacing = new Vector2(0, 2) }; - protected override OsuRearrangeableListItem CreateOsuDrawable(PlaylistItem item) => new DrawableRoomPlaylistItem(item, allowEdit, allowSelection, showItemOwner) + protected sealed override OsuRearrangeableListItem CreateOsuDrawable(PlaylistItem item) => CreateDrawablePlaylistItem(item).With(d => { - SelectedItem = { BindTarget = SelectedItem }, - RequestDeletion = requestDeletion - }; + d.SelectedItem.BindTarget = SelectedItem; + d.RequestDeletion = i => RequestDeletion?.Invoke(i); + d.RequestResults = i => RequestResults?.Invoke(i); + d.RequestEdit = i => RequestEdit?.Invoke(i); + d.AllowReordering = AllowReordering; + d.AllowDeletion = AllowDeletion; + d.AllowSelection = AllowSelection; + d.AllowShowingResults = AllowShowingResults; + d.AllowEditing = AllowEditing; + d.ShowItemOwner = ShowItemOwners; + }); - private void requestDeletion(PlaylistItem item) - { - if (allowSelection && SelectedItem.Value == item) - { - if (Items.Count == 1) - SelectedItem.Value = null; - else - SelectedItem.Value = Items.GetNext(item) ?? Items[^2]; - } - - Items.Remove(item); - } - - private class ReversibleFillFlowContainer : FillFlowContainer> - { - private bool reverse; - - public bool Reverse - { - get => reverse; - set - { - reverse = value; - Invalidate(); - } - } - - public override IEnumerable FlowingChildren => Reverse ? base.FlowingChildren.OrderBy(d => -GetLayoutPosition(d)) : base.FlowingChildren; - } + protected virtual DrawableRoomPlaylistItem CreateDrawablePlaylistItem(PlaylistItem item) => new DrawableRoomPlaylistItem(item); } } diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs index 2dbe2df82c..e1f7ea5e92 100644 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs +++ b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistItem.cs @@ -5,9 +5,9 @@ using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; -using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; using osu.Framework.Graphics.Containers; @@ -16,6 +16,7 @@ using osu.Framework.Graphics.Shapes; using osu.Framework.Graphics.Sprites; using osu.Framework.Input.Events; using osu.Framework.Localisation; +using osu.Framework.Logging; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Drawables; using osu.Game.Database; @@ -24,6 +25,7 @@ using osu.Game.Graphics.Containers; using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Online.Chat; +using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Overlays.BeatmapSet; using osu.Game.Rulesets; @@ -38,27 +40,51 @@ namespace osu.Game.Screens.OnlinePlay public class DrawableRoomPlaylistItem : OsuRearrangeableListItem { public const float HEIGHT = 50; - public const float ICON_HEIGHT = 34; + private const float icon_height = 34; + + /// + /// Invoked when this item requests to be deleted. + /// public Action RequestDeletion; + /// + /// Invoked when this item requests its results to be shown. + /// + public Action RequestResults; + + /// + /// Invoked when this item requests to be edited. + /// + public Action RequestEdit; + + /// + /// The currently-selected item, used to show a border around this item. + /// May be updated by this item if is true. + /// public readonly Bindable SelectedItem = new Bindable(); + public readonly PlaylistItem Item; + + private readonly DelayedLoadWrapper onScreenLoader = new DelayedLoadWrapper(Empty) { RelativeSizeAxes = Axes.Both }; + private readonly IBindable valid = new Bindable(); + private readonly Bindable beatmap = new Bindable(); + private readonly Bindable ruleset = new Bindable(); + private readonly BindableList requiredMods = new BindableList(); + private Container maskingContainer; private Container difficultyIconContainer; private LinkFlowContainer beatmapText; private LinkFlowContainer authorText; private ExplicitContentBeatmapPill explicitContentPill; private ModDisplay modDisplay; + private FillFlowContainer buttonsFlow; private UpdateableAvatar ownerAvatar; - - private readonly IBindable valid = new Bindable(); - - private readonly Bindable beatmap = new Bindable(); - private readonly Bindable ruleset = new Bindable(); - private readonly BindableList requiredMods = new BindableList(); - - public readonly PlaylistItem Item; + private Drawable showResultsButton; + private Drawable editButton; + private Drawable removeButton; + private PanelBackground panelBackground; + private FillFlowContainer mainFillFlow; [Resolved] private OsuColour colours { get; set; } @@ -66,29 +92,25 @@ namespace osu.Game.Screens.OnlinePlay [Resolved] private UserLookupCache userLookupCache { get; set; } - private readonly bool allowEdit; - private readonly bool allowSelection; - private readonly bool showItemOwner; + [CanBeNull] + [Resolved(CanBeNull = true)] + private MultiplayerClient multiplayerClient { get; set; } - protected override bool ShouldBeConsideredForInput(Drawable child) => allowEdit || !allowSelection || SelectedItem.Value == Model; + [Resolved] + private BeatmapLookupCache beatmapLookupCache { get; set; } - public DrawableRoomPlaylistItem(PlaylistItem item, bool allowEdit, bool allowSelection, bool showItemOwner) + protected override bool ShouldBeConsideredForInput(Drawable child) => AllowReordering || AllowDeletion || !AllowSelection || SelectedItem.Value == Model; + + public DrawableRoomPlaylistItem(PlaylistItem item) : base(item) { Item = item; - // TODO: edit support should be moved out into a derived class - this.allowEdit = allowEdit; - this.allowSelection = allowSelection; - this.showItemOwner = showItemOwner; - beatmap.BindTo(item.Beatmap); valid.BindTo(item.Valid); ruleset.BindTo(item.Ruleset); requiredMods.BindTo(item.RequiredMods); - ShowDragHandle.Value = allowEdit; - if (item.Expired) Colour = OsuColour.Gray(0.5f); } @@ -96,9 +118,6 @@ namespace osu.Game.Screens.OnlinePlay [BackgroundDependencyLoader] private void load() { - if (!allowEdit) - HandleColour = HandleColour.Opacity(0); - maskingContainer.BorderColour = colours.Yellow; } @@ -130,10 +149,123 @@ namespace osu.Game.Screens.OnlinePlay valid.BindValueChanged(_ => Scheduler.AddOnce(refresh)); requiredMods.CollectionChanged += (_, __) => Scheduler.AddOnce(refresh); + onScreenLoader.DelayedLoadStarted += _ => + { + Task.Run(async () => + { + try + { + if (showItemOwner) + { + var foundUser = await userLookupCache.GetUserAsync(Item.OwnerID).ConfigureAwait(false); + Schedule(() => ownerAvatar.User = foundUser); + } + + if (Item.Beatmap.Value == null) + { + IBeatmapInfo foundBeatmap; + + if (multiplayerClient != null) + // This call can eventually go away (and use the else case below). + // Currently required only due to the method being overridden to provide special behaviour in tests. + foundBeatmap = await multiplayerClient.GetAPIBeatmap(Item.BeatmapID).ConfigureAwait(false); + else + foundBeatmap = await beatmapLookupCache.GetBeatmapAsync(Item.BeatmapID).ConfigureAwait(false); + + Schedule(() => Item.Beatmap.Value = foundBeatmap); + } + } + catch (Exception e) + { + Logger.Log($"Error while populating playlist item {e}"); + } + }); + }; + refresh(); } - private PanelBackground panelBackground; + /// + /// Whether this item can be selected. + /// + public bool AllowSelection { get; set; } + + /// + /// Whether this item can be reordered in the playlist. + /// + public bool AllowReordering + { + get => ShowDragHandle.Value; + set => ShowDragHandle.Value = value; + } + + private bool allowDeletion; + + /// + /// Whether this item can be deleted. + /// + public bool AllowDeletion + { + get => allowDeletion; + set + { + allowDeletion = value; + + if (removeButton != null) + removeButton.Alpha = value ? 1 : 0; + } + } + + private bool allowShowingResults; + + /// + /// Whether this item can have results shown. + /// + public bool AllowShowingResults + { + get => allowShowingResults; + set + { + allowShowingResults = value; + + if (showResultsButton != null) + showResultsButton.Alpha = value ? 1 : 0; + } + } + + private bool allowEditing; + + /// + /// Whether this item can be edited. + /// + public bool AllowEditing + { + get => allowEditing; + set + { + allowEditing = value; + + if (editButton != null) + editButton.Alpha = value ? 1 : 0; + } + } + + private bool showItemOwner; + + /// + /// Whether to display the avatar of the user which owns this playlist item. + /// + public bool ShowItemOwner + { + get => showItemOwner; + set + { + showItemOwner = value; + + if (ownerAvatar != null) + ownerAvatar.Alpha = value ? 1 : 0; + } + } private void refresh() { @@ -143,22 +275,22 @@ namespace osu.Game.Screens.OnlinePlay maskingContainer.BorderColour = colours.Red; } - if (showItemOwner) - { - ownerAvatar.Show(); - userLookupCache.GetUserAsync(Item.OwnerID) - .ContinueWith(u => Schedule(() => ownerAvatar.User = u.Result), TaskContinuationOptions.OnlyOnRanToCompletion); - } - - difficultyIconContainer.Child = new DifficultyIcon(Item.Beatmap.Value, ruleset.Value, requiredMods, performBackgroundDifficultyLookup: false) { Size = new Vector2(ICON_HEIGHT) }; + if (Item.Beatmap.Value != null) + difficultyIconContainer.Child = new DifficultyIcon(Item.Beatmap.Value, ruleset.Value, requiredMods, performBackgroundDifficultyLookup: false) { Size = new Vector2(icon_height) }; + else + difficultyIconContainer.Clear(); panelBackground.Beatmap.Value = Item.Beatmap.Value; beatmapText.Clear(); - beatmapText.AddLink(Item.Beatmap.Value.GetDisplayTitleRomanisable(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineID.ToString(), null, text => + + if (Item.Beatmap.Value != null) { - text.Truncate = true; - }); + beatmapText.AddLink(Item.Beatmap.Value.GetDisplayTitleRomanisable(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineID.ToString(), null, text => + { + text.Truncate = true; + }); + } authorText.Clear(); @@ -168,10 +300,16 @@ namespace osu.Game.Screens.OnlinePlay authorText.AddUserLink(Item.Beatmap.Value.Metadata.Author); } - bool hasExplicitContent = (Item.Beatmap.Value.BeatmapSet as IBeatmapSetOnlineInfo)?.HasExplicitContent == true; + bool hasExplicitContent = (Item.Beatmap.Value?.BeatmapSet as IBeatmapSetOnlineInfo)?.HasExplicitContent == true; explicitContentPill.Alpha = hasExplicitContent ? 1 : 0; modDisplay.Current.Value = requiredMods.ToArray(); + + buttonsFlow.Clear(); + buttonsFlow.ChildrenEnumerable = createButtons(); + + difficultyIconContainer.FadeInFromZero(500, Easing.OutQuint); + mainFillFlow.FadeInFromZero(500, Easing.OutQuint); } protected override Drawable CreateContent() @@ -192,6 +330,7 @@ namespace osu.Game.Screens.OnlinePlay Alpha = 0, AlwaysPresent = true }, + onScreenLoader, panelBackground = new PanelBackground { RelativeSizeAxes = Axes.Both, @@ -217,7 +356,7 @@ namespace osu.Game.Screens.OnlinePlay AutoSizeAxes = Axes.Both, Margin = new MarginPadding { Left = 8, Right = 8 }, }, - new FillFlowContainer + mainFillFlow = new FillFlowContainer { Anchor = Anchor.CentreLeft, Origin = Anchor.CentreLeft, @@ -273,7 +412,7 @@ namespace osu.Game.Screens.OnlinePlay } } }, - new FillFlowContainer + buttonsFlow = new FillFlowContainer { Anchor = Anchor.CentreRight, Origin = Anchor.CentreRight, @@ -281,7 +420,7 @@ namespace osu.Game.Screens.OnlinePlay Margin = new MarginPadding { Horizontal = 8 }, AutoSizeAxes = Axes.Both, Spacing = new Vector2(5), - ChildrenEnumerable = CreateButtons().Select(button => button.With(b => + ChildrenEnumerable = createButtons().Select(button => button.With(b => { b.Anchor = Anchor.Centre; b.Origin = Anchor.Centre; @@ -291,11 +430,11 @@ namespace osu.Game.Screens.OnlinePlay { Anchor = Anchor.Centre, Origin = Anchor.Centre, - Size = new Vector2(ICON_HEIGHT), + Size = new Vector2(icon_height), Margin = new MarginPadding { Right = 8 }, Masking = true, CornerRadius = 4, - Alpha = showItemOwner ? 1 : 0 + Alpha = ShowItemOwner ? 1 : 0 }, } } @@ -304,38 +443,53 @@ namespace osu.Game.Screens.OnlinePlay }; } - protected virtual IEnumerable CreateButtons() => - new Drawable[] + private IEnumerable createButtons() => new[] + { + showResultsButton = new GrayButton(FontAwesome.Solid.ChartPie) { - new PlaylistDownloadButton(Item), - new PlaylistRemoveButton - { - Size = new Vector2(30, 30), - Alpha = allowEdit ? 1 : 0, - Action = () => RequestDeletion?.Invoke(Model), - }, - }; + Size = new Vector2(30, 30), + Action = () => RequestResults?.Invoke(Item), + Alpha = AllowShowingResults ? 1 : 0, + TooltipText = "View results" + }, + Item.Beatmap.Value == null ? Empty() : new PlaylistDownloadButton(Item), + editButton = new PlaylistEditButton + { + Size = new Vector2(30, 30), + Alpha = AllowEditing ? 1 : 0, + Action = () => RequestEdit?.Invoke(Item), + TooltipText = "Edit" + }, + removeButton = new PlaylistRemoveButton + { + Size = new Vector2(30, 30), + Alpha = AllowDeletion ? 1 : 0, + Action = () => RequestDeletion?.Invoke(Item), + TooltipText = "Remove from playlist" + }, + }; + + protected override bool OnClick(ClickEvent e) + { + if (AllowSelection && valid.Value) + SelectedItem.Value = Model; + return true; + } + + public class PlaylistEditButton : GrayButton + { + public PlaylistEditButton() + : base(FontAwesome.Solid.Edit) + { + } + } public class PlaylistRemoveButton : GrayButton { public PlaylistRemoveButton() : base(FontAwesome.Solid.MinusSquare) { - TooltipText = "Remove from playlist"; } - - [BackgroundDependencyLoader] - private void load() - { - Icon.Scale = new Vector2(0.8f); - } - } - - protected override bool OnClick(ClickEvent e) - { - if (allowSelection && valid.Value) - SelectedItem.Value = Model; - return true; } private sealed class PlaylistDownloadButton : BeatmapDownloadButton diff --git a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistWithResults.cs b/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistWithResults.cs deleted file mode 100644 index 8b1bb7abc1..0000000000 --- a/osu.Game/Screens/OnlinePlay/DrawableRoomPlaylistWithResults.cs +++ /dev/null @@ -1,69 +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 System; -using System.Collections.Generic; -using System.Linq; -using osu.Framework.Allocation; -using osu.Framework.Graphics; -using osu.Framework.Graphics.Shapes; -using osu.Framework.Graphics.Sprites; -using osu.Game.Graphics; -using osu.Game.Graphics.Containers; -using osu.Game.Graphics.UserInterface; -using osu.Game.Online.Rooms; - -namespace osu.Game.Screens.OnlinePlay -{ - public class DrawableRoomPlaylistWithResults : DrawableRoomPlaylist - { - public Action RequestShowResults; - - private readonly bool showItemOwner; - - public DrawableRoomPlaylistWithResults(bool showItemOwner = false) - : base(false, true, showItemOwner: showItemOwner) - { - this.showItemOwner = showItemOwner; - } - - protected override OsuRearrangeableListItem CreateOsuDrawable(PlaylistItem item) => - new DrawableRoomPlaylistItemWithResults(item, false, true, showItemOwner) - { - RequestShowResults = () => RequestShowResults(item), - SelectedItem = { BindTarget = SelectedItem }, - }; - - private class DrawableRoomPlaylistItemWithResults : DrawableRoomPlaylistItem - { - public Action RequestShowResults; - - public DrawableRoomPlaylistItemWithResults(PlaylistItem item, bool allowEdit, bool allowSelection, bool showItemOwner) - : base(item, allowEdit, allowSelection, showItemOwner) - { - } - - protected override IEnumerable CreateButtons() => - base.CreateButtons().Prepend(new FilledIconButton - { - Icon = FontAwesome.Solid.ChartPie, - Action = () => RequestShowResults?.Invoke(), - TooltipText = "View results" - }); - - private class FilledIconButton : IconButton - { - [BackgroundDependencyLoader] - private void load(OsuColour colours) - { - Add(new Box - { - RelativeSizeAxes = Axes.Both, - Depth = float.MaxValue, - Colour = colours.Gray4, - }); - } - } - } - } -} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs index 9920883078..a87f21630c 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/DrawableRoom.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Collections.Generic; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; @@ -30,9 +31,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components public readonly Room Room; - [Resolved] - private BeatmapManager beatmaps { get; set; } - protected Container ButtonsContainer { get; private set; } private readonly Bindable roomType = new Bindable(); @@ -187,20 +185,7 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components AutoSizeAxes = Axes.Both, Direction = FillDirection.Horizontal, Spacing = new Vector2(5), - Children = new Drawable[] - { - new PlaylistCountPill - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - }, - new StarRatingRangeDisplay - { - Anchor = Anchor.CentreLeft, - Origin = Anchor.CentreLeft, - Scale = new Vector2(0.8f) - } - } + ChildrenEnumerable = CreateBottomDetails() } } }, @@ -290,6 +275,37 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components protected virtual Drawable CreateBackground() => new OnlinePlayBackgroundSprite(); + protected virtual IEnumerable CreateBottomDetails() + { + var pills = new List(); + + if (Room.Type.Value != MatchType.Playlists) + { + pills.AddRange(new OnlinePlayComposite[] + { + new MatchTypePill(), + new QueueModePill(), + }); + } + + pills.AddRange(new Drawable[] + { + new PlaylistCountPill + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + }, + new StarRatingRangeDisplay + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + Scale = new Vector2(0.8f) + } + }); + + return pills; + } + private class RoomNameText : OsuSpriteText { [Resolved(typeof(Room), nameof(Online.Rooms.Room.Name))] diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs new file mode 100644 index 0000000000..d104ede8f7 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/MatchTypePill.cs @@ -0,0 +1,50 @@ +// 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; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + public class MatchTypePill : OnlinePlayComposite + { + private OsuTextFlowContainer textFlow; + + public MatchTypePill() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new PillContainer + { + Child = textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + Type.BindValueChanged(onMatchTypeChanged, true); + } + + private void onMatchTypeChanged(ValueChangedEvent type) + { + textFlow.Clear(); + textFlow.AddText(type.NewValue.GetLocalisableDescription()); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs new file mode 100644 index 0000000000..7501f0237b --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/QueueModePill.cs @@ -0,0 +1,50 @@ +// 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; +using osu.Framework.Graphics; +using osu.Game.Graphics; +using osu.Game.Graphics.Containers; +using osu.Game.Online.Multiplayer; + +namespace osu.Game.Screens.OnlinePlay.Lounge.Components +{ + public class QueueModePill : OnlinePlayComposite + { + private OsuTextFlowContainer textFlow; + + public QueueModePill() + { + AutoSizeAxes = Axes.Both; + } + + [BackgroundDependencyLoader] + private void load() + { + InternalChild = new PillContainer + { + Child = textFlow = new OsuTextFlowContainer(s => s.Font = OsuFont.GetFont(size: 12)) + { + Anchor = Anchor.CentreLeft, + Origin = Anchor.CentreLeft, + AutoSizeAxes = Axes.Both, + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + QueueMode.BindValueChanged(onQueueModeChanged, true); + } + + private void onQueueModeChanged(ValueChangedEvent mode) + { + textFlow.Clear(); + textFlow.AddText(mode.NewValue.GetLocalisableDescription()); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs index 54c762b8ce..f4d7823fcc 100644 --- a/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs +++ b/osu.Game/Screens/OnlinePlay/Lounge/Components/RoomsContainer.cs @@ -33,9 +33,6 @@ namespace osu.Game.Screens.OnlinePlay.Lounge.Components [Resolved] private IRoomManager roomManager { get; set; } - [Resolved(CanBeNull = true)] - private LoungeSubScreen loungeSubScreen { get; set; } - // handle deselection public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) => true; diff --git a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs index 7c5ed3f5cc..a560d85b7d 100644 --- a/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Match/RoomSubScreen.cs @@ -28,7 +28,7 @@ namespace osu.Game.Screens.OnlinePlay.Match public abstract class RoomSubScreen : OnlinePlaySubScreen, IPreviewTrackOwner { [Cached(typeof(IBindable))] - protected readonly Bindable SelectedItem = new Bindable(); + public readonly Bindable SelectedItem = new Bindable(); public override bool? AllowTrackAdjustments => true; @@ -67,7 +67,7 @@ namespace osu.Game.Screens.OnlinePlay.Match protected OnlinePlayScreen ParentScreen { get; private set; } [Cached] - private OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker { get; set; } + private readonly OnlinePlayBeatmapAvailabilityTracker beatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker(); protected IBindable BeatmapAvailability => beatmapAvailabilityTracker.Availability; @@ -90,11 +90,6 @@ namespace osu.Game.Screens.OnlinePlay.Match Padding = new MarginPadding { Top = Header.HEIGHT }; - beatmapAvailabilityTracker = new OnlinePlayBeatmapAvailabilityTracker - { - SelectedItem = { BindTarget = SelectedItem } - }; - RoomId.BindTo(room.RoomID); } @@ -247,10 +242,10 @@ namespace osu.Game.Screens.OnlinePlay.Match }, true); SelectedItem.BindValueChanged(_ => Scheduler.AddOnce(selectedItemChanged)); - - beatmapManager.ItemUpdated += beatmapUpdated; - UserMods.BindValueChanged(_ => Scheduler.AddOnce(UpdateMods)); + + beatmapAvailabilityTracker.SelectedItem.BindTo(SelectedItem); + beatmapAvailabilityTracker.Availability.BindValueChanged(_ => updateWorkingBeatmap()); } protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) @@ -319,6 +314,16 @@ namespace osu.Game.Screens.OnlinePlay.Match protected void StartPlay() { + // User may be at song select or otherwise when the host starts gameplay. + // Ensure that they first return to this screen, else global bindables (beatmap etc.) may be in a bad state. + if (!this.IsCurrentScreen()) + { + this.MakeCurrent(); + + Schedule(StartPlay); + return; + } + sampleStart?.Play(); // fallback is to allow this class to operate when there is no parent OnlineScreen (testing purposes). @@ -364,8 +369,6 @@ namespace osu.Game.Screens.OnlinePlay.Match } } - private void beatmapUpdated(BeatmapSetInfo set) => Schedule(updateWorkingBeatmap); - private void updateWorkingBeatmap() { var beatmap = SelectedItem.Value?.Beatmap.Value; @@ -433,14 +436,6 @@ namespace osu.Game.Screens.OnlinePlay.Match /// The room to change the settings of. protected abstract RoomSettingsOverlay CreateRoomSettingsOverlay(Room room); - protected override void Dispose(bool isDisposing) - { - base.Dispose(isDisposing); - - if (beatmapManager != null) - beatmapManager.ItemUpdated -= beatmapUpdated; - } - public class UserModSelectButton : PurpleTriangleButton { } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs index 34edc1ccd1..7f1db733b3 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerMatchSettingsOverlay.cs @@ -12,7 +12,6 @@ using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; using osu.Framework.Screens; -using osu.Game.Beatmaps; using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; @@ -20,7 +19,6 @@ using osu.Game.Graphics.UserInterface; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; using osu.Game.Overlays; -using osu.Game.Rulesets; using osu.Game.Screens.OnlinePlay.Match.Components; using osuTK; @@ -84,12 +82,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match [Resolved] private MultiplayerClient client { get; set; } - [Resolved] - private Bindable beatmap { get; set; } - - [Resolved] - private Bindable ruleset { get; set; } - [Resolved] private OngoingOperationTracker ongoingOperationTracker { get; set; } @@ -256,7 +248,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Spacing = new Vector2(5), Children = new Drawable[] { - drawablePlaylist = new DrawableRoomPlaylist(false, false) + drawablePlaylist = new DrawableRoomPlaylist { RelativeSizeAxes = Axes.X, Height = DrawableRoomPlaylistItem.HEIGHT diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index ce988e377f..06959d942f 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -11,7 +11,6 @@ using osu.Framework.Graphics; using osu.Framework.Threading; using osu.Game.Graphics; using osu.Game.Graphics.Backgrounds; -using osu.Game.Online.API; using osu.Game.Online.Multiplayer; using osu.Game.Screens.OnlinePlay.Components; using osuTK; @@ -25,9 +24,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match set => button.Action = value; } - [Resolved] - private IAPIProvider api { get; set; } - [Resolved] private OsuColour colours { get; set; } @@ -67,6 +63,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match sampleUnready = audio.Samples.Get(@"Multiplayer/player-unready"); } + protected override void LoadComplete() + { + base.LoadComplete(); + + SelectedItem.BindValueChanged(_ => updateState()); + } + protected override void OnRoomUpdated() { base.OnRoomUpdated(); @@ -108,7 +111,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match bool enableButton = Room?.State == MultiplayerRoomState.Open - && Client.CurrentMatchPlayingItem.Value?.Expired == false + && SelectedItem.Value?.ID == Room.Settings.PlaylistItemId + && !Room.Playlist.Single(i => i.ID == Room.Settings.PlaylistItemId).Expired && !operationInProgress.Value; // When the local user is the host and spectating the match, the "start match" state should be enabled if any users are ready. diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerHistoryList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerHistoryList.cs new file mode 100644 index 0000000000..32d355d149 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerHistoryList.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.Rooms; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist +{ + /// + /// A historically-ordered list of s. + /// + public class MultiplayerHistoryList : DrawableRoomPlaylist + { + public MultiplayerHistoryList() + { + ShowItemOwners = true; + } + + protected override FillFlowContainer> CreateListFillFlowContainer() => new HistoryFillFlowContainer + { + Spacing = new Vector2(0, 2) + }; + + private class HistoryFillFlowContainer : FillFlowContainer> + { + public override IEnumerable FlowingChildren => base.FlowingChildren.OfType>().OrderByDescending(item => item.Model.PlayedAt); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs new file mode 100644 index 0000000000..7b90532cce --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs @@ -0,0 +1,142 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Graphics.UserInterface; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist +{ + /// + /// The multiplayer playlist, containing lists to show the items from a in both gameplay-order and historical-order. + /// + public class MultiplayerPlaylist : MultiplayerRoomComposite + { + public readonly Bindable DisplayMode = new Bindable(); + + /// + /// Invoked when an item requests to be edited. + /// + public Action RequestEdit; + + private MultiplayerQueueList queueList; + private MultiplayerHistoryList historyList; + private bool firstPopulation = true; + + [BackgroundDependencyLoader] + private void load() + { + const float tab_control_height = 25; + + InternalChildren = new Drawable[] + { + new OsuTabControl + { + RelativeSizeAxes = Axes.X, + Height = tab_control_height, + Current = { BindTarget = DisplayMode } + }, + new Container + { + RelativeSizeAxes = Axes.Both, + Padding = new MarginPadding { Top = tab_control_height + 5 }, + Masking = true, + Children = new Drawable[] + { + queueList = new MultiplayerQueueList + { + RelativeSizeAxes = Axes.Both, + SelectedItem = { BindTarget = SelectedItem }, + RequestEdit = item => RequestEdit?.Invoke(item) + }, + historyList = new MultiplayerHistoryList + { + RelativeSizeAxes = Axes.Both, + Alpha = 0, + SelectedItem = { BindTarget = SelectedItem } + } + } + } + }; + } + + protected override void LoadComplete() + { + base.LoadComplete(); + DisplayMode.BindValueChanged(onDisplayModeChanged, true); + } + + private void onDisplayModeChanged(ValueChangedEvent mode) + { + historyList.FadeTo(mode.NewValue == MultiplayerPlaylistDisplayMode.History ? 1 : 0, 100); + queueList.FadeTo(mode.NewValue == MultiplayerPlaylistDisplayMode.Queue ? 1 : 0, 100); + } + + protected override void OnRoomUpdated() + { + base.OnRoomUpdated(); + + if (Room == null) + { + historyList.Items.Clear(); + queueList.Items.Clear(); + firstPopulation = true; + return; + } + + if (firstPopulation) + { + foreach (var item in Room.Playlist) + addItemToLists(item); + + firstPopulation = false; + } + } + + protected override void PlaylistItemAdded(MultiplayerPlaylistItem item) + { + base.PlaylistItemAdded(item); + addItemToLists(item); + } + + protected override void PlaylistItemRemoved(long item) + { + base.PlaylistItemRemoved(item); + removeItemFromLists(item); + } + + protected override void PlaylistItemChanged(MultiplayerPlaylistItem item) + { + base.PlaylistItemChanged(item); + + removeItemFromLists(item.ID); + addItemToLists(item); + } + + private void addItemToLists(MultiplayerPlaylistItem item) + { + var apiItem = Playlist.SingleOrDefault(i => i.ID == item.ID); + + // Item could have been removed from the playlist while the local player was in gameplay. + if (apiItem == null) + return; + + if (item.Expired) + historyList.Items.Add(apiItem); + else + queueList.Items.Add(apiItem); + } + + private void removeItemFromLists(long item) + { + queueList.Items.RemoveAll(i => i.ID == item); + historyList.Items.RemoveAll(i => i.ID == item); + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistDisplayMode.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistDisplayMode.cs new file mode 100644 index 0000000000..cc3dca6a34 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylistDisplayMode.cs @@ -0,0 +1,14 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist +{ + /// + /// The type of list displayed in a . + /// + public enum MultiplayerPlaylistDisplayMode + { + Queue, + History, + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs new file mode 100644 index 0000000000..3e0f663d42 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs @@ -0,0 +1,88 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using System.Linq; +using osu.Framework.Allocation; +using osu.Framework.Bindables; +using osu.Framework.Graphics; +using osu.Framework.Graphics.Containers; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; +using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; +using osuTK; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist +{ + /// + /// A gameplay-ordered list of s. + /// + public class MultiplayerQueueList : DrawableRoomPlaylist + { + public MultiplayerQueueList() + { + ShowItemOwners = true; + } + + protected override FillFlowContainer> CreateListFillFlowContainer() => new QueueFillFlowContainer + { + Spacing = new Vector2(0, 2) + }; + + protected override DrawableRoomPlaylistItem CreateDrawablePlaylistItem(PlaylistItem item) => new QueuePlaylistItem(item); + + private class QueueFillFlowContainer : FillFlowContainer> + { + [Resolved(typeof(Room), nameof(Room.Playlist))] + private BindableList roomPlaylist { get; set; } + + protected override void LoadComplete() + { + base.LoadComplete(); + roomPlaylist.BindCollectionChanged((_, __) => InvalidateLayout()); + } + + public override IEnumerable FlowingChildren => base.FlowingChildren.OfType>().OrderBy(item => item.Model.PlaylistOrder); + } + + private class QueuePlaylistItem : DrawableRoomPlaylistItem + { + [Resolved] + private IAPIProvider api { get; set; } + + [Resolved] + private MultiplayerClient multiplayerClient { get; set; } + + [Resolved(typeof(Room), nameof(Room.Host))] + private Bindable host { get; set; } + + [Resolved(typeof(Room), nameof(Room.QueueMode))] + private Bindable queueMode { get; set; } + + public QueuePlaylistItem(PlaylistItem item) + : base(item) + { + } + + protected override void LoadComplete() + { + base.LoadComplete(); + + RequestDeletion = item => multiplayerClient.RemovePlaylistItem(item.ID); + + host.BindValueChanged(_ => updateDeleteButtonVisibility()); + queueMode.BindValueChanged(_ => updateDeleteButtonVisibility()); + SelectedItem.BindValueChanged(_ => updateDeleteButtonVisibility(), true); + } + + private void updateDeleteButtonVisibility() + { + bool isItemOwner = Item.OwnerID == api.LocalUser.Value.OnlineID || multiplayerClient.IsHost; + + AllowDeletion = isItemOwner && SelectedItem.Value != Item; + AllowEditing = isItemOwner; + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs index 58b5b7bbeb..e136627d43 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Multiplayer.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using System.Diagnostics; using osu.Framework.Allocation; using osu.Framework.Screens; using osu.Game.Online.Multiplayer; @@ -18,8 +19,26 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.OnResuming(last); - if (client.Room != null && client.LocalUser?.State != MultiplayerUserState.Spectating) - client.ChangeState(MultiplayerUserState.Idle); + if (client.Room == null) + return; + + Debug.Assert(client.LocalUser != null); + + switch (client.LocalUser.State) + { + case MultiplayerUserState.Spectating: + break; + + case MultiplayerUserState.WaitingForLoad: + case MultiplayerUserState.Loaded: + case MultiplayerUserState.Playing: + client.AbortGameplay(); + break; + + default: + client.ChangeState(MultiplayerUserState.Idle); + break; + } } protected override string ScreenTitle => "Multiplayer"; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs index 44efef53f5..073497e1ce 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSongSelect.cs @@ -4,6 +4,7 @@ using System; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; using osu.Framework.Allocation; using osu.Framework.Logging; @@ -24,17 +25,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [Resolved] private MultiplayerClient client { get; set; } + private readonly long? itemToEdit; + private LoadingLayer loadingLayer; /// /// Construct a new instance of multiplayer song select. /// /// The room. + /// The item to be edited. May be null, in which case a new item will be added to the playlist. /// An optional initial beatmap selection to perform. /// An optional initial ruleset selection to perform. - public MultiplayerMatchSongSelect(Room room, WorkingBeatmap beatmap = null, RulesetInfo ruleset = null) + public MultiplayerMatchSongSelect(Room room, long? itemToEdit = null, WorkingBeatmap beatmap = null, RulesetInfo ruleset = null) : base(room) { + this.itemToEdit = itemToEdit; + if (beatmap != null || ruleset != null) { Schedule(() => @@ -59,14 +65,19 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { loadingLayer.Show(); - client.AddPlaylistItem(new MultiplayerPlaylistItem + var multiplayerItem = new MultiplayerPlaylistItem { + ID = itemToEdit ?? 0, BeatmapID = item.BeatmapID, BeatmapChecksum = item.Beatmap.Value.MD5Hash, RulesetID = item.RulesetID, RequiredMods = item.RequiredMods.Select(m => new APIMod(m)).ToArray(), AllowedMods = item.AllowedMods.Select(m => new APIMod(m)).ToArray() - }).ContinueWith(t => + }; + + Task task = itemToEdit != null ? client.EditPlaylistItem(multiplayerItem) : client.AddPlaylistItem(multiplayerItem); + + task.ContinueWith(t => { Schedule(() => { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index 077e9cef93..6895608c8e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Threading.Tasks; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Bindables; @@ -14,7 +15,6 @@ using osu.Framework.Screens; using osu.Framework.Threading; using osu.Game.Beatmaps; using osu.Game.Configuration; -using osu.Game.Graphics.UserInterface; using osu.Game.Online; using osu.Game.Online.Multiplayer; using osu.Game.Online.Rooms; @@ -26,6 +26,7 @@ using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.OnlinePlay.Multiplayer.Match; +using osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist; using osu.Game.Screens.OnlinePlay.Multiplayer.Participants; using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; using osu.Game.Screens.Play; @@ -43,8 +44,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer public override string ShortTitle => "room"; - public OsuButton AddOrEditPlaylistButton { get; private set; } - [Resolved] private MultiplayerClient client { get; set; } @@ -56,7 +55,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer [CanBeNull] private IDisposable readyClickOperation; - private DrawableRoomPlaylist playlist; + private AddItemButton addItemButton; public MultiplayerMatchSubScreen(Room room) : base(room) @@ -69,14 +68,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { base.LoadComplete(); - SelectedItem.BindTo(client.CurrentMatchPlayingItem); - BeatmapAvailability.BindValueChanged(updateBeatmapAvailability, true); UserMods.BindValueChanged(onUserModsChanged); - playlist.Items.BindTo(Room.Playlist); - playlist.SelectedItem.BindTo(SelectedItem); - client.LoadRequested += onLoadRequested; client.RoomUpdated += onRoomUpdated; @@ -138,25 +132,22 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer new Drawable[] { new OverlinedHeader("Beatmap") }, new Drawable[] { - AddOrEditPlaylistButton = new PurpleTriangleButton + addItemButton = new AddItemButton { RelativeSizeAxes = Axes.X, Height = 40, - Action = () => - { - if (this.IsCurrentScreen()) - this.Push(new MultiplayerMatchSongSelect(Room)); - }, - Alpha = 0 + Text = "Add item", + Action = () => OpenSongSelection() }, }, null, new Drawable[] { - playlist = new DrawableRoomPlaylist(false, false, true, true) + new MultiplayerPlaylist { RelativeSizeAxes = Axes.Both, - }, + RequestEdit = item => OpenSongSelection(item.ID) + } }, new[] { @@ -228,6 +219,18 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer } }; + /// + /// Opens the song selection screen to add or edit an item. + /// + /// An optional playlist item to edit. If null, a new item will be added instead. + internal void OpenSongSelection(long? itemToEdit = null) + { + if (!this.IsCurrentScreen()) + return; + + this.Push(new MultiplayerMatchSongSelect(Room, itemToEdit)); + } + protected override Drawable CreateFooter() => new MultiplayerMatchFooter { OnReadyClick = onReadyClick, @@ -323,10 +326,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (client.LocalUser?.State == MultiplayerUserState.Ready) client.ChangeState(MultiplayerUserState.Idle); } - else + else if (client.LocalUser?.State == MultiplayerUserState.Spectating + && (client.Room?.State == MultiplayerRoomState.WaitingForLoad || client.Room?.State == MultiplayerRoomState.Playing)) { - if (client.LocalUser?.State == MultiplayerUserState.Spectating && (client.Room?.State == MultiplayerRoomState.WaitingForLoad || client.Room?.State == MultiplayerRoomState.Playing)) - onLoadRequested(); + onLoadRequested(); } } @@ -385,27 +388,50 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return; } - switch (client.Room.Settings.QueueMode) - { - case QueueMode.HostOnly: - AddOrEditPlaylistButton.Text = "Edit beatmap"; - AddOrEditPlaylistButton.Alpha = client.IsHost ? 1 : 0; - break; + updateCurrentItem(); - case QueueMode.AllPlayers: - case QueueMode.AllPlayersRoundRobin: - AddOrEditPlaylistButton.Text = "Add beatmap"; - AddOrEditPlaylistButton.Alpha = 1; - break; - - default: - AddOrEditPlaylistButton.Alpha = 0; - break; - } + addItemButton.Alpha = client.IsHost || Room.QueueMode.Value != QueueMode.HostOnly ? 1 : 0; Scheduler.AddOnce(UpdateMods); } + private void updateCurrentItem() + { + Debug.Assert(client.Room != null); + + var expectedSelectedItem = Room.Playlist.SingleOrDefault(i => i.ID == client.Room.Settings.PlaylistItemId); + + if (expectedSelectedItem == null) + return; + + // There's no reason to renew the selected item if its content hasn't changed. + if (SelectedItem.Value?.Equals(expectedSelectedItem) == true && expectedSelectedItem.Beatmap.Value != null) + return; + + // Clear the selected item while the lookup is performed, so components like the ready button can enter their disabled states. + SelectedItem.Value = null; + + if (expectedSelectedItem.Beatmap.Value == null) + { + Task.Run(async () => + { + var beatmap = await client.GetAPIBeatmap(expectedSelectedItem.BeatmapID).ConfigureAwait(false); + + Schedule(() => + { + expectedSelectedItem.Beatmap.Value = beatmap; + + if (Room.Playlist.SingleOrDefault(i => i.ID == client.Room?.Settings.PlaylistItemId)?.Equals(expectedSelectedItem) == true) + applyCurrentItem(); + }); + }); + } + else + applyCurrentItem(); + + void applyCurrentItem() => SelectedItem.Value = expectedSelectedItem; + } + private void handleRoomLost() => Schedule(() => { if (this.IsCurrentScreen()) @@ -458,6 +484,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (!this.IsCurrentScreen()) return; + if (client.Room == null) + return; + if (!client.IsHost) { // todo: should handle this when the request queue is implemented. @@ -466,7 +495,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer return; } - this.Push(new MultiplayerMatchSongSelect(Room, beatmap, ruleset)); + this.Push(new MultiplayerMatchSongSelect(Room, client.Room.Settings.PlaylistItemId, beatmap, ruleset)); } protected override void Dispose(bool isDisposing) @@ -481,5 +510,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer modSettingChangeTracker?.Dispose(); } + + public class AddItemButton : PurpleTriangleButton + { + } } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs index a380ddef25..7d2fe44c4e 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomComposite.cs @@ -4,6 +4,7 @@ using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Game.Online.Multiplayer; +using osu.Game.Online.Rooms; namespace osu.Game.Screens.OnlinePlay.Multiplayer { @@ -23,14 +24,20 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Client.UserLeft += invokeUserLeft; Client.UserKicked += invokeUserKicked; Client.UserJoined += invokeUserJoined; + Client.ItemAdded += invokeItemAdded; + Client.ItemRemoved += invokeItemRemoved; + Client.ItemChanged += invokeItemChanged; OnRoomUpdated(); } private void invokeOnRoomUpdated() => Scheduler.AddOnce(OnRoomUpdated); - private void invokeUserJoined(MultiplayerRoomUser user) => Scheduler.AddOnce(UserJoined, user); - private void invokeUserKicked(MultiplayerRoomUser user) => Scheduler.AddOnce(UserKicked, user); - private void invokeUserLeft(MultiplayerRoomUser user) => Scheduler.AddOnce(UserLeft, user); + private void invokeUserJoined(MultiplayerRoomUser user) => Scheduler.Add(() => UserJoined(user)); + private void invokeUserKicked(MultiplayerRoomUser user) => Scheduler.Add(() => UserKicked(user)); + private void invokeUserLeft(MultiplayerRoomUser user) => Scheduler.Add(() => UserLeft(user)); + private void invokeItemAdded(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemAdded(item)); + private void invokeItemRemoved(long item) => Schedule(() => PlaylistItemRemoved(item)); + private void invokeItemChanged(MultiplayerPlaylistItem item) => Schedule(() => PlaylistItemChanged(item)); /// /// Invoked when a user has joined the room. @@ -56,6 +63,30 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer { } + /// + /// Invoked when a playlist item is added to the room. + /// + /// The added playlist item. + protected virtual void PlaylistItemAdded(MultiplayerPlaylistItem item) + { + } + + /// + /// Invoked when a playlist item is removed from the room. + /// + /// The ID of the removed playlist item. + protected virtual void PlaylistItemRemoved(long item) + { + } + + /// + /// Invoked when a playlist item is changed in the room. + /// + /// The new playlist item, with an existing item's ID. + protected virtual void PlaylistItemChanged(MultiplayerPlaylistItem item) + { + } + /// /// Invoked when any change occurs to the multiplayer room. /// @@ -71,6 +102,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Client.UserLeft -= invokeUserLeft; Client.UserKicked -= invokeUserKicked; Client.UserJoined -= invokeUserJoined; + Client.ItemAdded -= invokeItemAdded; + Client.ItemRemoved -= invokeItemRemoved; + Client.ItemChanged -= invokeItemChanged; } base.Dispose(isDisposing); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index 3152f50d3d..8fbaebadfe 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -35,7 +35,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants private IAPIProvider api { get; set; } [Resolved] - private RulesetStore rulesets { get; set; } + private IRulesetStore rulesets { get; set; } private SpriteIcon crown; @@ -185,9 +185,9 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants const double fade_time = 50; // Todo: Should use the room's selected item to determine ruleset. - var ruleset = rulesets.GetRuleset(0).CreateInstance(); + var ruleset = rulesets.GetRuleset(0)?.CreateInstance(); - int? currentModeRank = User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank; + int? currentModeRank = ruleset != null ? User.User?.RulesetsStatistics?.GetValueOrDefault(ruleset.ShortName)?.GlobalRank : null; userRankText.Text = currentModeRank != null ? $"#{currentModeRank.Value:N0}" : string.Empty; userStateDisplay.UpdateStatus(User.State, User.BeatmapAvailability); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs index 2ad64e115e..d36c556fac 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantsList.cs @@ -77,7 +77,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants else { // Remove panels for users no longer in the room. - panels.RemoveAll(p => !Room.Users.Contains(p.User)); + foreach (var p in panels) + { + // Note that we *must* use reference equality here, as this call is scheduled and a user may have left and joined since it was last run. + if (Room.Users.All(u => !ReferenceEquals(p.User, u))) + p.Expire(); + } // Add panels for all users new to the room. foreach (var user in Room.Users.Except(panels.Select(p => p.User))) diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index 57d0d2c198..7350408eba 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Diagnostics; using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; @@ -36,9 +37,6 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate [Resolved] private OsuColour colours { get; set; } - [Resolved] - private SpectatorClient spectatorClient { get; set; } - [Resolved] private MultiplayerClient multiplayerClient { get; set; } @@ -229,8 +227,12 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate public override bool OnBackButton() { - // On a manual exit, set the player state back to idle. - multiplayerClient.ChangeState(MultiplayerUserState.Idle); + Debug.Assert(multiplayerClient.Room != null); + + // On a manual exit, set the player back to idle unless gameplay has finished. + if (multiplayerClient.Room.State != MultiplayerRoomState.Open) + multiplayerClient.ChangeState(MultiplayerUserState.Idle); + return base.OnBackButton(); } } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs index c3190cd845..48f153ecbe 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/PlayerArea.cs @@ -87,7 +87,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate gameplayContent.Child = new PlayerIsolationContainer(beatmapManager.GetWorkingBeatmap(Score.ScoreInfo.BeatmapInfo), Score.ScoreInfo.Ruleset, Score.ScoreInfo.Mods) { RelativeSizeAxes = Axes.Both, - Child = stack = new OsuScreenStack() + Child = stack = new OsuScreenStack + { + Name = nameof(PlayerArea), + } }; stack.Push(new MultiSpectatorPlayerLoader(Score, () => new MultiSpectatorPlayer(Score, GameplayClock))); diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs index a18e4b45cf..19153521cd 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlayScreen.cs @@ -40,18 +40,9 @@ namespace osu.Game.Screens.OnlinePlay [Cached] private readonly OngoingOperationTracker ongoingOperationTracker = new OngoingOperationTracker(); - [Resolved(CanBeNull = true)] - private MusicController music { get; set; } - - [Resolved] - private OsuGameBase game { get; set; } - [Resolved] protected IAPIProvider API { get; private set; } - [Resolved(CanBeNull = true)] - private OsuLogo logo { get; set; } - protected OnlinePlayScreen() { Anchor = Anchor.Centre; diff --git a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs index 4bc0b55433..63957caee3 100644 --- a/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/OnlinePlaySongSelect.cs @@ -33,14 +33,14 @@ namespace osu.Game.Screens.OnlinePlay [Resolved(typeof(Room), nameof(Room.Playlist))] protected BindableList Playlist { get; private set; } + [CanBeNull] + [Resolved(CanBeNull = true)] + protected IBindable SelectedItem { get; private set; } + protected override UserActivity InitialActivity => new UserActivity.InLobby(room); protected readonly Bindable> FreeMods = new Bindable>(Array.Empty()); - [CanBeNull] - [Resolved(CanBeNull = true)] - private IBindable selectedItem { get; set; } - private readonly FreeModSelectOverlay freeModSelectOverlay; private readonly Room room; @@ -80,8 +80,8 @@ namespace osu.Game.Screens.OnlinePlay // At this point, Mods contains both the required and allowed mods. For selection purposes, it should only contain the required mods. // Similarly, freeMods is currently empty but should only contain the allowed mods. - Mods.Value = selectedItem?.Value?.RequiredMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty(); - FreeMods.Value = selectedItem?.Value?.AllowedMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty(); + Mods.Value = SelectedItem?.Value?.RequiredMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty(); + FreeMods.Value = SelectedItem?.Value?.AllowedMods.Select(m => m.DeepClone()).ToArray() ?? Array.Empty(); Mods.BindValueChanged(onModsChanged); Ruleset.BindValueChanged(onRulesetChanged); diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs index aed3635cbc..1e6722d51e 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsResultsScreen.cs @@ -87,6 +87,13 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { var allScores = new List { userScore }; + // Other scores could have arrived between score submission and entering the results screen. Ensure the local player score position is up to date. + if (Score != null) + { + Score.Position = userScore.Position; + ScorePanelList.GetPanelForScore(Score).ScorePosition.Value = userScore.Position; + } + if (userScore.ScoresAround?.Higher != null) { allScores.AddRange(userScore.ScoresAround.Higher.Scores); @@ -186,12 +193,12 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Schedule(() => { // Prefer selecting the local user's score, or otherwise default to the first visible score. - SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.Id == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); + SelectedScore.Value = scoreInfos.FirstOrDefault(s => s.User.OnlineID == api.LocalUser.Value.Id) ?? scoreInfos.FirstOrDefault(); }); } // Invoke callback to add the scores. Exclude the user's current score which was added previously. - callback.Invoke(scoreInfos.Where(s => s.OnlineScoreID != Score?.OnlineScoreID)); + callback.Invoke(scoreInfos.Where(s => s.OnlineID != Score?.OnlineID)); hideLoadingSpinners(pivot); })); diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs index 27c8dc1120..6c8ab52d22 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsOverlay.cs @@ -5,7 +5,9 @@ using System; using System.Collections.Specialized; using System.Linq; using Humanizer; +using Humanizer.Localisation; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Shapes; @@ -14,6 +16,8 @@ using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Graphics.Sprites; using osu.Game.Graphics.UserInterface; +using osu.Game.Online.API; +using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osu.Game.Overlays; using osu.Game.Screens.OnlinePlay.Match.Components; @@ -69,6 +73,9 @@ namespace osu.Game.Screens.OnlinePlay.Playlists [Resolved(CanBeNull = true)] private IRoomManager manager { get; set; } + [Resolved] + private IAPIProvider api { get; set; } + private readonly Room room; public MatchSettings(Room room) @@ -134,19 +141,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Child = DurationField = new DurationDropdown { RelativeSizeAxes = Axes.X, - Items = new[] - { - TimeSpan.FromMinutes(30), - TimeSpan.FromHours(1), - TimeSpan.FromHours(2), - TimeSpan.FromHours(4), - TimeSpan.FromHours(8), - TimeSpan.FromHours(12), - //TimeSpan.FromHours(16), - TimeSpan.FromHours(24), - TimeSpan.FromDays(3), - TimeSpan.FromDays(7) - } } }, new Section("Allowed attempts (across all playlist items)") @@ -205,7 +199,10 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { new Drawable[] { - playlist = new DrawableRoomPlaylist(true, false) { RelativeSizeAxes = Axes.Both } + playlist = new PlaylistsRoomSettingsPlaylist + { + RelativeSizeAxes = Axes.Both, + } }, new Drawable[] { @@ -300,10 +297,40 @@ namespace osu.Game.Screens.OnlinePlay.Playlists MaxAttempts.BindValueChanged(count => MaxAttemptsField.Text = count.NewValue?.ToString(), true); Duration.BindValueChanged(duration => DurationField.Current.Value = duration.NewValue ?? TimeSpan.FromMinutes(30), true); + api.LocalUser.BindValueChanged(populateDurations, true); + playlist.Items.BindTo(Playlist); Playlist.BindCollectionChanged(onPlaylistChanged, true); } + private void populateDurations(ValueChangedEvent user) + { + DurationField.Items = new[] + { + TimeSpan.FromMinutes(30), + TimeSpan.FromHours(1), + TimeSpan.FromHours(2), + TimeSpan.FromHours(4), + TimeSpan.FromHours(8), + TimeSpan.FromHours(12), + TimeSpan.FromHours(24), + TimeSpan.FromDays(3), + TimeSpan.FromDays(7), + TimeSpan.FromDays(14), + }; + + // TODO: show these in the interface at all times. + if (user.NewValue.IsSupporter) + { + // roughly correct (see https://github.com/Humanizr/Humanizer/blob/18167e56c082449cc4fe805b8429e3127a7b7f93/readme.md?plain=1#L427) + // if we want this to be more accurate we might consider sending an actual end time, not a time span. probably not required though. + const int days_in_month = 31; + + DurationField.AddDropdownItem(TimeSpan.FromDays(days_in_month)); + DurationField.AddDropdownItem(TimeSpan.FromDays(days_in_month * 3)); + } + } + protected override void Update() { base.Update(); @@ -402,7 +429,7 @@ namespace osu.Game.Screens.OnlinePlay.Playlists Menu.MaxHeight = 100; } - protected override LocalisableString GenerateItemText(TimeSpan item) => item.Humanize(); + protected override LocalisableString GenerateItemText(TimeSpan item) => item.Humanize(maxUnit: TimeUnit.Month); } } } diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsPlaylist.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsPlaylist.cs new file mode 100644 index 0000000000..2fe215eef2 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSettingsPlaylist.cs @@ -0,0 +1,29 @@ +// 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.Extensions.IEnumerableExtensions; + +namespace osu.Game.Screens.OnlinePlay.Playlists +{ + /// + /// A which is displayed during the setup stage of a playlists room. + /// + public class PlaylistsRoomSettingsPlaylist : DrawableRoomPlaylist + { + public PlaylistsRoomSettingsPlaylist() + { + AllowReordering = true; + AllowDeletion = true; + + RequestDeletion = item => + { + var nextItem = Items.GetNext(item); + + Items.Remove(item); + + SelectedItem.Value = nextItem ?? Items.LastOrDefault(); + }; + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs index 6d2a426e70..4114a5e9a0 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsRoomSubScreen.cs @@ -11,7 +11,6 @@ using osu.Framework.Graphics.Containers; using osu.Framework.Logging; using osu.Framework.Screens; using osu.Game.Input; -using osu.Game.Online.API; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.OnlinePlay.Match; @@ -29,9 +28,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists public override string ShortTitle => "playlist"; - [Resolved] - private IAPIProvider api { get; set; } - private readonly IBindable isIdle = new BindableBool(); private MatchLeaderboard leaderboard; @@ -92,12 +88,14 @@ namespace osu.Game.Screens.OnlinePlay.Playlists new Drawable[] { new OverlinedPlaylistHeader(), }, new Drawable[] { - new DrawableRoomPlaylistWithResults + new DrawableRoomPlaylist { RelativeSizeAxes = Axes.Both, Items = { BindTarget = Room.Playlist }, SelectedItem = { BindTarget = SelectedItem }, - RequestShowResults = item => + AllowSelection = true, + AllowShowingResults = true, + RequestResults = item => { Debug.Assert(RoomId.Value != null); ParentScreen?.Push(new PlaylistsResultsScreen(null, RoomId.Value.Value, item, false)); diff --git a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs index 03c95ec060..0fd76f7e25 100644 --- a/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs +++ b/osu.Game/Screens/OnlinePlay/Playlists/PlaylistsSongSelect.cs @@ -2,9 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System.Linq; -using osu.Framework.Allocation; using osu.Framework.Screens; -using osu.Game.Beatmaps; using osu.Game.Online.Rooms; using osu.Game.Screens.OnlinePlay.Components; using osu.Game.Screens.Select; @@ -13,9 +11,6 @@ namespace osu.Game.Screens.OnlinePlay.Playlists { public class PlaylistsSongSelect : OnlinePlaySongSelect { - [Resolved] - private BeatmapManager beatmaps { get; set; } - public PlaylistsSongSelect(Room room) : base(room) { diff --git a/osu.Game/Screens/Play/FailAnimation.cs b/osu.Game/Screens/Play/FailAnimation.cs index 193e1e4129..cfbfdc9966 100644 --- a/osu.Game/Screens/Play/FailAnimation.cs +++ b/osu.Game/Screens/Play/FailAnimation.cs @@ -6,6 +6,7 @@ using osu.Framework.Bindables; using osu.Game.Rulesets.UI; using System; using System.Collections.Generic; +using JetBrains.Annotations; using ManagedBass.Fx; using osu.Framework.Allocation; using osu.Framework.Audio.Sample; @@ -18,6 +19,7 @@ using osu.Framework.Utils; using osu.Game.Audio.Effects; using osu.Game.Beatmaps; using osu.Game.Configuration; +using osu.Game.Graphics; using osu.Game.Rulesets.Objects.Drawables; using osuTK; using osuTK.Graphics; @@ -58,6 +60,12 @@ namespace osu.Game.Screens.Play RelativeSizeAxes = Axes.Both, }; + /// + /// The player screen background, used to adjust appearance on failing. + /// + [CanBeNull] + public BackgroundScreen Background { private get; set; } + public FailAnimation(DrawableRuleset drawableRuleset) { this.drawableRuleset = drawableRuleset; @@ -136,6 +144,9 @@ namespace osu.Game.Screens.Play Content.ScaleTo(0.85f, duration, Easing.OutQuart); Content.RotateTo(1, duration, Easing.OutQuart); Content.FadeColour(Color4.Gray, duration); + + // Will be restored by `ApplyToBackground` logic in `SongSelect`. + Background?.FadeColour(OsuColour.Gray(0.3f), 60); } public void RemoveFilters(bool resetTrackFrequency = true) diff --git a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs index 324e5d43b5..06b53e8426 100644 --- a/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultAccuracyCounter.cs @@ -9,9 +9,6 @@ namespace osu.Game.Screens.Play.HUD { public class DefaultAccuracyCounter : GameplayAccuracyCounter, ISkinnableDrawable { - [Resolved(canBeNull: true)] - private HUDOverlay hud { get; set; } - public bool UsesFixedAnchor { get; set; } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs index 6d87211ddc..52f86d2bc3 100644 --- a/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultComboCounter.cs @@ -15,9 +15,6 @@ namespace osu.Game.Screens.Play.HUD { public class DefaultComboCounter : RollingCounter, ISkinnableDrawable { - [Resolved(canBeNull: true)] - private HUDOverlay hud { get; set; } - public bool UsesFixedAnchor { get; set; } public DefaultComboCounter() diff --git a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs index 87b19e8433..6af89404e0 100644 --- a/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs +++ b/osu.Game/Screens/Play/HUD/DefaultScoreCounter.cs @@ -16,9 +16,6 @@ namespace osu.Game.Screens.Play.HUD Origin = Anchor.TopCentre; } - [Resolved(canBeNull: true)] - private HUDOverlay hud { get; set; } - public bool UsesFixedAnchor { get; set; } [BackgroundDependencyLoader] diff --git a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs index 5c5b66d496..f1078c5d55 100644 --- a/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs +++ b/osu.Game/Screens/Play/HUD/LegacyComboCounter.cs @@ -40,9 +40,6 @@ namespace osu.Game.Screens.Play.HUD private bool isRolling; - [Resolved] - private ISkinSource skin { get; set; } - private readonly Container counterContainer; /// @@ -70,22 +67,32 @@ namespace osu.Game.Screens.Play.HUD Scale = new Vector2(1.2f); - InternalChild = counterContainer = new Container + InternalChildren = new[] { - AutoSizeAxes = Axes.Both, - AlwaysPresent = true, - Children = new[] + popOutCount = new LegacySpriteText(LegacyFont.Combo) { - popOutCount = new LegacySpriteText(LegacyFont.Combo) + Alpha = 0, + Margin = new MarginPadding(0.05f), + Blending = BlendingParameters.Additive, + Anchor = Anchor.BottomLeft, + Origin = Anchor.BottomLeft, + BypassAutoSizeAxes = Axes.Both, + }, + counterContainer = new Container + { + AutoSizeAxes = Axes.Both, + AlwaysPresent = true, + Children = new[] { - Alpha = 0, - Margin = new MarginPadding(0.05f), - Blending = BlendingParameters.Additive, - }, - displayedCountSpriteText = new LegacySpriteText(LegacyFont.Combo) - { - Alpha = 0, - }, + displayedCountSpriteText = new LegacySpriteText(LegacyFont.Combo) + { + // Initial text and AlwaysPresent allow the counter to have a size before it first displays a combo. + // This is useful for display in the skin editor. + Text = formatCount(0), + AlwaysPresent = true, + Alpha = 0, + }, + } } }; } @@ -124,13 +131,6 @@ namespace osu.Game.Screens.Play.HUD ((IHasText)displayedCountSpriteText).Text = formatCount(Current.Value); - counterContainer.Anchor = Anchor; - counterContainer.Origin = Origin; - displayedCountSpriteText.Anchor = Anchor; - displayedCountSpriteText.Origin = Origin; - popOutCount.Anchor = Anchor; - popOutCount.Origin = Origin; - Current.BindValueChanged(combo => updateCount(combo.NewValue == 0), true); } diff --git a/osu.Game/Screens/Play/Player.cs b/osu.Game/Screens/Play/Player.cs index 4d574dea99..745e1f9e7c 100644 --- a/osu.Game/Screens/Play/Player.cs +++ b/osu.Game/Screens/Play/Player.cs @@ -768,7 +768,15 @@ namespace osu.Game.Screens.Play Scheduler.Add(resultsDisplayDelegate); } - protected override bool OnScroll(ScrollEvent e) => mouseWheelDisabled.Value && !GameplayClockContainer.IsPaused.Value; + protected override bool OnScroll(ScrollEvent e) + { + // During pause, allow global volume adjust regardless of settings. + if (GameplayClockContainer.IsPaused.Value) + return false; + + // Block global volume adjust if the user has asked for it (special case when holding "Alt"). + return mouseWheelDisabled.Value && !e.AltPressed; + } #region Fail Logic @@ -913,6 +921,8 @@ namespace osu.Game.Screens.Play b.IsBreakTime.BindTo(breakTracker.IsBreakTime); b.StoryboardReplacesBackground.BindTo(storyboardReplacesBackground); + + failAnimationLayer.Background = b; }); HUDOverlay.IsBreakTime.BindTo(breakTracker.IsBreakTime); @@ -1023,13 +1033,13 @@ namespace osu.Game.Screens.Play // // Until we better define the server-side logic behind this, let's not store the online ID to avoid potential unique constraint // conflicts across various systems (ie. solo and multiplayer). - long? onlineScoreId = score.ScoreInfo.OnlineScoreID; - score.ScoreInfo.OnlineScoreID = null; + long? onlineScoreId = score.ScoreInfo.OnlineID; + score.ScoreInfo.OnlineID = -1; await scoreManager.Import(score.ScoreInfo, replayReader).ConfigureAwait(false); // ... And restore the online ID for other processes to handle correctly (e.g. de-duplication for the results screen). - score.ScoreInfo.OnlineScoreID = onlineScoreId; + score.ScoreInfo.OnlineID = onlineScoreId; } /// diff --git a/osu.Game/Screens/Play/PlayerLoader.cs b/osu.Game/Screens/Play/PlayerLoader.cs index 57db411571..60843acb4f 100644 --- a/osu.Game/Screens/Play/PlayerLoader.cs +++ b/osu.Game/Screens/Play/PlayerLoader.cs @@ -468,12 +468,14 @@ namespace osu.Game.Screens.Play private int restartCount; + private const double volume_requirement = 0.05; + private void showMuteWarningIfNeeded() { if (!muteWarningShownOnce.Value) { // Checks if the notification has not been shown yet and also if master volume is muted, track/music volume is muted or if the whole game is muted. - if (volumeOverlay?.IsMuted.Value == true || audioManager.Volume.Value <= audioManager.Volume.MinValue || audioManager.VolumeTrack.Value <= audioManager.VolumeTrack.MinValue) + if (volumeOverlay?.IsMuted.Value == true || audioManager.Volume.Value <= volume_requirement || audioManager.VolumeTrack.Value <= volume_requirement) { notificationOverlay?.Post(new MutedNotification()); muteWarningShownOnce.Value = true; @@ -487,7 +489,7 @@ namespace osu.Game.Screens.Play public MutedNotification() { - Text = "Your music volume is set to 0%! Click here to restore it."; + Text = "Your game volume is too low to hear anything! Click here to restore it."; } [BackgroundDependencyLoader] @@ -501,8 +503,12 @@ namespace osu.Game.Screens.Play notificationOverlay.Hide(); volumeOverlay.IsMuted.Value = false; - audioManager.Volume.SetDefault(); - audioManager.VolumeTrack.SetDefault(); + + // Check values before resetting, as the user may have only had mute enabled, in which case we might not need to adjust volumes. + if (audioManager.Volume.Value <= volume_requirement) + audioManager.Volume.SetDefault(); + if (audioManager.VolumeTrack.Value <= volume_requirement) + audioManager.VolumeTrack.SetDefault(); return true; }; diff --git a/osu.Game/Screens/Play/SoloSpectator.cs b/osu.Game/Screens/Play/SoloSpectator.cs index 45601999a0..ba5663bfa3 100644 --- a/osu.Game/Screens/Play/SoloSpectator.cs +++ b/osu.Game/Screens/Play/SoloSpectator.cs @@ -23,12 +23,10 @@ using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Spectator; using osu.Game.Overlays; using osu.Game.Overlays.Settings; -using osu.Game.Rulesets; using osu.Game.Screens.OnlinePlay.Match.Components; using osu.Game.Screens.Spectate; using osu.Game.Users; using osuTK; -using APIUser = osu.Game.Online.API.Requests.Responses.APIUser; namespace osu.Game.Screens.Play { @@ -44,9 +42,6 @@ namespace osu.Game.Screens.Play [Resolved] private PreviewTrackManager previewTrackManager { get; set; } - [Resolved] - private RulesetStore rulesets { get; set; } - [Resolved] private BeatmapManager beatmaps { get; set; } @@ -233,7 +228,7 @@ namespace osu.Game.Screens.Play onlineBeatmapRequest.Success += beatmapSet => Schedule(() => { this.beatmapSet = beatmapSet; - beatmapPanelContainer.Child = new BeatmapCard(this.beatmapSet); + beatmapPanelContainer.Child = new BeatmapCard(this.beatmapSet, allowExpansion: false); checkForAutomaticDownload(); }); diff --git a/osu.Game/Screens/Play/SpectatorPlayer.cs b/osu.Game/Screens/Play/SpectatorPlayer.cs index f6a89e7fa9..d42643c416 100644 --- a/osu.Game/Screens/Play/SpectatorPlayer.cs +++ b/osu.Game/Screens/Play/SpectatorPlayer.cs @@ -54,7 +54,7 @@ namespace osu.Game.Screens.Play private void userSentFrames(int userId, FrameDataBundle bundle) { - if (userId != score.ScoreInfo.User.Id) + if (userId != score.ScoreInfo.User.OnlineID) return; if (!LoadedBeatmapSuccessfully) diff --git a/osu.Game/Screens/Play/SubmittingPlayer.cs b/osu.Game/Screens/Play/SubmittingPlayer.cs index 76411c8c6b..c613167908 100644 --- a/osu.Game/Screens/Play/SubmittingPlayer.cs +++ b/osu.Game/Screens/Play/SubmittingPlayer.cs @@ -156,7 +156,9 @@ namespace osu.Game.Screens.Play request.Success += s => { - score.ScoreInfo.OnlineScoreID = s.ID; + score.ScoreInfo.OnlineID = s.ID; + score.ScoreInfo.Position = s.Position; + scoreSubmissionSource.SetResult(true); }; diff --git a/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs b/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs index 0935ee7fb2..beff509dc6 100644 --- a/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs +++ b/osu.Game/Screens/Ranking/Contracted/ContractedPanelTopContent.cs @@ -2,36 +2,42 @@ // See the LICENCE file in the repository root for full licence text. using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Game.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Scoring; namespace osu.Game.Screens.Ranking.Contracted { public class ContractedPanelTopContent : CompositeDrawable { - private readonly ScoreInfo score; + public readonly Bindable ScorePosition = new Bindable(); - public ContractedPanelTopContent(ScoreInfo score) + private OsuSpriteText text; + + public ContractedPanelTopContent() { - this.score = score; - RelativeSizeAxes = Axes.Both; } [BackgroundDependencyLoader] private void load() { - InternalChild = new OsuSpriteText + InternalChild = text = new OsuSpriteText { Anchor = Anchor.TopCentre, Origin = Anchor.TopCentre, Y = 6, - Text = score.Position != null ? $"#{score.Position}" : string.Empty, Font = OsuFont.GetFont(size: 18, weight: FontWeight.Bold) }; } + + protected override void LoadComplete() + { + base.LoadComplete(); + + ScorePosition.BindValueChanged(pos => text.Text = pos.NewValue != null ? $"#{pos.NewValue}" : string.Empty, true); + } } } diff --git a/osu.Game/Screens/Ranking/ScorePanel.cs b/osu.Game/Screens/Ranking/ScorePanel.cs index 6ddecf8297..bc6eb9e366 100644 --- a/osu.Game/Screens/Ranking/ScorePanel.cs +++ b/osu.Game/Screens/Ranking/ScorePanel.cs @@ -4,6 +4,7 @@ using System; using osu.Framework; using osu.Framework.Allocation; +using osu.Framework.Bindables; using osu.Framework.Extensions.Color4Extensions; using osu.Framework.Graphics; using osu.Framework.Graphics.Colour; @@ -78,6 +79,11 @@ namespace osu.Game.Screens.Ranking public event Action StateChanged; + /// + /// The position of the score in the rankings. + /// + public readonly Bindable ScorePosition = new Bindable(); + /// /// An action to be invoked if this is clicked while in an expanded state. /// @@ -103,6 +109,8 @@ namespace osu.Game.Screens.Ranking { Score = score; displayWithFlair = isNewLocalScore; + + ScorePosition.Value = score.Position; } [BackgroundDependencyLoader] @@ -211,8 +219,8 @@ namespace osu.Game.Screens.Ranking topLayerBackground.FadeColour(expanded_top_layer_colour, RESIZE_DURATION, Easing.OutQuint); middleLayerBackground.FadeColour(expanded_middle_layer_colour, RESIZE_DURATION, Easing.OutQuint); - topLayerContentContainer.Add(topLayerContent = new ExpandedPanelTopContent(Score.User).With(d => d.Alpha = 0)); - middleLayerContentContainer.Add(middleLayerContent = new ExpandedPanelMiddleContent(Score, displayWithFlair).With(d => d.Alpha = 0)); + topLayerContentContainer.Add(topLayerContent = new ExpandedPanelTopContent(Score.User) { Alpha = 0 }); + middleLayerContentContainer.Add(middleLayerContent = new ExpandedPanelMiddleContent(Score, displayWithFlair) { Alpha = 0 }); // only the first expanded display should happen with flair. displayWithFlair = false; @@ -224,8 +232,13 @@ namespace osu.Game.Screens.Ranking topLayerBackground.FadeColour(contracted_top_layer_colour, RESIZE_DURATION, Easing.OutQuint); middleLayerBackground.FadeColour(contracted_middle_layer_colour, RESIZE_DURATION, Easing.OutQuint); - topLayerContentContainer.Add(topLayerContent = new ContractedPanelTopContent(Score).With(d => d.Alpha = 0)); - middleLayerContentContainer.Add(middleLayerContent = new ContractedPanelMiddleContent(Score).With(d => d.Alpha = 0)); + topLayerContentContainer.Add(topLayerContent = new ContractedPanelTopContent + { + ScorePosition = { BindTarget = ScorePosition }, + Alpha = 0 + }); + + middleLayerContentContainer.Add(middleLayerContent = new ContractedPanelMiddleContent(Score) { Alpha = 0 }); break; } diff --git a/osu.Game/Screens/Ranking/ScorePanelList.cs b/osu.Game/Screens/Ranking/ScorePanelList.cs index d5b8a4c8ea..f3de48dcf0 100644 --- a/osu.Game/Screens/Ranking/ScorePanelList.cs +++ b/osu.Game/Screens/Ranking/ScorePanelList.cs @@ -13,7 +13,6 @@ using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Events; -using osu.Game.Beatmaps; using osu.Game.Graphics.Containers; using osu.Game.Scoring; using osuTK; @@ -70,9 +69,6 @@ namespace osu.Game.Screens.Ranking [Resolved] private ScoreManager scoreManager { get; set; } - [Resolved] - private BeatmapDifficultyCache difficultyCache { get; set; } - private readonly CancellationTokenSource loadCancellationSource = new CancellationTokenSource(); private readonly Flow flow; private readonly Scroll scroll; @@ -345,7 +341,7 @@ namespace osu.Game.Screens.Ranking private IEnumerable applySorting(IEnumerable drawables) => drawables.OfType() .OrderByDescending(GetLayoutPosition) - .ThenBy(s => s.Panel.Score.OnlineScoreID); + .ThenBy(s => s.Panel.Score.OnlineID); } private class Scroll : OsuScrollContainer diff --git a/osu.Game/Screens/Ranking/SoloResultsScreen.cs b/osu.Game/Screens/Ranking/SoloResultsScreen.cs index 929bda6508..afebc728b4 100644 --- a/osu.Game/Screens/Ranking/SoloResultsScreen.cs +++ b/osu.Game/Screens/Ranking/SoloResultsScreen.cs @@ -31,7 +31,7 @@ namespace osu.Game.Screens.Ranking return null; getScoreRequest = new GetScoresRequest(Score.BeatmapInfo, Score.Ruleset); - getScoreRequest.Success += r => scoresCallback?.Invoke(r.Scores.Where(s => s.OnlineID != Score.OnlineScoreID).Select(s => s.CreateScoreInfo(rulesets, Beatmap.Value.BeatmapInfo))); + getScoreRequest.Success += r => scoresCallback?.Invoke(r.Scores.Where(s => s.OnlineID != Score.OnlineID).Select(s => s.CreateScoreInfo(rulesets, Beatmap.Value.BeatmapInfo))); return getScoreRequest; } diff --git a/osu.Game/Screens/Select/BeatmapDetails.cs b/osu.Game/Screens/Select/BeatmapDetails.cs index 7543c89f17..bbe0a37d8e 100644 --- a/osu.Game/Screens/Select/BeatmapDetails.cs +++ b/osu.Game/Screens/Select/BeatmapDetails.cs @@ -16,7 +16,6 @@ using osu.Game.Online; using osu.Game.Online.API; using osu.Game.Online.API.Requests; using osu.Game.Overlays.BeatmapSet; -using osu.Game.Rulesets; using osu.Game.Screens.Select.Details; using osuTK; using osuTK.Graphics; @@ -38,9 +37,6 @@ namespace osu.Game.Screens.Select [Resolved] private IAPIProvider api { get; set; } - [Resolved] - private RulesetStore rulesets { get; set; } - private IBeatmapInfo beatmapInfo; private APIFailTimes failTimes; diff --git a/osu.Game/Screens/Select/BeatmapInfoWedge.cs b/osu.Game/Screens/Select/BeatmapInfoWedge.cs index e4cf9bd868..6791565828 100644 --- a/osu.Game/Screens/Select/BeatmapInfoWedge.cs +++ b/osu.Game/Screens/Select/BeatmapInfoWedge.cs @@ -163,7 +163,7 @@ namespace osu.Game.Screens.Select private FillFlowContainer infoLabelContainer; private Container bpmLabelContainer; - private readonly WorkingBeatmap beatmap; + private readonly WorkingBeatmap working; private readonly RulesetInfo ruleset; [Resolved] @@ -171,10 +171,10 @@ namespace osu.Game.Screens.Select private ModSettingChangeTracker settingChangeTracker; - public WedgeInfoText(WorkingBeatmap beatmap, RulesetInfo userRuleset) + public WedgeInfoText(WorkingBeatmap working, RulesetInfo userRuleset) { - this.beatmap = beatmap; - ruleset = userRuleset ?? beatmap.BeatmapInfo.Ruleset; + this.working = working; + ruleset = userRuleset ?? working.BeatmapInfo.Ruleset; } private CancellationTokenSource cancellationSource; @@ -183,8 +183,8 @@ namespace osu.Game.Screens.Select [BackgroundDependencyLoader] private void load(OsuColour colours, LocalisationManager localisation, BeatmapDifficultyCache difficultyCache) { - var beatmapInfo = beatmap.BeatmapInfo; - var metadata = beatmapInfo.Metadata ?? beatmap.BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); + var beatmapInfo = working.BeatmapInfo; + var metadata = beatmapInfo.Metadata ?? working.BeatmapSetInfo?.Metadata ?? new BeatmapMetadata(); RelativeSizeAxes = Axes.Both; @@ -330,36 +330,9 @@ namespace osu.Game.Screens.Select addInfoLabels(); } - private void setMetadata(string source) + protected override void LoadComplete() { - ArtistLabel.Text = artistBinding.Value; - TitleLabel.Text = string.IsNullOrEmpty(source) ? titleBinding.Value : source + " — " + titleBinding.Value; - } - - private void addInfoLabels() - { - if (beatmap.Beatmap?.HitObjects?.Any() != true) - return; - - infoLabelContainer.Children = new Drawable[] - { - new InfoLabel(new BeatmapStatistic - { - Name = "Length", - CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Length), - Content = beatmap.BeatmapInfo.Length.ToFormattedDuration().ToString(), - }), - bpmLabelContainer = new Container - { - AutoSizeAxes = Axes.Both, - }, - new FillFlowContainer - { - AutoSizeAxes = Axes.Both, - Spacing = new Vector2(20, 0), - Children = getRulesetInfoLabels() - } - }; + base.LoadComplete(); mods.BindValueChanged(m => { @@ -372,6 +345,38 @@ namespace osu.Game.Screens.Select }, true); } + private void setMetadata(string source) + { + ArtistLabel.Text = artistBinding.Value; + TitleLabel.Text = string.IsNullOrEmpty(source) ? titleBinding.Value : source + " — " + titleBinding.Value; + } + + private void addInfoLabels() + { + if (working.Beatmap?.HitObjects?.Any() != true) + return; + + infoLabelContainer.Children = new Drawable[] + { + new InfoLabel(new BeatmapStatistic + { + Name = "Length", + CreateIcon = () => new BeatmapStatisticIcon(BeatmapStatisticsIconType.Length), + Content = working.BeatmapInfo.Length.ToFormattedDuration().ToString(), + }), + bpmLabelContainer = new Container + { + AutoSizeAxes = Axes.Both, + }, + new FillFlowContainer + { + AutoSizeAxes = Axes.Both, + Spacing = new Vector2(20, 0), + Children = getRulesetInfoLabels() + } + }; + } + private InfoLabel[] getRulesetInfoLabels() { try @@ -381,12 +386,12 @@ namespace osu.Game.Screens.Select try { // Try to get the beatmap with the user's ruleset - playableBeatmap = beatmap.GetPlayableBeatmap(ruleset, Array.Empty()); + playableBeatmap = working.GetPlayableBeatmap(ruleset, Array.Empty()); } catch (BeatmapInvalidForRulesetException) { // Can't be converted to the user's ruleset, so use the beatmap's own ruleset - playableBeatmap = beatmap.GetPlayableBeatmap(beatmap.BeatmapInfo.Ruleset, Array.Empty()); + playableBeatmap = working.GetPlayableBeatmap(working.BeatmapInfo.Ruleset, Array.Empty()); } return playableBeatmap.GetStatistics().Select(s => new InfoLabel(s)).ToArray(); @@ -401,8 +406,9 @@ namespace osu.Game.Screens.Select private void refreshBPMLabel() { - var b = beatmap.Beatmap; - if (b == null) + var beatmap = working.Beatmap; + + if (beatmap == null || bpmLabelContainer == null) return; // this doesn't consider mods which apply variable rates, yet. @@ -410,9 +416,9 @@ namespace osu.Game.Screens.Select foreach (var mod in mods.Value.OfType()) rate = mod.ApplyToRate(0, rate); - double bpmMax = b.ControlPointInfo.BPMMaximum * rate; - double bpmMin = b.ControlPointInfo.BPMMinimum * rate; - double mostCommonBPM = 60000 / b.GetMostCommonBeatLength() * rate; + double bpmMax = beatmap.ControlPointInfo.BPMMaximum * rate; + double bpmMin = beatmap.ControlPointInfo.BPMMinimum * rate; + double mostCommonBPM = 60000 / beatmap.GetMostCommonBeatLength() * rate; string labelText = Precision.AlmostEquals(bpmMin, bpmMax) ? $"{bpmMin:0}" diff --git a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs index 1fd6d8c921..872e630ba0 100644 --- a/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs +++ b/osu.Game/Screens/Select/Leaderboards/BeatmapLeaderboard.cs @@ -64,9 +64,6 @@ namespace osu.Game.Screens.Select.Leaderboards [Resolved] private ScoreManager scoreManager { get; set; } - [Resolved] - private BeatmapDifficultyCache difficultyCache { get; set; } - [Resolved] private IBindable ruleset { get; set; } diff --git a/osu.Game/Screens/Select/SongSelect.cs b/osu.Game/Screens/Select/SongSelect.cs index f715c7ff59..08ad9f2ec0 100644 --- a/osu.Game/Screens/Select/SongSelect.cs +++ b/osu.Game/Screens/Select/SongSelect.cs @@ -807,14 +807,14 @@ namespace osu.Game.Screens.Select private void delete(BeatmapSetInfo beatmap) { - if (beatmap == null || beatmap.ID <= 0) return; + if (beatmap == null || !beatmap.IsManaged) return; dialogOverlay?.Push(new BeatmapDeleteDialog(beatmap)); } private void clearScores(BeatmapInfo beatmapInfo) { - if (beatmapInfo == null || beatmapInfo.ID <= 0) return; + if (beatmapInfo == null || !beatmapInfo.IsManaged) return; dialogOverlay?.Push(new BeatmapClearScoresDialog(beatmapInfo, () => // schedule done here rather than inside the dialog as the dialog may fade out and never callback. diff --git a/osu.Game/Skinning/DefaultLegacySkin.cs b/osu.Game/Skinning/DefaultLegacySkin.cs index cd6dbd9ddd..c7033d37dc 100644 --- a/osu.Game/Skinning/DefaultLegacySkin.cs +++ b/osu.Game/Skinning/DefaultLegacySkin.cs @@ -12,8 +12,17 @@ namespace osu.Game.Skinning { public class DefaultLegacySkin : LegacySkin { + public static SkinInfo CreateInfo() => new SkinInfo + { + ID = Skinning.SkinInfo.CLASSIC_SKIN, // this is temporary until database storage is decided upon. + Name = "osu!classic", + Creator = "team osu!", + Protected = true, + InstantiationInfo = typeof(DefaultLegacySkin).GetInvariantInstantiationInfo() + }; + public DefaultLegacySkin(IStorageResourceProvider resources) - : this(Info, resources) + : this(CreateInfo(), resources) { } @@ -25,7 +34,7 @@ namespace osu.Game.Skinning resources, // A default legacy skin may still have a skin.ini if it is modified by the user. // We must specify the stream directly as we are redirecting storage to the osu-resources location for other files. - new LegacySkinResourceStore(skin, resources.Files).GetStream("skin.ini") + new LegacyDatabasedSkinResourceStore(skin, resources.Files).GetStream("skin.ini") ) { Configuration.CustomColours["SliderBall"] = new Color4(2, 170, 255, 255); @@ -39,13 +48,5 @@ namespace osu.Game.Skinning Configuration.LegacyVersion = 2.7m; } - - public static SkinInfo Info { get; } = new SkinInfo - { - ID = SkinInfo.CLASSIC_SKIN, // this is temporary until database storage is decided upon. - Name = "osu!classic", - Creator = "team osu!", - InstantiationInfo = typeof(DefaultLegacySkin).GetInvariantInstantiationInfo() - }; } } diff --git a/osu.Game/Skinning/DefaultSkin.cs b/osu.Game/Skinning/DefaultSkin.cs index c377f16f8b..951e3f9cc5 100644 --- a/osu.Game/Skinning/DefaultSkin.cs +++ b/osu.Game/Skinning/DefaultSkin.cs @@ -23,10 +23,19 @@ namespace osu.Game.Skinning { public class DefaultSkin : Skin { + public static SkinInfo CreateInfo() => new SkinInfo + { + ID = osu.Game.Skinning.SkinInfo.DEFAULT_SKIN, + Name = "osu! (triangles)", + Creator = "team osu!", + Protected = true, + InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo() + }; + private readonly IStorageResourceProvider resources; public DefaultSkin(IStorageResourceProvider resources) - : this(SkinInfo.Default, resources) + : this(CreateInfo(), resources) { } diff --git a/osu.Game/Skinning/EFSkinInfo.cs b/osu.Game/Skinning/EFSkinInfo.cs new file mode 100644 index 0000000000..50588563be --- /dev/null +++ b/osu.Game/Skinning/EFSkinInfo.cs @@ -0,0 +1,63 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using osu.Framework.Extensions.ObjectExtensions; +using osu.Game.Database; +using osu.Game.Extensions; +using osu.Game.IO; + +namespace osu.Game.Skinning +{ + [Table(@"SkinInfo")] + public class EFSkinInfo : IHasFiles, IEquatable, IHasPrimaryKey, ISoftDelete + { + internal const int DEFAULT_SKIN = 0; + internal const int CLASSIC_SKIN = -1; + internal const int RANDOM_SKIN = -2; + + public int ID { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Creator { get; set; } = string.Empty; + + public string Hash { get; set; } + + public string InstantiationInfo { get; set; } + + public virtual Skin CreateInstance(IStorageResourceProvider resources) + { + var type = string.IsNullOrEmpty(InstantiationInfo) + // handle the case of skins imported before InstantiationInfo was added. + ? typeof(LegacySkin) + : Type.GetType(InstantiationInfo).AsNonNull(); + + return (Skin)Activator.CreateInstance(type, this, resources); + } + + public List Files { get; set; } = new List(); + + public bool DeletePending { get; set; } + + public static EFSkinInfo Default { get; } = new EFSkinInfo + { + ID = DEFAULT_SKIN, + Name = "osu! (triangles)", + Creator = "team osu!", + InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo() + }; + + public bool Equals(EFSkinInfo other) => other != null && ID == other.ID; + + public override string ToString() + { + string author = Creator == null ? string.Empty : $"({Creator})"; + return $"{Name} {author}".Trim(); + } + + public bool IsManaged => ID > 0; + } +} diff --git a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs index d27122aea8..340c6ed931 100644 --- a/osu.Game/Skinning/Editor/SkinEditorOverlay.cs +++ b/osu.Game/Skinning/Editor/SkinEditorOverlay.cs @@ -3,13 +3,11 @@ using System.Diagnostics; using JetBrains.Annotations; -using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Graphics; using osu.Framework.Graphics.Containers; using osu.Framework.Input.Bindings; using osu.Framework.Input.Events; -using osu.Game.Graphics; using osu.Game.Graphics.Containers; using osu.Game.Input.Bindings; @@ -28,9 +26,6 @@ namespace osu.Game.Skinning.Editor public const float VISIBLE_TARGET_SCALE = 0.8f; - [Resolved] - private OsuColour colours { get; set; } - public SkinEditorOverlay(ScalingContainer target) { this.target = target; diff --git a/osu.Game/Skinning/LegacyAccuracyCounter.cs b/osu.Game/Skinning/LegacyAccuracyCounter.cs index fd5a9500d9..bdcb85456a 100644 --- a/osu.Game/Skinning/LegacyAccuracyCounter.cs +++ b/osu.Game/Skinning/LegacyAccuracyCounter.cs @@ -1,10 +1,8 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using osu.Framework.Allocation; using osu.Framework.Graphics; using osu.Game.Graphics.Sprites; -using osu.Game.Screens.Play; using osu.Game.Screens.Play.HUD; using osuTK; @@ -23,9 +21,6 @@ namespace osu.Game.Skinning Margin = new MarginPadding(10); } - [Resolved(canBeNull: true)] - private HUDOverlay hud { get; set; } - protected sealed override OsuSpriteText CreateSpriteText() => new LegacySpriteText(LegacyFont.Score) { Anchor = Anchor.TopRight, diff --git a/osu.Game/Skinning/LegacyBeatmapSkin.cs b/osu.Game/Skinning/LegacyBeatmapSkin.cs index 8abef6800d..d44d3dce49 100644 --- a/osu.Game/Skinning/LegacyBeatmapSkin.cs +++ b/osu.Game/Skinning/LegacyBeatmapSkin.cs @@ -21,7 +21,7 @@ namespace osu.Game.Skinning protected override bool UseCustomSampleBanks => true; public LegacyBeatmapSkin(BeatmapInfo beatmapInfo, IResourceStore storage, IStorageResourceProvider resources) - : base(createSkinInfo(beatmapInfo), new LegacySkinResourceStore(beatmapInfo.BeatmapSet, storage), resources, beatmapInfo.Path) + : base(createSkinInfo(beatmapInfo), new LegacySkinResourceStore(beatmapInfo.BeatmapSet, storage), resources, beatmapInfo.Path) { // Disallow default colours fallback on beatmap skins to allow using parent skin combo colours. (via SkinProvidingContainer) Configuration.AllowDefaultComboColoursFallback = false; @@ -77,6 +77,6 @@ namespace osu.Game.Skinning } private static SkinInfo createSkinInfo(BeatmapInfo beatmapInfo) => - new SkinInfo { Name = beatmapInfo.ToString(), Creator = beatmapInfo.Metadata?.Author.Username }; + new SkinInfo { Name = beatmapInfo.ToString(), Creator = beatmapInfo.Metadata?.Author.Username ?? string.Empty }; } } diff --git a/osu.Game/Skinning/LegacyDatabasedSkinResourceStore.cs b/osu.Game/Skinning/LegacyDatabasedSkinResourceStore.cs new file mode 100644 index 0000000000..cd90fea9bb --- /dev/null +++ b/osu.Game/Skinning/LegacyDatabasedSkinResourceStore.cs @@ -0,0 +1,43 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Framework.Extensions; +using osu.Framework.IO.Stores; +using osu.Game.Extensions; + +namespace osu.Game.Skinning +{ + public class LegacyDatabasedSkinResourceStore : ResourceStore + { + private readonly Dictionary fileToStoragePathMapping = new Dictionary(); + + public LegacyDatabasedSkinResourceStore(SkinInfo source, IResourceStore underlyingStore) + : base(underlyingStore) + { + initialiseFileCache(source); + } + + private void initialiseFileCache(SkinInfo source) + { + fileToStoragePathMapping.Clear(); + foreach (var f in source.Files) + fileToStoragePathMapping[f.Filename.ToLowerInvariant()] = f.File.GetStoragePath(); + } + + protected override IEnumerable GetFilenames(string name) + { + foreach (string filename in base.GetFilenames(name)) + { + string path = getPathForFile(filename.ToStandardisedPath()); + if (path != null) + yield return path; + } + } + + private string getPathForFile(string filename) => + fileToStoragePathMapping.TryGetValue(filename.ToLower(), out string path) ? path : null; + + public override IEnumerable GetAvailableResources() => fileToStoragePathMapping.Keys; + } +} diff --git a/osu.Game/Skinning/LegacySkin.cs b/osu.Game/Skinning/LegacySkin.cs index 0e7ae95169..e677e2c01b 100644 --- a/osu.Game/Skinning/LegacySkin.cs +++ b/osu.Game/Skinning/LegacySkin.cs @@ -51,7 +51,7 @@ namespace osu.Game.Skinning [UsedImplicitly(ImplicitUseKindFlags.InstantiatedWithFixedConstructorSignature)] public LegacySkin(SkinInfo skin, IStorageResourceProvider resources) - : this(skin, new LegacySkinResourceStore(skin, resources.Files), resources, "skin.ini") + : this(skin, new LegacyDatabasedSkinResourceStore(skin, resources.Files), resources, "skin.ini") { } diff --git a/osu.Game/Skinning/LegacySkinResourceStore.cs b/osu.Game/Skinning/LegacySkinResourceStore.cs index c4418baeff..2487a469c8 100644 --- a/osu.Game/Skinning/LegacySkinResourceStore.cs +++ b/osu.Game/Skinning/LegacySkinResourceStore.cs @@ -11,12 +11,11 @@ using osu.Game.Extensions; namespace osu.Game.Skinning { - public class LegacySkinResourceStore : ResourceStore - where T : INamedFileInfo + public class LegacySkinResourceStore : ResourceStore { - private readonly IHasFiles source; + private readonly IHasNamedFiles source; - public LegacySkinResourceStore(IHasFiles source, IResourceStore underlyingStore) + public LegacySkinResourceStore(IHasNamedFiles source, IResourceStore underlyingStore) : base(underlyingStore) { this.source = source; @@ -33,7 +32,7 @@ namespace osu.Game.Skinning } private string getPathForFile(string filename) => - source.Files.Find(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.FileInfo.GetStoragePath(); + source.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath(); public override IEnumerable GetAvailableResources() => source.Files.Select(f => f.Filename); } diff --git a/osu.Game/Skinning/PoolableSkinnableSample.cs b/osu.Game/Skinning/PoolableSkinnableSample.cs index 3fcca74fb8..5db4f00b46 100644 --- a/osu.Game/Skinning/PoolableSkinnableSample.cs +++ b/osu.Game/Skinning/PoolableSkinnableSample.cs @@ -4,7 +4,6 @@ using System; using System.Linq; using JetBrains.Annotations; -using osu.Framework.Allocation; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; @@ -30,9 +29,6 @@ namespace osu.Game.Skinning private ISampleInfo sampleInfo; private SampleChannel activeChannel; - [Resolved] - private ISampleStore sampleStore { get; set; } - /// /// Creates a new with no applied . /// An can be applied later via . diff --git a/osu.Game/Skinning/Skin.cs b/osu.Game/Skinning/Skin.cs index 10526b69af..d606d94b97 100644 --- a/osu.Game/Skinning/Skin.cs +++ b/osu.Game/Skinning/Skin.cs @@ -15,6 +15,7 @@ using osu.Framework.Graphics.OpenGL.Textures; using osu.Framework.Graphics.Textures; using osu.Framework.Logging; using osu.Game.Audio; +using osu.Game.Database; using osu.Game.Extensions; using osu.Game.IO; using osu.Game.Screens.Play.HUD; @@ -23,7 +24,7 @@ namespace osu.Game.Skinning { public abstract class Skin : IDisposable, ISkin { - public readonly SkinInfo SkinInfo; + public readonly ILive SkinInfo; private readonly IStorageResourceProvider resources; public SkinConfiguration Configuration { get; set; } @@ -42,7 +43,11 @@ namespace osu.Game.Skinning protected Skin(SkinInfo skin, IStorageResourceProvider resources, [CanBeNull] Stream configurationStream = null) { - SkinInfo = skin; + SkinInfo = resources?.RealmContextFactory != null + ? skin.ToLive(resources.RealmContextFactory) + // This path should only be used in some tests. + : skin.ToLiveUnmanaged(); + this.resources = resources; configurationStream ??= getConfigurationStream(); @@ -53,37 +58,41 @@ namespace osu.Game.Skinning else Configuration = new SkinConfiguration(); - // we may want to move this to some kind of async operation in the future. - foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget))) + // skininfo files may be null for default skin. + SkinInfo.PerformRead(s => { - string filename = $"{skinnableTarget}.json"; - - // skininfo files may be null for default skin. - var fileInfo = SkinInfo.Files.FirstOrDefault(f => f.Filename == filename); - - if (fileInfo == null) - continue; - - byte[] bytes = resources?.Files.Get(fileInfo.FileInfo.GetStoragePath()); - - if (bytes == null) - continue; - - try + // we may want to move this to some kind of async operation in the future. + foreach (SkinnableTarget skinnableTarget in Enum.GetValues(typeof(SkinnableTarget))) { - string jsonContent = Encoding.UTF8.GetString(bytes); - var deserializedContent = JsonConvert.DeserializeObject>(jsonContent); + string filename = $"{skinnableTarget}.json"; - if (deserializedContent == null) + // skininfo files may be null for default skin. + var fileInfo = s.Files.FirstOrDefault(f => f.Filename == filename); + + if (fileInfo == null) continue; - DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray(); + byte[] bytes = resources?.Files.Get(fileInfo.File.GetStoragePath()); + + if (bytes == null) + continue; + + try + { + string jsonContent = Encoding.UTF8.GetString(bytes); + var deserializedContent = JsonConvert.DeserializeObject>(jsonContent); + + if (deserializedContent == null) + continue; + + DrawableComponentInfo[skinnableTarget] = deserializedContent.ToArray(); + } + catch (Exception ex) + { + Logger.Error(ex, "Failed to load skin configuration."); + } } - catch (Exception ex) - { - Logger.Error(ex, "Failed to load skin configuration."); - } - } + }); } protected virtual void ParseConfigurationStream(Stream stream) @@ -94,7 +103,7 @@ namespace osu.Game.Skinning private Stream getConfigurationStream() { - string path = SkinInfo.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))?.FileInfo.GetStoragePath(); + string path = SkinInfo.PerformRead(s => s.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))?.File.GetStoragePath()); if (string.IsNullOrEmpty(path)) return null; diff --git a/osu.Game/Skinning/SkinFileInfo.cs b/osu.Game/Skinning/SkinFileInfo.cs index db7cd953bb..4f1bf68e51 100644 --- a/osu.Game/Skinning/SkinFileInfo.cs +++ b/osu.Game/Skinning/SkinFileInfo.cs @@ -11,8 +11,12 @@ namespace osu.Game.Skinning { public int ID { get; set; } + public bool IsManaged => ID > 0; + public int SkinInfoID { get; set; } + public EFSkinInfo SkinInfo { get; set; } + public int FileInfoID { get; set; } public FileInfo FileInfo { get; set; } diff --git a/osu.Game/Skinning/SkinInfo.cs b/osu.Game/Skinning/SkinInfo.cs index 5d2d51a9b0..fee8c3edb2 100644 --- a/osu.Game/Skinning/SkinInfo.cs +++ b/osu.Game/Skinning/SkinInfo.cs @@ -3,28 +3,43 @@ using System; using System.Collections.Generic; +using Newtonsoft.Json; using osu.Framework.Extensions.ObjectExtensions; +using osu.Framework.Testing; using osu.Game.Database; -using osu.Game.Extensions; using osu.Game.IO; +using osu.Game.Models; +using Realms; + +#nullable enable namespace osu.Game.Skinning { - public class SkinInfo : IHasFiles, IEquatable, IHasPrimaryKey, ISoftDelete, IHasNamedFiles + [ExcludeFromDynamicCompile] + [MapTo("Skin")] + [JsonObject(MemberSerialization.OptIn)] + public class SkinInfo : RealmObject, IHasRealmFiles, IEquatable, IHasGuidPrimaryKey, ISoftDelete, IHasNamedFiles { - internal const int DEFAULT_SKIN = 0; - internal const int CLASSIC_SKIN = -1; - internal const int RANDOM_SKIN = -2; + internal static readonly Guid DEFAULT_SKIN = new Guid("2991CFD8-2140-469A-BCB9-2EC23FBCE4AD"); + internal static readonly Guid CLASSIC_SKIN = new Guid("81F02CD3-EEC6-4865-AC23-FAE26A386187"); + internal static readonly Guid RANDOM_SKIN = new Guid("D39DFEFB-477C-4372-B1EA-2BCEA5FB8908"); - public int ID { get; set; } + [PrimaryKey] + [JsonProperty] + public Guid ID { get; set; } = Guid.NewGuid(); + [JsonProperty] public string Name { get; set; } = string.Empty; + [JsonProperty] public string Creator { get; set; } = string.Empty; - public string Hash { get; set; } + [JsonProperty] + public string InstantiationInfo { get; set; } = string.Empty; - public string InstantiationInfo { get; set; } + public string Hash { get; set; } = string.Empty; + + public bool Protected { get; set; } public virtual Skin CreateInstance(IStorageResourceProvider resources) { @@ -36,23 +51,21 @@ namespace osu.Game.Skinning return (Skin)Activator.CreateInstance(type, this, resources); } - public List Files { get; } = new List(); + public IList Files { get; } = null!; public bool DeletePending { get; set; } - public static SkinInfo Default { get; } = new SkinInfo + public bool Equals(SkinInfo? other) { - ID = DEFAULT_SKIN, - Name = "osu! (triangles)", - Creator = "team osu!", - InstantiationInfo = typeof(DefaultSkin).GetInvariantInstantiationInfo() - }; + if (ReferenceEquals(this, other)) return true; + if (other == null) return false; - public bool Equals(SkinInfo other) => other != null && ID == other.ID; + return ID == other.ID; + } public override string ToString() { - string author = Creator == null ? string.Empty : $"({Creator})"; + string author = string.IsNullOrEmpty(Creator) ? string.Empty : $"({Creator})"; return $"{Name} {author}".Trim(); } diff --git a/osu.Game/Skinning/SkinManager.cs b/osu.Game/Skinning/SkinManager.cs index 26ff4457af..bb2f0a37b4 100644 --- a/osu.Game/Skinning/SkinManager.cs +++ b/osu.Game/Skinning/SkinManager.cs @@ -3,14 +3,11 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Linq.Expressions; -using System.Text; using System.Threading; using System.Threading.Tasks; -using Microsoft.EntityFrameworkCore; -using Newtonsoft.Json; +using JetBrains.Annotations; using osu.Framework.Audio; using osu.Framework.Audio.Sample; using osu.Framework.Bindables; @@ -20,6 +17,7 @@ using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Platform; using osu.Framework.Testing; +using osu.Framework.Threading; using osu.Framework.Utils; using osu.Game.Audio; using osu.Game.Database; @@ -37,20 +35,25 @@ namespace osu.Game.Skinning /// For gameplay components, see which adds extra legacy and toggle logic that may affect the lookup process. /// [ExcludeFromDynamicCompile] - public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter, IModelManager + public class SkinManager : ISkinSource, IStorageResourceProvider, IModelImporter { private readonly AudioManager audio; + private readonly Scheduler scheduler; + private readonly GameHost host; private readonly IResourceStore resources; public readonly Bindable CurrentSkin = new Bindable(); - public readonly Bindable CurrentSkinInfo = new Bindable(SkinInfo.Default) { Default = SkinInfo.Default }; + + public readonly Bindable> CurrentSkinInfo = new Bindable>(Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged()) + { + Default = Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged() + }; private readonly SkinModelManager skinModelManager; - - private readonly SkinStore skinStore; + private readonly RealmContextFactory contextFactory; private readonly IResourceStore userFiles; @@ -64,69 +67,66 @@ namespace osu.Game.Skinning /// public Skin DefaultLegacySkin { get; } - public SkinManager(Storage storage, DatabaseContextFactory contextFactory, GameHost host, IResourceStore resources, AudioManager audio) + public SkinManager(Storage storage, RealmContextFactory contextFactory, GameHost host, IResourceStore resources, AudioManager audio, Scheduler scheduler) { + this.contextFactory = contextFactory; this.audio = audio; + this.scheduler = scheduler; this.host = host; this.resources = resources; - skinStore = new SkinStore(contextFactory, storage); - userFiles = new FileStore(contextFactory, storage).Store; + userFiles = new StorageBackedResourceStore(storage.GetStorageForDirectory("files")); - skinModelManager = new SkinModelManager(storage, contextFactory, skinStore, host, this); + skinModelManager = new SkinModelManager(storage, contextFactory, host, this); - DefaultLegacySkin = new DefaultLegacySkin(this); - DefaultSkin = new DefaultSkin(this); + var defaultSkins = new[] + { + DefaultLegacySkin = new DefaultLegacySkin(this), + DefaultSkin = new DefaultSkin(this), + }; - CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = GetSkin(skin.NewValue); + // Ensure the default entries are present. + using (var context = contextFactory.CreateContext()) + using (var transaction = context.BeginWrite()) + { + foreach (var skin in defaultSkins) + { + if (context.Find(skin.SkinInfo.ID) == null) + context.Add(skin.SkinInfo.Value); + } + + transaction.Commit(); + } + + CurrentSkinInfo.ValueChanged += skin => CurrentSkin.Value = skin.NewValue.PerformRead(GetSkin); CurrentSkin.Value = DefaultSkin; CurrentSkin.ValueChanged += skin => { - if (skin.NewValue.SkinInfo != CurrentSkinInfo.Value) + if (!skin.NewValue.SkinInfo.Equals(CurrentSkinInfo.Value)) throw new InvalidOperationException($"Setting {nameof(CurrentSkin)}'s value directly is not supported. Use {nameof(CurrentSkinInfo)} instead."); SourceChanged?.Invoke(); }; } - /// - /// Returns a list of all usable s. Includes the special default skin plus all skins from . - /// - /// A newly allocated list of available . - public List GetAllUsableSkins() - { - var userSkins = GetAllUserSkins(); - userSkins.Insert(0, DefaultSkin.SkinInfo); - userSkins.Insert(1, DefaultLegacySkin.SkinInfo); - return userSkins; - } - - /// - /// Returns a list of all usable s that have been loaded by the user. - /// - /// A newly allocated list of available . - public List GetAllUserSkins(bool includeFiles = false) - { - if (includeFiles) - return skinStore.ConsumableItems.Where(s => !s.DeletePending).ToList(); - - return skinStore.Items.Where(s => !s.DeletePending).ToList(); - } - public void SelectRandomSkin() { - // choose from only user skins, removing the current selection to ensure a new one is chosen. - var randomChoices = skinStore.Items.Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray(); - - if (randomChoices.Length == 0) + using (var context = contextFactory.CreateContext()) { - CurrentSkinInfo.Value = SkinInfo.Default; - return; - } + // choose from only user skins, removing the current selection to ensure a new one is chosen. + var randomChoices = context.All().Where(s => !s.DeletePending && s.ID != CurrentSkinInfo.Value.ID).ToArray(); - var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length)); - CurrentSkinInfo.Value = skinStore.ConsumableItems.Single(i => i.ID == chosen.ID); + if (randomChoices.Length == 0) + { + CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged(); + return; + } + + var chosen = randomChoices.ElementAt(RNG.Next(0, randomChoices.Length)); + + CurrentSkinInfo.Value = chosen.ToLive(contextFactory); + } } /// @@ -142,40 +142,36 @@ namespace osu.Game.Skinning /// public void EnsureMutableSkin() { - if (CurrentSkinInfo.Value.ID >= 1) return; - - var skin = CurrentSkin.Value; - - // if the user is attempting to save one of the default skin implementations, create a copy first. - CurrentSkinInfo.Value = skinModelManager.Import(new SkinInfo + CurrentSkinInfo.Value.PerformRead(s => { - Name = skin.SkinInfo.Name + @" (modified)", - Creator = skin.SkinInfo.Creator, - InstantiationInfo = skin.SkinInfo.InstantiationInfo, - }).Result.Value; + if (!s.Protected) + return; + + // if the user is attempting to save one of the default skin implementations, create a copy first. + var result = skinModelManager.Import(new SkinInfo + { + Name = s.Name + @" (modified)", + Creator = s.Creator, + InstantiationInfo = s.InstantiationInfo, + }).Result; + + if (result != null) + { + // save once to ensure the required json content is populated. + // currently this only happens on save. + result.PerformRead(skin => Save(skin.CreateInstance(this))); + + CurrentSkinInfo.Value = result; + } + }); } public void Save(Skin skin) { - if (skin.SkinInfo.ID <= 0) + if (!skin.SkinInfo.IsManaged) throw new InvalidOperationException($"Attempting to save a skin which is not yet tracked. Call {nameof(EnsureMutableSkin)} first."); - foreach (var drawableInfo in skin.DrawableComponentInfo) - { - string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented }); - - using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(json))) - { - string filename = @$"{drawableInfo.Key}.json"; - - var oldFile = skin.SkinInfo.Files.FirstOrDefault(f => f.Filename == filename); - - if (oldFile != null) - skinModelManager.ReplaceFile(skin.SkinInfo, oldFile, streamContent); - else - skinModelManager.AddFile(skin.SkinInfo, streamContent, filename); - } - } + skinModelManager.Save(skin); } /// @@ -183,7 +179,11 @@ namespace osu.Game.Skinning /// /// The query. /// The first result for the provided query, or null if no results were found. - public SkinInfo Query(Expression> query) => skinStore.ConsumableItems.AsNoTracking().FirstOrDefault(query); + public ILive Query(Expression> query) + { + using (var context = contextFactory.CreateContext()) + return context.All().FirstOrDefault(query)?.ToLive(contextFactory); + } public event Action SourceChanged; @@ -237,6 +237,7 @@ namespace osu.Game.Skinning AudioManager IStorageResourceProvider.AudioManager => audio; IResourceStore IStorageResourceProvider.Resources => resources; IResourceStore IStorageResourceProvider.Files => userFiles; + RealmContextFactory IStorageResourceProvider.RealmContextFactory => contextFactory; IResourceStore IStorageResourceProvider.CreateTextureLoaderStore(IResourceStore underlyingStore) => host.CreateTextureLoaderStore(underlyingStore); #endregion @@ -289,46 +290,23 @@ namespace osu.Game.Skinning #region Implementation of IModelManager - public event Action ItemUpdated + public void Delete([CanBeNull] Expression> filter = null, bool silent = false) { - add => skinModelManager.ItemUpdated += value; - remove => skinModelManager.ItemUpdated -= value; - } + using (var context = contextFactory.CreateContext()) + { + var items = context.All() + .Where(s => !s.Protected && !s.DeletePending); + if (filter != null) + items = items.Where(filter); - public event Action ItemRemoved - { - add => skinModelManager.ItemRemoved += value; - remove => skinModelManager.ItemRemoved -= value; - } + // check the removed skin is not the current user choice. if it is, switch back to default. + Guid currentUserSkin = CurrentSkinInfo.Value.ID; - public void Update(SkinInfo item) - { - skinModelManager.Update(item); - } + if (items.Any(s => s.ID == currentUserSkin)) + scheduler.Add(() => CurrentSkinInfo.Value = Skinning.DefaultSkin.CreateInfo().ToLiveUnmanaged()); - public bool Delete(SkinInfo item) - { - return skinModelManager.Delete(item); - } - - public void Delete(List items, bool silent = false) - { - skinModelManager.Delete(items, silent); - } - - public void Undelete(List items, bool silent = false) - { - skinModelManager.Undelete(items, silent); - } - - public void Undelete(SkinInfo item) - { - skinModelManager.Undelete(item); - } - - public bool IsAvailableLocally(SkinInfo model) - { - return skinModelManager.IsAvailableLocally(model); + skinModelManager.Delete(items.ToList(), silent); + } } #endregion diff --git a/osu.Game/Skinning/SkinModelManager.cs b/osu.Game/Skinning/SkinModelManager.cs index 572ae5cbfc..822cb8efa0 100644 --- a/osu.Game/Skinning/SkinModelManager.cs +++ b/osu.Game/Skinning/SkinModelManager.cs @@ -8,21 +8,28 @@ using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; +using Newtonsoft.Json; using osu.Framework.Logging; using osu.Framework.Platform; using osu.Game.Database; using osu.Game.Extensions; using osu.Game.IO; using osu.Game.IO.Archives; +using osu.Game.Stores; +using Realms; + +#nullable enable namespace osu.Game.Skinning { - public class SkinModelManager : ArchiveModelManager + public class SkinModelManager : RealmArchiveModelManager { + private const string skin_info_file = "skininfo.json"; + private readonly IStorageResourceProvider skinResources; - public SkinModelManager(Storage storage, DatabaseContextFactory contextFactory, SkinStore skinStore, GameHost host, IStorageResourceProvider skinResources) - : base(storage, contextFactory, skinStore, host) + public SkinModelManager(Storage storage, RealmContextFactory contextFactory, GameHost host, IStorageResourceProvider skinResources) + : base(storage, contextFactory) { this.skinResources = skinResources; @@ -42,18 +49,55 @@ namespace osu.Game.Skinning protected override bool HasCustomHashFunction => true; - protected override string ComputeHash(SkinInfo item) + protected override Task Populate(SkinInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default) + { + var skinInfoFile = model.Files.SingleOrDefault(f => f.Filename == skin_info_file); + + if (skinInfoFile != null) + { + try + { + using (var existingStream = Files.Storage.GetStream(skinInfoFile.File.GetStoragePath())) + using (var reader = new StreamReader(existingStream)) + { + var deserialisedSkinInfo = JsonConvert.DeserializeObject(reader.ReadToEnd()); + + if (deserialisedSkinInfo != null) + { + // for now we only care about the instantiation info. + // eventually we probably want to transfer everything across. + model.InstantiationInfo = deserialisedSkinInfo.InstantiationInfo; + } + } + } + catch (Exception e) + { + LogForModel(model, $"Error during {skin_info_file} parsing, falling back to default", e); + + // Not sure if we should still run the import in the case of failure here, but let's do so for now. + model.InstantiationInfo = string.Empty; + } + } + + // Always rewrite instantiation info (even after parsing in from the skin json) for sanity. + model.InstantiationInfo = createInstance(model).GetType().GetInvariantInstantiationInfo(); + + checkSkinIniMetadata(model, realm); + + return Task.CompletedTask; + } + + private void checkSkinIniMetadata(SkinInfo item, Realm realm) { var instance = createInstance(item); // This function can be run on fresh import or save. The logic here ensures a skin.ini file is in a good state for both operations. - // `Skin` will parse the skin.ini and populate `Skin.Configuration` during construction above. string skinIniSourcedName = instance.Configuration.SkinInfo.Name; string skinIniSourcedCreator = instance.Configuration.SkinInfo.Creator; string archiveName = item.Name.Replace(@".osk", string.Empty, StringComparison.OrdinalIgnoreCase); - bool isImport = item.ID == 0; + bool isImport = !item.IsManaged; if (isImport) { @@ -71,12 +115,10 @@ namespace osu.Game.Skinning // Regardless of whether this is an import or not, let's write the skin.ini if non-existing or non-matching. // This is (weirdly) done inside ComputeHash to avoid adding a new method to handle this case. After switching to realm it can be moved into another place. if (skinIniSourcedName != item.Name) - updateSkinIniMetadata(item); - - return base.ComputeHash(item); + updateSkinIniMetadata(item, realm); } - private void updateSkinIniMetadata(SkinInfo item) + private void updateSkinIniMetadata(SkinInfo item, Realm realm) { string nameLine = @$"Name: {item.Name}"; string authorLine = @$"Author: {item.Creator}"; @@ -95,39 +137,47 @@ namespace osu.Game.Skinning { // In the case a skin doesn't have a skin.ini yet, let's create one. writeNewSkinIni(); - return; } - - using (Stream stream = new MemoryStream()) + else { - using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) + using (Stream stream = new MemoryStream()) { - using (var existingStream = Files.Storage.GetStream(existingFile.FileInfo.GetStoragePath())) - using (var sr = new StreamReader(existingStream)) + using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true)) { - string line; - while ((line = sr.ReadLine()) != null) + using (var existingStream = Files.Storage.GetStream(existingFile.File.GetStoragePath())) + using (var sr = new StreamReader(existingStream)) + { + string? line; + while ((line = sr.ReadLine()) != null) + sw.WriteLine(line); + } + + sw.WriteLine(); + + foreach (string line in newLines) sw.WriteLine(line); } - sw.WriteLine(); + ReplaceFile(existingFile, stream, realm); - foreach (string line in newLines) - sw.WriteLine(line); - } + // can be removed 20220502. + if (!ensureIniWasUpdated(item)) + { + Logger.Log($"Skin {item}'s skin.ini had issues and has been removed. Please report this and provide the problematic skin.", LoggingTarget.Database, LogLevel.Important); - ReplaceFile(item, existingFile, stream); + var existingIni = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase)); + if (existingIni != null) + item.Files.Remove(existingIni); - // can be removed 20220502. - if (!ensureIniWasUpdated(item)) - { - Logger.Log($"Skin {item}'s skin.ini had issues and has been removed. Please report this and provide the problematic skin.", LoggingTarget.Database, LogLevel.Important); - - DeleteFile(item, item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase))); - writeNewSkinIni(); + writeNewSkinIni(); + } } } + // The hash is already populated at this point in import. + // As we have changed files, it needs to be recomputed. + item.Hash = ComputeHash(item); + void writeNewSkinIni() { using (Stream stream = new MemoryStream()) @@ -138,8 +188,10 @@ namespace osu.Game.Skinning sw.WriteLine(line); } - AddFile(item, stream, @"skin.ini"); + AddFile(item, stream, @"skin.ini", realm); } + + item.Hash = ComputeHash(item); } } @@ -154,36 +206,61 @@ namespace osu.Game.Skinning return instance.Configuration.SkinInfo.Name == item.Name; } - protected override Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default) - { - var instance = createInstance(model); - - model.InstantiationInfo ??= instance.GetType().GetInvariantInstantiationInfo(); - - model.Name = instance.Configuration.SkinInfo.Name; - model.Creator = instance.Configuration.SkinInfo.Creator; - - return Task.CompletedTask; - } - private void populateMissingHashes() { - var skinsWithoutHashes = ModelStore.ConsumableItems.Where(i => i.Hash == null).ToArray(); - - foreach (SkinInfo skin in skinsWithoutHashes) + using (var realm = ContextFactory.CreateContext()) { - try + var skinsWithoutHashes = realm.All().Where(i => string.IsNullOrEmpty(i.Hash)).ToArray(); + + foreach (SkinInfo skin in skinsWithoutHashes) { - Update(skin); - } - catch (Exception e) - { - Delete(skin); - Logger.Error(e, $"Existing skin {skin} has been deleted during hash recomputation due to being invalid"); + try + { + Update(skin); + } + catch (Exception e) + { + Delete(skin); + Logger.Error(e, $"Existing skin {skin} has been deleted during hash recomputation due to being invalid"); + } } } } private Skin createInstance(SkinInfo item) => item.CreateInstance(skinResources); + + public void Save(Skin skin) + { + skin.SkinInfo.PerformWrite(s => + { + // Serialise out the SkinInfo itself. + string skinInfoJson = JsonConvert.SerializeObject(s, new JsonSerializerSettings { Formatting = Formatting.Indented }); + + using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(skinInfoJson))) + { + AddFile(s, streamContent, skin_info_file, s.Realm); + } + + // Then serialise each of the drawable component groups into respective files. + foreach (var drawableInfo in skin.DrawableComponentInfo) + { + string json = JsonConvert.SerializeObject(drawableInfo.Value, new JsonSerializerSettings { Formatting = Formatting.Indented }); + + using (var streamContent = new MemoryStream(Encoding.UTF8.GetBytes(json))) + { + string filename = @$"{drawableInfo.Key}.json"; + + var oldFile = s.Files.FirstOrDefault(f => f.Filename == filename); + + if (oldFile != null) + ReplaceFile(oldFile, streamContent, s.Realm); + else + AddFile(s, streamContent, filename, s.Realm); + } + } + + s.Hash = ComputeHash(s); + }); + } } } diff --git a/osu.Game/Skinning/SkinStore.cs b/osu.Game/Skinning/SkinStore.cs index 31cadb0a24..922d146259 100644 --- a/osu.Game/Skinning/SkinStore.cs +++ b/osu.Game/Skinning/SkinStore.cs @@ -6,7 +6,7 @@ using osu.Game.Database; namespace osu.Game.Skinning { - public class SkinStore : MutableDatabaseBackedStoreWithFileIncludes + public class SkinStore : MutableDatabaseBackedStoreWithFileIncludes { public SkinStore(DatabaseContextFactory contextFactory, Storage storage = null) : base(contextFactory, storage) diff --git a/osu.Game/Skinning/SkinnableSound.cs b/osu.Game/Skinning/SkinnableSound.cs index f935adf7a5..c9e55c09aa 100644 --- a/osu.Game/Skinning/SkinnableSound.cs +++ b/osu.Game/Skinning/SkinnableSound.cs @@ -7,7 +7,6 @@ using System.Linq; using JetBrains.Annotations; using osu.Framework.Allocation; using osu.Framework.Audio; -using osu.Framework.Audio.Sample; using osu.Framework.Bindables; using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Graphics; @@ -43,9 +42,6 @@ namespace osu.Game.Skinning private readonly AudioContainer samplesContainer; - [Resolved] - private ISampleStore sampleStore { get; set; } - [Resolved(CanBeNull = true)] private IPooledSampleProvider samplePool { get; set; } diff --git a/osu.Game/Stores/RealmArchiveModelImporter.cs b/osu.Game/Stores/RealmArchiveModelImporter.cs index 1681dad750..4aca079e2e 100644 --- a/osu.Game/Stores/RealmArchiveModelImporter.cs +++ b/osu.Game/Stores/RealmArchiveModelImporter.cs @@ -352,7 +352,7 @@ namespace osu.Game.Stores transaction.Commit(); } - return existing.ToLive(); + return existing.ToLive(ContextFactory); } LogForModel(item, @"Found existing (optimised) but failed pre-check."); @@ -387,7 +387,7 @@ namespace osu.Game.Stores existing.DeletePending = false; transaction.Commit(); - return existing.ToLive(); + return existing.ToLive(ContextFactory); } LogForModel(item, @"Found existing but failed re-use check."); @@ -416,7 +416,7 @@ namespace osu.Game.Stores throw; } - return item.ToLive(); + return item.ToLive(ContextFactory); } } diff --git a/osu.Game/Stores/RealmArchiveModelManager.cs b/osu.Game/Stores/RealmArchiveModelManager.cs new file mode 100644 index 0000000000..87a27cbbbc --- /dev/null +++ b/osu.Game/Stores/RealmArchiveModelManager.cs @@ -0,0 +1,196 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using osu.Framework.Platform; +using osu.Game.Database; +using osu.Game.IO.Archives; +using osu.Game.Models; +using osu.Game.Overlays.Notifications; +using Realms; + +#nullable enable + +namespace osu.Game.Stores +{ + /// + /// Class which adds all the missing pieces bridging the gap between and . + /// + public abstract class RealmArchiveModelManager : RealmArchiveModelImporter, IModelManager, IModelFileManager + where TModel : RealmObject, IHasRealmFiles, IHasGuidPrimaryKey, ISoftDelete + { + public event Action? ItemUpdated + { + // This may be brought back for beatmaps to ease integration. + // The eventual goal would be not requiring this and using realm subscriptions in its place. + add => throw new NotImplementedException(); + remove => throw new NotImplementedException(); + } + + public event Action? ItemRemoved + { + // This may be brought back for beatmaps to ease integration. + // The eventual goal would be not requiring this and using realm subscriptions in its place. + add => throw new NotImplementedException(); + remove => throw new NotImplementedException(); + } + + private readonly RealmFileStore realmFileStore; + + protected RealmArchiveModelManager(Storage storage, RealmContextFactory contextFactory) + : base(storage, contextFactory) + { + realmFileStore = new RealmFileStore(contextFactory, storage); + } + + public void DeleteFile(TModel item, RealmNamedFileUsage file) => + item.Realm.Write(() => DeleteFile(item, file, item.Realm)); + + public void ReplaceFile(TModel item, RealmNamedFileUsage file, Stream contents) + => item.Realm.Write(() => ReplaceFile(file, contents, item.Realm)); + + public void AddFile(TModel item, Stream contents, string filename) + => item.Realm.Write(() => AddFile(item, contents, filename, item.Realm)); + + /// + /// Delete a file from within an ongoing realm transaction. + /// + protected void DeleteFile(TModel item, RealmNamedFileUsage file, Realm realm) + { + item.Files.Remove(file); + } + + /// + /// Replace a file from within an ongoing realm transaction. + /// + protected void ReplaceFile(RealmNamedFileUsage file, Stream contents, Realm realm) + { + file.File = realmFileStore.Add(contents, realm); + } + + /// + /// Add a file from within an ongoing realm transaction. If the file already exists, it is overwritten. + /// + protected void AddFile(TModel item, Stream contents, string filename, Realm realm) + { + var existing = item.Files.FirstOrDefault(f => string.Equals(f.Filename, filename, StringComparison.OrdinalIgnoreCase)); + + if (existing != null) + { + ReplaceFile(existing, contents, realm); + return; + } + + var file = realmFileStore.Add(contents, realm); + var namedUsage = new RealmNamedFileUsage(file, filename); + + item.Files.Add(namedUsage); + } + + public override async Task?> Import(TModel item, ArchiveReader? archive = null, bool lowPriority = false, CancellationToken cancellationToken = default) + { + return await base.Import(item, archive, lowPriority, cancellationToken).ConfigureAwait(false); + } + + /// + /// Delete multiple items. + /// This will post notifications tracking progress. + /// + public void Delete(List items, bool silent = false) + { + if (items.Count == 0) return; + + var notification = new ProgressNotification + { + Progress = 0, + Text = $"Preparing to delete all {HumanisedModelName}s...", + CompletionText = $"Deleted all {HumanisedModelName}s!", + State = ProgressNotificationState.Active, + }; + + if (!silent) + PostNotification?.Invoke(notification); + + int i = 0; + + foreach (var b in items) + { + if (notification.State == ProgressNotificationState.Cancelled) + // user requested abort + return; + + notification.Text = $"Deleting {HumanisedModelName}s ({++i} of {items.Count})"; + + Delete(b); + + notification.Progress = (float)i / items.Count; + } + + notification.State = ProgressNotificationState.Completed; + } + + /// + /// Restore multiple items that were previously deleted. + /// This will post notifications tracking progress. + /// + public void Undelete(List items, bool silent = false) + { + if (!items.Any()) return; + + var notification = new ProgressNotification + { + CompletionText = "Restored all deleted items!", + Progress = 0, + State = ProgressNotificationState.Active, + }; + + if (!silent) + PostNotification?.Invoke(notification); + + int i = 0; + + foreach (var item in items) + { + if (notification.State == ProgressNotificationState.Cancelled) + // user requested abort + return; + + notification.Text = $"Restoring ({++i} of {items.Count})"; + + Undelete(item); + + notification.Progress = (float)i / items.Count; + } + + notification.State = ProgressNotificationState.Completed; + } + + public bool Delete(TModel item) + { + if (item.DeletePending) + return false; + + item.Realm.Write(r => item.DeletePending = true); + return true; + } + + public void Undelete(TModel item) + { + if (!item.DeletePending) + return; + + item.Realm.Write(r => item.DeletePending = false); + } + + public virtual bool IsAvailableLocally(TModel model) => false; // Not relevant for skins since they can't be downloaded yet. + + public void Update(TModel skin) + { + } + } +} diff --git a/osu.Game/Stores/RealmRulesetStore.cs b/osu.Game/Stores/RealmRulesetStore.cs index 0119aec9a4..93b6d29e7d 100644 --- a/osu.Game/Stores/RealmRulesetStore.cs +++ b/osu.Game/Stores/RealmRulesetStore.cs @@ -18,7 +18,7 @@ using osu.Game.Rulesets; namespace osu.Game.Stores { - public class RealmRulesetStore : IDisposable + public class RealmRulesetStore : IRulesetStore, IDisposable { private readonly RealmContextFactory realmFactory; @@ -29,9 +29,9 @@ namespace osu.Game.Stores /// /// All available rulesets. /// - public IEnumerable AvailableRulesets => availableRulesets; + public IEnumerable AvailableRulesets => availableRulesets; - private readonly List availableRulesets = new List(); + private readonly List availableRulesets = new List(); public RealmRulesetStore(RealmContextFactory realmFactory, Storage? storage = null) { @@ -64,14 +64,14 @@ namespace osu.Game.Stores /// /// The ruleset's internal ID. /// A ruleset, if available, else null. - public IRulesetInfo? GetRuleset(int id) => AvailableRulesets.FirstOrDefault(r => r.OnlineID == id); + public RealmRuleset? GetRuleset(int id) => AvailableRulesets.FirstOrDefault(r => r.OnlineID == id); /// /// Retrieve a ruleset using a known short name. /// /// The ruleset's short name. /// A ruleset, if available, else null. - public IRulesetInfo? GetRuleset(string shortName) => AvailableRulesets.FirstOrDefault(r => r.ShortName == shortName); + public RealmRuleset? GetRuleset(string shortName) => AvailableRulesets.FirstOrDefault(r => r.ShortName == shortName); private Assembly? resolveRulesetDependencyAssembly(object? sender, ResolveEventArgs args) { @@ -258,5 +258,13 @@ namespace osu.Game.Stores { AppDomain.CurrentDomain.AssemblyResolve -= resolveRulesetDependencyAssembly; } + + #region Implementation of IRulesetStore + + IRulesetInfo? IRulesetStore.GetRuleset(int id) => GetRuleset(id); + IRulesetInfo? IRulesetStore.GetRuleset(string shortName) => GetRuleset(shortName); + IEnumerable IRulesetStore.AvailableRulesets => AvailableRulesets; + + #endregion } } diff --git a/osu.Game/Storyboards/StoryboardSprite.cs b/osu.Game/Storyboards/StoryboardSprite.cs index 6fb2f5994b..ebd1a941a8 100644 --- a/osu.Game/Storyboards/StoryboardSprite.cs +++ b/osu.Game/Storyboards/StoryboardSprite.cs @@ -33,10 +33,8 @@ namespace osu.Game.Storyboards foreach (var l in loops) { - if (!(l.EarliestDisplayedTime is double lEarliest)) - continue; - - earliestStartTime = Math.Min(earliestStartTime, lEarliest); + if (l.EarliestDisplayedTime is double loopEarliestDisplayTime) + earliestStartTime = Math.Min(earliestStartTime, l.LoopStartTime + loopEarliestDisplayTime); } if (earliestStartTime < double.MaxValue) diff --git a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs index 9d92f5c5fc..f919edecf7 100644 --- a/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs +++ b/osu.Game/Tests/Beatmaps/HitObjectSampleTest.cs @@ -14,7 +14,9 @@ using osu.Framework.Testing; using osu.Framework.Timing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.Formats; +using osu.Game.Database; using osu.Game.IO; +using osu.Game.Models; using osu.Game.Rulesets; using osu.Game.Rulesets.Objects; using osu.Game.Screens.Ranking; @@ -88,11 +90,7 @@ namespace osu.Game.Tests.Beatmaps AddStep("setup skins", () => { userSkinInfo.Files.Clear(); - userSkinInfo.Files.Add(new SkinFileInfo - { - Filename = userFile, - FileInfo = new IO.FileInfo { Hash = userFile } - }); + userSkinInfo.Files.Add(new RealmNamedFileUsage(new RealmFile { Hash = userFile }, userFile)); beatmapInfo.BeatmapSet.Files.Clear(); beatmapInfo.BeatmapSet.Files.Add(new BeatmapSetFileInfo @@ -121,6 +119,7 @@ namespace osu.Game.Tests.Beatmaps public IResourceStore Files => userSkinResourceStore; public new IResourceStore Resources => base.Resources; public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => null; + RealmContextFactory IStorageResourceProvider.RealmContextFactory => null; #endregion diff --git a/osu.Game/Tests/TestScoreInfo.cs b/osu.Game/Tests/TestScoreInfo.cs deleted file mode 100644 index a53cb0ae78..0000000000 --- a/osu.Game/Tests/TestScoreInfo.cs +++ /dev/null @@ -1,66 +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 System; -using System.Linq; -using osu.Game.Online.API.Requests.Responses; -using osu.Game.Rulesets; -using osu.Game.Rulesets.Mods; -using osu.Game.Rulesets.Scoring; -using osu.Game.Scoring; -using osu.Game.Tests.Beatmaps; - -namespace osu.Game.Tests -{ - public class TestScoreInfo : ScoreInfo - { - public TestScoreInfo(RulesetInfo ruleset, bool excessMods = false) - { - User = new APIUser - { - Id = 2, - Username = "peppy", - CoverUrl = "https://osu.ppy.sh/images/headers/profile-covers/c3.jpg", - }; - - BeatmapInfo = new TestBeatmap(ruleset).BeatmapInfo; - Ruleset = ruleset; - RulesetID = ruleset.ID ?? 0; - - Mods = excessMods - ? ruleset.CreateInstance().CreateAllMods().ToArray() - : new Mod[] { new TestModHardRock(), new TestModDoubleTime() }; - - TotalScore = 2845370; - Accuracy = 0.95; - MaxCombo = 999; - Rank = ScoreRank.S; - Date = DateTimeOffset.Now; - - Statistics[HitResult.Miss] = 1; - Statistics[HitResult.Meh] = 50; - Statistics[HitResult.Ok] = 100; - Statistics[HitResult.Good] = 200; - Statistics[HitResult.Great] = 300; - Statistics[HitResult.Perfect] = 320; - Statistics[HitResult.SmallTickHit] = 50; - Statistics[HitResult.SmallTickMiss] = 25; - Statistics[HitResult.LargeTickHit] = 100; - Statistics[HitResult.LargeTickMiss] = 50; - Statistics[HitResult.SmallBonus] = 10; - Statistics[HitResult.SmallBonus] = 50; - - Position = 1; - } - - private class TestModHardRock : ModHardRock - { - public override double ScoreMultiplier => 1; - } - - private class TestModDoubleTime : ModDoubleTime - { - public override double ScoreMultiplier => 1; - } - } -} diff --git a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs index 5656704abf..7607122ef0 100644 --- a/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs +++ b/osu.Game/Tests/Visual/Multiplayer/MultiplayerTestScene.cs @@ -24,7 +24,7 @@ namespace osu.Game.Tests.Visual.Multiplayer protected new MultiplayerTestSceneDependencies OnlinePlayDependencies => (MultiplayerTestSceneDependencies)base.OnlinePlayDependencies; - public bool RoomJoined => RoomManager.RoomJoined; + public bool RoomJoined => Client.RoomJoined; private readonly bool joinRoom; diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index 2d77e17513..5b08b6b835 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -31,9 +31,10 @@ namespace osu.Game.Tests.Visual.Multiplayer private readonly Bindable isConnected = new Bindable(true); public new Room? APIRoom => base.APIRoom; - public Action? RoomSetupAction; + public bool RoomJoined { get; private set; } + [Resolved] private IAPIProvider api { get; set; } = null!; @@ -49,6 +50,7 @@ namespace osu.Game.Tests.Visual.Multiplayer private MultiplayerPlaylistItem? currentItem => Room?.Playlist[currentIndex]; private int currentIndex; + private long lastPlaylistItemId; public TestMultiplayerClient(TestMultiplayerRoomManager roomManager) { @@ -126,6 +128,15 @@ namespace osu.Game.Tests.Visual.Multiplayer case MultiplayerRoomState.WaitingForLoad: if (Room.Users.All(u => u.State != MultiplayerUserState.WaitingForLoad)) { + var loadedUsers = Room.Users.Where(u => u.State == MultiplayerUserState.Loaded).ToArray(); + + if (loadedUsers.Length == 0) + { + // all users have bailed from the load sequence. cancel the game start. + ChangeRoomState(MultiplayerRoomState.Open); + return; + } + foreach (var u in Room.Users.Where(u => u.State == MultiplayerUserState.Loaded)) ChangeUserState(u.UserID, MultiplayerUserState.Playing); @@ -141,11 +152,11 @@ namespace osu.Game.Tests.Visual.Multiplayer { foreach (var u in Room.Users.Where(u => u.State == MultiplayerUserState.FinishedPlay)) ChangeUserState(u.UserID, MultiplayerUserState.Results); - ChangeRoomState(MultiplayerRoomState.Open); + ChangeRoomState(MultiplayerRoomState.Open); ((IMultiplayerClient)this).ResultsReady(); - finishCurrentItem().Wait(); + FinishCurrentItem().Wait(); } break; @@ -169,6 +180,7 @@ namespace osu.Game.Tests.Visual.Multiplayer serverSidePlaylist.Clear(); serverSidePlaylist.AddRange(apiRoom.Playlist.Select(item => new MultiplayerPlaylistItem(item))); + lastPlaylistItemId = serverSidePlaylist.Max(item => item.ID); var localUser = new MultiplayerRoomUser(api.LocalUser.Value.Id) { @@ -189,6 +201,7 @@ namespace osu.Game.Tests.Visual.Multiplayer Host = localUser }; + await updatePlaylistOrder(room).ConfigureAwait(false); await updateCurrentItem(room, false).ConfigureAwait(false); RoomSetupAction?.Invoke(room); @@ -204,9 +217,15 @@ namespace osu.Game.Tests.Visual.Multiplayer // emulate the server sending this after the join room. scheduler required to make sure the join room event is fired first (in Join). changeMatchType(Room.Settings.MatchType).Wait(); + + RoomJoined = true; } - protected override Task LeaveRoomInternal() => Task.CompletedTask; + protected override Task LeaveRoomInternal() + { + RoomJoined = false; + return Task.CompletedTask; + } public override Task TransferHost(int userId) => ((IMultiplayerClient)this).HostChanged(userId); @@ -238,6 +257,11 @@ namespace osu.Game.Tests.Visual.Multiplayer public override Task ChangeState(MultiplayerUserState newState) { + Debug.Assert(Room != null); + + if (newState == MultiplayerUserState.Idle && LocalUser?.State == MultiplayerUserState.WaitingForLoad) + return Task.CompletedTask; + ChangeUserState(api.LocalUser.Value.Id, newState); return Task.CompletedTask; } @@ -299,6 +323,16 @@ namespace osu.Game.Tests.Visual.Multiplayer return ((IMultiplayerClient)this).LoadRequested(); } + public override Task AbortGameplay() + { + Debug.Assert(Room != null); + Debug.Assert(LocalUser != null); + + ChangeUserState(LocalUser.UserID, MultiplayerUserState.Idle); + + return Task.CompletedTask; + } + public async Task AddUserPlaylistItem(int userId, MultiplayerPlaylistItem item) { Debug.Assert(Room != null); @@ -308,35 +342,71 @@ namespace osu.Game.Tests.Visual.Multiplayer if (Room.Settings.QueueMode == QueueMode.HostOnly && Room.Host?.UserID != LocalUser?.UserID) throw new InvalidOperationException("Local user is not the room host."); - switch (Room.Settings.QueueMode) - { - case QueueMode.HostOnly: - // In host-only mode, the current item is re-used. - item.ID = currentItem.ID; - item.OwnerID = currentItem.OwnerID; + item.OwnerID = userId; - serverSidePlaylist[currentIndex] = item; - await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false); - - // Note: Unlike the server, this is the easiest way to update the current item at this point. - await updateCurrentItem(Room, false).ConfigureAwait(false); - break; - - default: - item.ID = serverSidePlaylist.Last().ID + 1; - item.OwnerID = userId; - - serverSidePlaylist.Add(item); - await ((IMultiplayerClient)this).PlaylistItemAdded(item).ConfigureAwait(false); - - await updateCurrentItem(Room).ConfigureAwait(false); - break; - } + await addItem(item).ConfigureAwait(false); + await updateCurrentItem(Room).ConfigureAwait(false); } public override Task AddPlaylistItem(MultiplayerPlaylistItem item) => AddUserPlaylistItem(api.LocalUser.Value.OnlineID, item); - protected override Task GetOnlineBeatmapSet(int beatmapId, CancellationToken cancellationToken = default) + public async Task EditUserPlaylistItem(int userId, MultiplayerPlaylistItem item) + { + Debug.Assert(Room != null); + Debug.Assert(APIRoom != null); + Debug.Assert(currentItem != null); + + item.OwnerID = userId; + + var existingItem = serverSidePlaylist.SingleOrDefault(i => i.ID == item.ID); + + if (existingItem == null) + throw new InvalidOperationException("Attempted to change an item that doesn't exist."); + + if (existingItem.OwnerID != userId && Room.Host?.UserID != LocalUser?.UserID) + throw new InvalidOperationException("Attempted to change an item which is not owned by the user."); + + if (existingItem.Expired) + throw new InvalidOperationException("Attempted to change an item which has already been played."); + + // Ensure the playlist order doesn't change. + item.PlaylistOrder = existingItem.PlaylistOrder; + + serverSidePlaylist[serverSidePlaylist.IndexOf(existingItem)] = item; + + await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false); + } + + public override Task EditPlaylistItem(MultiplayerPlaylistItem item) => EditUserPlaylistItem(api.LocalUser.Value.OnlineID, item); + + public async Task RemoveUserPlaylistItem(int userId, long playlistItemId) + { + Debug.Assert(Room != null); + Debug.Assert(APIRoom != null); + + var item = serverSidePlaylist.Find(i => i.ID == playlistItemId); + + if (item == null) + throw new InvalidOperationException("Item does not exist in the room."); + + if (item == currentItem) + throw new InvalidOperationException("The room's current item cannot be removed."); + + if (item.OwnerID != userId) + throw new InvalidOperationException("Attempted to remove an item which is not owned by the user."); + + if (item.Expired) + throw new InvalidOperationException("Attempted to remove an item which has already been played."); + + serverSidePlaylist.Remove(item); + await ((IMultiplayerClient)this).PlaylistItemRemoved(playlistItemId).ConfigureAwait(false); + + await updateCurrentItem(Room).ConfigureAwait(false); + } + + public override Task RemovePlaylistItem(long playlistItemId) => RemoveUserPlaylistItem(api.LocalUser.Value.OnlineID, playlistItemId); + + public 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 +415,12 @@ namespace osu.Game.Tests.Visual.Multiplayer 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) @@ -386,11 +455,11 @@ namespace osu.Game.Tests.Visual.Multiplayer if (newMode == QueueMode.HostOnly && serverSidePlaylist.All(item => item.Expired)) await duplicateCurrentItem().ConfigureAwait(false); - // When changing modes, items could have been added (above) or the queueing order could have changed. + await updatePlaylistOrder(Room).ConfigureAwait(false); await updateCurrentItem(Room).ConfigureAwait(false); } - private async Task finishCurrentItem() + public async Task FinishCurrentItem() { Debug.Assert(Room != null); Debug.Assert(APIRoom != null); @@ -398,10 +467,13 @@ namespace osu.Game.Tests.Visual.Multiplayer // Expire the current playlist item. currentItem.Expired = true; + currentItem.PlayedAt = DateTimeOffset.Now; + await ((IMultiplayerClient)this).PlaylistItemChanged(currentItem).ConfigureAwait(false); + await updatePlaylistOrder(Room).ConfigureAwait(false); // In host-only mode, a duplicate playlist item will be used for the next round. - if (Room.Settings.QueueMode == QueueMode.HostOnly) + if (Room.Settings.QueueMode == QueueMode.HostOnly && serverSidePlaylist.All(item => item.Expired)) await duplicateCurrentItem().ConfigureAwait(false); await updateCurrentItem(Room).ConfigureAwait(false); @@ -409,47 +481,93 @@ namespace osu.Game.Tests.Visual.Multiplayer private async Task duplicateCurrentItem() { - Debug.Assert(Room != null); - Debug.Assert(APIRoom != null); Debug.Assert(currentItem != null); - var newItem = new MultiplayerPlaylistItem + await addItem(new MultiplayerPlaylistItem { - ID = serverSidePlaylist.Last().ID + 1, BeatmapID = currentItem.BeatmapID, BeatmapChecksum = currentItem.BeatmapChecksum, RulesetID = currentItem.RulesetID, RequiredMods = currentItem.RequiredMods, AllowedMods = currentItem.AllowedMods - }; - - serverSidePlaylist.Add(newItem); - await ((IMultiplayerClient)this).PlaylistItemAdded(newItem).ConfigureAwait(false); + }).ConfigureAwait(false); } + private async Task addItem(MultiplayerPlaylistItem item) + { + Debug.Assert(Room != null); + + item.ID = ++lastPlaylistItemId; + + serverSidePlaylist.Add(item); + await ((IMultiplayerClient)this).PlaylistItemAdded(item).ConfigureAwait(false); + + await updatePlaylistOrder(Room).ConfigureAwait(false); + } + + private IEnumerable upcomingItems => serverSidePlaylist.Where(i => !i.Expired).OrderBy(i => i.PlaylistOrder); + private async Task updateCurrentItem(MultiplayerRoom room, bool notify = true) { - MultiplayerPlaylistItem newItem; + // Pick the next non-expired playlist item by playlist order, or default to the most-recently-expired item. + MultiplayerPlaylistItem nextItem = upcomingItems.FirstOrDefault() ?? serverSidePlaylist.OrderByDescending(i => i.PlayedAt).First(); + + currentIndex = serverSidePlaylist.IndexOf(nextItem); + + long lastItem = room.Settings.PlaylistItemId; + room.Settings.PlaylistItemId = nextItem.ID; + + if (notify && nextItem.ID != lastItem) + await ((IMultiplayerClient)this).SettingsChanged(room.Settings).ConfigureAwait(false); + } + + private async Task updatePlaylistOrder(MultiplayerRoom room) + { + List orderedActiveItems; switch (room.Settings.QueueMode) { default: - // Pick the single non-expired playlist item. - newItem = serverSidePlaylist.FirstOrDefault(i => !i.Expired) ?? serverSidePlaylist.Last(); + orderedActiveItems = serverSidePlaylist.Where(item => !item.Expired).OrderBy(item => item.ID).ToList(); break; case QueueMode.AllPlayersRoundRobin: - // Group playlist items by (user_id -> count_expired), and select the first available playlist item from a user that has available beatmaps where count_expired is the lowest. - throw new NotImplementedException(); + var itemsByPriority = new List<(MultiplayerPlaylistItem item, int priority)>(); + + // Assign a priority for items from each user, starting from 0 and increasing in order which the user added the items. + foreach (var group in room.Playlist.Where(item => !item.Expired).OrderBy(item => item.ID).GroupBy(item => item.OwnerID)) + { + int priority = 0; + itemsByPriority.AddRange(group.Select(item => (item, priority++))); + } + + orderedActiveItems = itemsByPriority + // Order by each user's priority. + .OrderBy(i => i.priority) + // Many users will have the same priority of items, so attempt to break the tie by maintaining previous ordering. + // Suppose there are two users: User1 and User2. User1 adds two items, and then User2 adds a third. If the previous order is not maintained, + // then after playing the first item by User1, their second item will become priority=0 and jump to the front of the queue (because it was added first). + .ThenBy(i => i.item.PlaylistOrder) + // If there are still ties (normally shouldn't happen), break ties by making items added earlier go first. + // This could happen if e.g. the item orders get reset. + .ThenBy(i => i.item.ID) + .Select(i => i.item) + .ToList(); + + break; } - currentIndex = serverSidePlaylist.IndexOf(newItem); + for (int i = 0; i < orderedActiveItems.Count; i++) + { + var item = orderedActiveItems[i]; - long lastItem = room.Settings.PlaylistItemId; - room.Settings.PlaylistItemId = newItem.ID; + if (item.PlaylistOrder == i) + continue; - if (notify && newItem.ID != lastItem) - await ((IMultiplayerClient)this).SettingsChanged(room.Settings).ConfigureAwait(false); + item.PlaylistOrder = (ushort)i; + + await ((IMultiplayerClient)this).PlaylistItemChanged(item).ConfigureAwait(false); + } } } } diff --git a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs index a1f010f082..296db3152d 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerRoomManager.cs @@ -17,8 +17,6 @@ namespace osu.Game.Tests.Visual.Multiplayer /// public class TestMultiplayerRoomManager : MultiplayerRoomManager { - public bool RoomJoined { get; private set; } - private readonly TestRoomRequestsHandler requestsHandler; public TestMultiplayerRoomManager(TestRoomRequestsHandler requestsHandler) @@ -29,28 +27,10 @@ namespace osu.Game.Tests.Visual.Multiplayer public IReadOnlyList ServerSideRooms => requestsHandler.ServerSideRooms; public override void CreateRoom(Room room, Action onSuccess = null, Action onError = null) - { - base.CreateRoom(room, r => - { - onSuccess?.Invoke(r); - RoomJoined = true; - }, onError); - } + => base.CreateRoom(room, r => onSuccess?.Invoke(r), onError); public override void JoinRoom(Room room, string password = null, Action onSuccess = null, Action onError = null) - { - base.JoinRoom(room, password, r => - { - onSuccess?.Invoke(r); - RoomJoined = true; - }, onError); - } - - public override void PartRoom() - { - base.PartRoom(); - RoomJoined = false; - } + => base.JoinRoom(room, password, r => onSuccess?.Invoke(r), onError); /// /// Adds a room to a local "server-side" list that's returned when a is fired. diff --git a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs index abcf31c007..520f2c4585 100644 --- a/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs +++ b/osu.Game/Tests/Visual/OnlinePlay/TestRoomRequestsHandler.cs @@ -4,9 +4,7 @@ using System; using System.Collections.Generic; using System.Linq; -using osu.Framework.Allocation; using osu.Game.Online.API; -using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Rooms; using osu.Game.Rulesets.Scoring; @@ -25,9 +23,9 @@ namespace osu.Game.Tests.Visual.OnlinePlay 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 @@ -89,15 +87,6 @@ namespace osu.Game.Tests.Visual.OnlinePlay getRoomRequest.TriggerSuccess(createResponseRoom(ServerSideRooms.Single(r => r.RoomID.Value == getRoomRequest.RoomId), true)); return true; - case GetBeatmapSetRequest getBeatmapSetRequest: - var onlineReq = new GetBeatmapSetRequest(getBeatmapSetRequest.ID, getBeatmapSetRequest.Type); - onlineReq.Success += res => getBeatmapSetRequest.TriggerSuccess(res); - onlineReq.Failure += e => getBeatmapSetRequest.TriggerFailure(e); - - // Get the online API from the game's dependencies. - game.Dependencies.Get().Queue(onlineReq); - return true; - case CreateRoomScoreRequest createRoomScoreRequest: createRoomScoreRequest.TriggerSuccess(new APIScoreToken { ID = 1 }); return true; diff --git a/osu.Game/Tests/Visual/ScreenTestScene.cs b/osu.Game/Tests/Visual/ScreenTestScene.cs index aa46b516bf..c44a848275 100644 --- a/osu.Game/Tests/Visual/ScreenTestScene.cs +++ b/osu.Game/Tests/Visual/ScreenTestScene.cs @@ -29,7 +29,11 @@ namespace osu.Game.Tests.Visual { base.Content.AddRange(new Drawable[] { - Stack = new OsuScreenStack { RelativeSizeAxes = Axes.Both }, + Stack = new OsuScreenStack + { + Name = nameof(ScreenTestScene), + RelativeSizeAxes = Axes.Both + }, content = new Container { RelativeSizeAxes = Axes.Both }, DialogOverlay = new DialogOverlay() }); diff --git a/osu.Game/Tests/Visual/SkinnableTestScene.cs b/osu.Game/Tests/Visual/SkinnableTestScene.cs index 000e7194bc..22aac79056 100644 --- a/osu.Game/Tests/Visual/SkinnableTestScene.cs +++ b/osu.Game/Tests/Visual/SkinnableTestScene.cs @@ -15,6 +15,7 @@ using osu.Framework.Graphics.Textures; using osu.Framework.IO.Stores; using osu.Framework.Platform; using osu.Game.Beatmaps; +using osu.Game.Database; using osu.Game.Graphics.Sprites; using osu.Game.IO; using osu.Game.Rulesets; @@ -95,7 +96,7 @@ namespace osu.Game.Tests.Visual }, new OsuSpriteText { - Text = skin?.SkinInfo?.Name ?? "none", + Text = skin?.SkinInfo?.Value.Name ?? "none", Scale = new Vector2(1.5f), Padding = new MarginPadding(5), }, @@ -158,6 +159,7 @@ namespace osu.Game.Tests.Visual public IResourceStore Files => null; public new IResourceStore Resources => base.Resources; public IResourceStore CreateTextureLoaderStore(IResourceStore underlyingStore) => host.CreateTextureLoaderStore(underlyingStore); + RealmContextFactory IStorageResourceProvider.RealmContextFactory => null; #endregion diff --git a/osu.Game/Updater/NoActionUpdateManager.cs b/osu.Game/Updater/NoActionUpdateManager.cs index 641263ed0f..8f9c4c6f16 100644 --- a/osu.Game/Updater/NoActionUpdateManager.cs +++ b/osu.Game/Updater/NoActionUpdateManager.cs @@ -5,7 +5,6 @@ using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Graphics.Sprites; -using osu.Framework.Platform; using osu.Game.Online.API; using osu.Game.Overlays.Notifications; @@ -19,9 +18,6 @@ namespace osu.Game.Updater { private string version; - [Resolved] - private GameHost host { get; set; } - [BackgroundDependencyLoader] private void load(OsuGameBase game) { diff --git a/osu.Game/Users/UserActivity.cs b/osu.Game/Users/UserActivity.cs index 0874685f49..516aa80652 100644 --- a/osu.Game/Users/UserActivity.cs +++ b/osu.Game/Users/UserActivity.cs @@ -29,9 +29,9 @@ namespace osu.Game.Users { public IBeatmapInfo BeatmapInfo { get; } - public RulesetInfo Ruleset { get; } + public IRulesetInfo Ruleset { get; } - protected InGame(IBeatmapInfo beatmapInfo, RulesetInfo ruleset) + protected InGame(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset) { BeatmapInfo = beatmapInfo; Ruleset = ruleset; @@ -42,7 +42,7 @@ namespace osu.Game.Users public class InMultiplayerGame : InGame { - public InMultiplayerGame(IBeatmapInfo beatmapInfo, RulesetInfo ruleset) + public InMultiplayerGame(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset) : base(beatmapInfo, ruleset) { } @@ -52,7 +52,7 @@ namespace osu.Game.Users public class InPlaylistGame : InGame { - public InPlaylistGame(IBeatmapInfo beatmapInfo, RulesetInfo ruleset) + public InPlaylistGame(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset) : base(beatmapInfo, ruleset) { } @@ -60,7 +60,7 @@ namespace osu.Game.Users public class InSoloGame : InGame { - public InSoloGame(IBeatmapInfo beatmapInfo, RulesetInfo ruleset) + public InSoloGame(IBeatmapInfo beatmapInfo, IRulesetInfo ruleset) : base(beatmapInfo, ruleset) { } diff --git a/osu.Game/Users/UserStatistics.cs b/osu.Game/Users/UserStatistics.cs index c690447256..f8d26fe421 100644 --- a/osu.Game/Users/UserStatistics.cs +++ b/osu.Game/Users/UserStatistics.cs @@ -27,6 +27,9 @@ namespace osu.Game.Users public int Progress; } + [JsonProperty(@"is_ranked")] + public bool IsRanked; + [JsonProperty(@"global_rank")] public int? GlobalRank; diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 7cc8893d8d..6e6002bc8e 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -20,7 +20,7 @@ - + @@ -31,15 +31,15 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - + + + + diff --git a/osu.iOS.props b/osu.iOS.props index 9c21f76617..de359245d1 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -60,8 +60,8 @@ - - + + @@ -83,7 +83,7 @@ - + diff --git a/osu.iOS/Info.plist b/osu.iOS/Info.plist index 249474b1d7..2592f909ce 100644 --- a/osu.iOS/Info.plist +++ b/osu.iOS/Info.plist @@ -33,6 +33,8 @@ UIStatusBarHidden + CADisableMinimumFrameDurationOnPhone + NSCameraUsageDescription We don't really use the camera. NSMicrophoneUsageDescription