diff --git a/osu.Android.props b/osu.Android.props index 5cf59decec..1131203a95 100644 --- a/osu.Android.props +++ b/osu.Android.props @@ -51,8 +51,8 @@ - - + + diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayer.cs index bc2902480d..710855a605 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; @@ -594,9 +596,7 @@ namespace osu.Game.Tests.Visual.Multiplayer } }); - pressReadyButton(); - pressReadyButton(); - 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) @@ -656,23 +656,172 @@ namespace osu.Game.Tests.Visual.Multiplayer }); } + [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", () => (multiplayerScreenStack.CurrentScreen as MultiSpectatorScreen)?.IsLoaded == true); + + AddStep("press back button and exit", () => + { + multiplayerScreenStack.OnBackButton(); + multiplayerScreenStack.Exit(); + }); + + AddUntilStep("wait for return to match subscreen", () => multiplayerScreenStack.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", () => (multiplayerScreenStack.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", () => + { + multiplayerScreenStack.OnBackButton(); + multiplayerScreenStack.Exit(); + }); + + AddUntilStep("wait for return to match subscreen", () => multiplayerScreenStack.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", () => multiplayerScreenStack.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", () => multiplayerScreenStack.MultiplayerScreen.MakeCurrent()); + AddUntilStep("queue is empty", () => this.ChildrenOfType().Single().Items.Count == 0); + } + + private void enterGameplay() + { + pressReadyButton(); + pressReadyButton(); + AddUntilStep("wait for player", () => multiplayerScreenStack.CurrentScreen is Player); + } + private ReadyButton readyButton => this.ChildrenOfType().Single(); - private void pressReadyButton() + 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", () => { - lastState = client.LocalUser?.State ?? MultiplayerUserState.Idle; + 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", () => client.LocalUser?.State != lastState); + AddUntilStep("wait for state change", () => user?.State != lastState); } private void createRoom(Func room) diff --git a/osu.Game.Tests/Visual/TestMultiplayerScreenStack.cs b/osu.Game.Tests/Visual/TestMultiplayerScreenStack.cs index 7f1171db1f..370f3bd0ae 100644 --- a/osu.Game.Tests/Visual/TestMultiplayerScreenStack.cs +++ b/osu.Game.Tests/Visual/TestMultiplayerScreenStack.cs @@ -61,7 +61,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/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/Online/Multiplayer/IMultiplayerRoomServer.cs b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs index 73fda78d00..073d512f90 100644 --- a/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs +++ b/osu.Game/Online/Multiplayer/IMultiplayerRoomServer.cs @@ -77,6 +77,11 @@ 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. /// diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index f366de557f..903aaa89e3 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -327,6 +327,8 @@ 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); diff --git a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs index f911ef3121..3794bec228 100644 --- a/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/OnlineMultiplayerClient.cs @@ -154,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) 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/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs index 4971489769..7b90532cce 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerPlaylist.cs @@ -121,7 +121,11 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist private void addItemToLists(MultiplayerPlaylistItem item) { - var apiItem = Playlist.Single(i => i.ID == item.ID); + 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); 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/Spectate/MultiSpectatorScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiSpectatorScreen.cs index 9ac64add9a..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; @@ -226,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/Tests/Visual/Multiplayer/TestMultiplayerClient.cs b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs index d20d6b1d37..4e0cfe405e 100644 --- a/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs +++ b/osu.Game/Tests/Visual/Multiplayer/TestMultiplayerClient.cs @@ -128,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); @@ -143,8 +152,8 @@ 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(); @@ -242,6 +251,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; } @@ -303,6 +317,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); diff --git a/osu.Game/osu.Game.csproj b/osu.Game/osu.Game.csproj index 46064e320b..feae990df7 100644 --- a/osu.Game/osu.Game.csproj +++ b/osu.Game/osu.Game.csproj @@ -36,8 +36,8 @@ runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/osu.iOS.props b/osu.iOS.props index fdb63a19d3..27ac1bf647 100644 --- a/osu.iOS.props +++ b/osu.iOS.props @@ -60,8 +60,8 @@ - - + + @@ -83,7 +83,7 @@ - +