From 31c1168d76a9aadf0b4e340c821f0b0fdbfcd8ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 3 Apr 2026 12:38:44 +0200 Subject: [PATCH] Adjust multiplayer logic to accommodate for referees spectating in-game (#37152) - Last part of / closes https://github.com/ppy/osu-server-spectator/issues/406. - Remaining work on slots will be tracked in https://github.com/ppy/osu-server-spectator/issues/405. This PR is a corollary of https://github.com/ppy/osu-server-spectator/pull/453 and all of the dispensations referee users in a multiplayer have received therein. The goal here is to allow access to all relevant room management functions even if the referee in question isn't host, as well as to disallow access to all non-relevant functions to do with the actual match gameplay. I'm not going to lie, this logic *is* ugly. I would argue that it already *was* ugly on `master` and my goal was to operate with as light a touch as possible myself. But you could see this as copping out and that I should try to refactor some of this. I will try - but only after someone else's seen the initial approach and deemed it unsuitable. The logic in `MatchStartControl` is awful - there are so many moving pieces of state that dictate what can happen when with all the buttons, and yes, I am making it worse here. This time there is some test coverage. Not everything is covered, but the coverage should be on par in all components and pieces of relevant logic I touched that already had tests covering them. On that note, please forgive the diffstat size, but the tests *are* most of that size. --------- Co-authored-by: Dean Herbert --- .../Multiplayer/TestSceneMatchStartControl.cs | 223 ++++++++++++++---- .../TestSceneMultiplayerMatchSubScreen.cs | 24 ++ .../TestSceneMultiplayerParticipantsList.cs | 49 +++- .../TestSceneMultiplayerQueueList.cs | 38 ++- .../Online/Multiplayer/MultiplayerClient.cs | 5 + .../Online/Multiplayer/MultiplayerRoom.cs | 2 +- .../Multiplayer/Match/MatchStartControl.cs | 51 ++-- .../Match/MultiplayerCountdownButton.cs | 2 +- .../Match/MultiplayerReadyButton.cs | 24 +- .../Match/Playlist/MultiplayerQueueList.cs | 4 +- .../Multiplayer/MultiplayerMatchSubScreen.cs | 11 +- .../Multiplayer/MultiplayerRoomPanel.cs | 2 +- .../Participants/ParticipantPanel.cs | 11 +- 13 files changed, 352 insertions(+), 94 deletions(-) diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs index 2f1b768ea6..4e2c3f81da 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMatchStartControl.cs @@ -50,47 +50,6 @@ namespace osu.Game.Tests.Visual.Multiplayer Dependencies.CacheAs(ongoingOperationTracker = new OngoingOperationTracker()); Dependencies.CacheAs(availabilityTracker.Object); - availabilityTracker.SetupGet(a => a.Availability).Returns(beatmapAvailability); - - multiplayerClient.SetupGet(m => m.LocalUser).Returns(() => localUser); - multiplayerClient.SetupGet(m => m.Room).Returns(() => multiplayerRoom); - - // By default, the local user is to be the host. - multiplayerClient.SetupGet(m => m.IsHost).Returns(() => ReferenceEquals(multiplayerRoom.Host, localUser)); - - // Assume all state changes are accepted by the server. - multiplayerClient.Setup(m => m.ChangeState(It.IsAny())) - .Callback((MultiplayerUserState r) => - { - Logger.Log($"Changing local user state from {localUser.State} to {r}"); - localUser.State = r; - raiseRoomUpdated(); - }); - - multiplayerClient.Setup(m => m.StartMatch()) - .Callback(() => - { - multiplayerClient.Raise(m => m.LoadRequested -= null); - - // immediately "end" gameplay, as we don't care about that part of the process. - changeUserState(localUser.UserID, MultiplayerUserState.Idle); - }); - - multiplayerClient.Setup(m => m.SendMatchRequest(It.IsAny())) - .Callback((MatchUserRequest request) => - { - switch (request) - { - case StartMatchCountdownRequest countdownStart: - setRoomCountdown(countdownStart.Duration); - break; - - case StopCountdownRequest: - clearRoomCountdown(); - break; - } - }); - Children = new Drawable[] { ongoingOperationTracker, @@ -103,10 +62,51 @@ namespace osu.Game.Tests.Visual.Multiplayer { AddStep("reset state", () => { - multiplayerClient.Invocations.Clear(); + multiplayerClient.Reset(); + multiplayerClient.SetupGet(m => m.LocalUser).Returns(() => localUser); + multiplayerClient.SetupGet(m => m.Room).Returns(() => multiplayerRoom); + + // By default, the local user is to be the host. + multiplayerClient.SetupGet(m => m.IsHost).Returns(() => ReferenceEquals(multiplayerRoom.Host, localUser)); + + // Assume all state changes are accepted by the server. + multiplayerClient.Setup(m => m.ChangeState(It.IsAny())) + .Callback((MultiplayerUserState r) => + { + Logger.Log($"Changing local user state from {localUser.State} to {r}"); + localUser.State = r; + raiseRoomUpdated(); + }); + + multiplayerClient.Setup(m => m.StartMatch()) + .Callback(() => + { + multiplayerClient.Raise(m => m.LoadRequested -= null); + + // immediately "end" gameplay, as we don't care about that part of the process. + changeUserState(localUser.UserID, MultiplayerUserState.Idle); + }); + + multiplayerClient.Setup(m => m.SendMatchRequest(It.IsAny())) + .Callback((MatchUserRequest request) => + { + switch (request) + { + case StartMatchCountdownRequest countdownStart: + setRoomCountdown(countdownStart.Duration); + break; + + case StopCountdownRequest: + clearRoomCountdown(); + break; + } + }); beatmapAvailability.Value = BeatmapAvailability.LocallyAvailable(); + availabilityTracker.Reset(); + availabilityTracker.SetupGet(a => a.Availability).Returns(beatmapAvailability); + PlaylistItem item = new PlaylistItem(Beatmap.Value.BeatmapInfo) { RulesetID = Beatmap.Value.BeatmapInfo.Ruleset.OnlineID @@ -375,6 +375,22 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestAbortMatch() + { + setUpMatchCallbacks(); + + // Ready + ClickButtonWhenEnabled(); + + // Start match + ClickButtonWhenEnabled(); + AddUntilStep("countdown button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); + + // Abort + ClickButtonWhenEnabled(); + AddStep("check abort request received", () => multiplayerClient.Verify(m => m.AbortMatch(), Times.Once)); + } + + private void setUpMatchCallbacks() { AddStep("setup client", () => { @@ -383,6 +399,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { multiplayerClient.Raise(m => m.LoadRequested -= null); multiplayerClient.Object.Room!.State = MultiplayerRoomState.WaitingForLoad; + raiseRoomUpdated(); // The local user state doesn't really matter, so let's do the same as the base implementation for these tests. changeUserState(localUser.UserID, MultiplayerUserState.Idle); @@ -395,19 +412,133 @@ namespace osu.Game.Tests.Visual.Multiplayer raiseRoomUpdated(); }); }); + } - // Ready - ClickButtonWhenEnabled(); + [Test] + public void TestRefereeSpectating() + { + AddStep("set up referee", () => + { + multiplayerClient.SetupGet(m => m.IsReferee).Returns(true); + multiplayerClient.SetupGet(m => m.IsHost).Returns(false); + multiplayerClient.Object.Room!.Users.Single().Role = MultiplayerRoomUserRole.Referee; + raiseRoomUpdated(); + }); - // Start match + const int users = 10; + + AddStep("add many users", () => + { + for (int i = 0; i < users; i++) + addUser(new APIUser { Id = i, Username = "Another user" }); + }); + AddAssert("button disabled", () => this.ChildrenOfType().Single().Enabled.Value, () => Is.False); + + AddStep("move to spectate", () => changeUserState(multiplayerClient.Object.LocalUser!.UserID, MultiplayerUserState.Spectating)); + + AddStep("ready up a user", () => changeUserState(9, MultiplayerUserState.Ready)); + AddAssert("button enabled", () => this.ChildrenOfType().Single().Enabled.Value, () => Is.True); + + setUpMatchCallbacks(); + + // start match ClickButtonWhenEnabled(); AddUntilStep("countdown button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); - // Abort + // abort ClickButtonWhenEnabled(); AddStep("check abort request received", () => multiplayerClient.Verify(m => m.AbortMatch(), Times.Once)); } + [Test] + public void TestRefereeFlowWithoutCountdown() + { + AddStep("set up referee", () => + { + multiplayerClient.SetupGet(m => m.IsReferee).Returns(true); + multiplayerClient.SetupGet(m => m.IsHost).Returns(false); + multiplayerClient.Object.Room!.Users.Single().Role = MultiplayerRoomUserRole.Referee; + raiseRoomUpdated(); + }); + + const int users = 10; + + AddStep("add many users", () => + { + for (int i = 0; i < users; i++) + addUser(new APIUser { Id = i, Username = "Another user" }); + }); + AddAssert("button disabled", () => this.ChildrenOfType().Single().Enabled.Value, () => Is.False); + + AddStep("ready up a user", () => changeUserState(9, MultiplayerUserState.Ready)); + AddAssert("button enabled", () => this.ChildrenOfType().Single().Enabled.Value, () => Is.True); + + setUpMatchCallbacks(); + + // start match + ClickButtonWhenEnabled(); + AddUntilStep("countdown button disabled", () => !this.ChildrenOfType().Single().Enabled.Value); + + // abort + ClickButtonWhenEnabled(); + AddStep("check abort request received", () => multiplayerClient.Verify(m => m.AbortMatch(), Times.Once)); + } + + [Test] + public void TestRefereeFlowWithCountdown() + { + AddStep("set up referee", () => + { + multiplayerClient.SetupGet(m => m.IsReferee).Returns(true); + multiplayerClient.SetupGet(m => m.IsHost).Returns(false); + multiplayerClient.Object.Room!.Users.Single().Role = MultiplayerRoomUserRole.Referee; + raiseRoomUpdated(); + }); + + const int users = 10; + + AddStep("add many users", () => + { + for (int i = 0; i < users; i++) + addUser(new APIUser { Id = i, Username = "Another user" }); + }); + AddAssert("button disabled", () => this.ChildrenOfType().Single().Enabled.Value, () => Is.False); + + AddStep("ready up a user", () => changeUserState(9, MultiplayerUserState.Ready)); + AddAssert("button enabled", () => this.ChildrenOfType().Single().Enabled.Value, () => Is.True); + + setUpMatchCallbacks(); + + AddUntilStep("countdown button shown", () => this.ChildrenOfType().SingleOrDefault()?.IsPresent == true); + ClickButtonWhenEnabled(); + AddStep("click the first countdown button", () => + { + var popoverButton = this.ChildrenOfType().Single().ChildrenOfType().First(); + InputManager.MoveMouseTo(popoverButton); + InputManager.Click(MouseButton.Left); + }); + + AddStep("check request received", () => + { + multiplayerClient.Verify(m => m.SendMatchRequest(It.Is(req => + req.Duration == TimeSpan.FromSeconds(10) + )), Times.Once); + }); + + ClickButtonWhenEnabled(); + AddStep("click the cancel button", () => + { + var popoverButton = this.ChildrenOfType().Single().ChildrenOfType().Last(); + InputManager.MoveMouseTo(popoverButton); + InputManager.Click(MouseButton.Left); + }); + + AddStep("check request received", () => + { + multiplayerClient.Verify(m => m.SendMatchRequest(It.IsAny()), Times.Once); + }); + } + private void verifyGameplayStartFlow() { checkLocalUserState(MultiplayerUserState.Ready); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs index 906cb3436c..379a589ca9 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerMatchSubScreen.cs @@ -353,6 +353,30 @@ namespace osu.Game.Tests.Visual.Multiplayer AddAssert("button hidden", () => this.ChildrenOfType().Single().ChangeSettingsButton.Alpha, () => Is.EqualTo(0)); } + [Test] + public void TestChangeSettingsButtonAlwaysVisibleForReferee() + { + AddStep("add playlist item", () => + { + room.Playlist = + [ + new PlaylistItem(new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo) + { + RulesetID = new OsuRuleset().RulesetInfo.OnlineID + } + ]; + }); + AddStep("setup referee", () => MultiplayerClient.RoomSetupAction = r => r.Host!.Role = MultiplayerRoomUserRole.Referee); + ClickButtonWhenEnabled(); + + AddUntilStep("wait for join", () => RoomJoined); + + AddUntilStep("button visible", () => this.ChildrenOfType().Single().ChangeSettingsButton.Alpha, () => Is.GreaterThan(0)); + AddStep("join other user", void () => MultiplayerClient.AddUser(new APIUser { Id = PLAYER_1_ID })); + AddStep("make other user host", () => MultiplayerClient.TransferHost(PLAYER_1_ID)); + AddAssert("button hidden", () => this.ChildrenOfType().Single().ChangeSettingsButton.Alpha, () => Is.GreaterThan(0)); + } + [Test] public void TestUserModSelectUpdatesWhenNotVisible() { diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs index 7f1eb50ac2..8aaf85923c 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerParticipantsList.cs @@ -32,10 +32,8 @@ namespace osu.Game.Tests.Visual.Multiplayer { public partial class TestSceneMultiplayerParticipantsList : MultiplayerTestScene { - public override void SetUpSteps() + private void setUpList() { - base.SetUpSteps(); - AddStep("join room", () => JoinRoom(CreateDefaultRoom())); WaitForJoined(); createNewParticipantsList(); @@ -44,6 +42,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestAddUser() { + setUpList(); AddAssert("one unique panel", () => this.ChildrenOfType().Select(p => p.Current.Value).Distinct().Count() == 1); AddStep("add user", () => MultiplayerClient.AddUser(new APIUser @@ -59,6 +58,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestAddReferee() { + setUpList(); AddAssert("one unique panel", () => this.ChildrenOfType().Select(p => p.Current.Value).Distinct().Count() == 1); AddStep("add user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(3) @@ -78,6 +78,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestAddUnresolvedUser() { + setUpList(); AddAssert("one unique panel", () => this.ChildrenOfType().Select(p => p.Current.Value).Distinct().Count() == 1); AddStep("add non-resolvable user", () => MultiplayerClient.TestAddUnresolvedUser()); @@ -94,6 +95,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestRemoveUser() { + setUpList(); + APIUser? secondUser = null; AddStep("add a user", () => @@ -114,6 +117,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestGameStateHasPriorityOverDownloadState() { + setUpList(); AddStep("set to downloading map", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0))); checkProgressBarVisibility(true); @@ -128,6 +132,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestCorrectInitialState() { + setUpList(); AddStep("set to downloading map", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0))); createNewParticipantsList(); checkProgressBarVisibility(true); @@ -136,6 +141,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestBeatmapDownloadingStates() { + setUpList(); AddStep("set to unknown", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Unknown())); AddStep("set to no map", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.NotDownloaded())); AddStep("set to downloading map", () => MultiplayerClient.ChangeBeatmapAvailability(BeatmapAvailability.Downloading(0))); @@ -159,6 +165,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestToggleReadyState() { + setUpList(); AddAssert("ready mark invisible", () => !this.ChildrenOfType().Single().IsPresent); AddStep("make user ready", () => MultiplayerClient.ChangeState(MultiplayerUserState.Ready)); @@ -171,6 +178,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestToggleSpectateState() { + setUpList(); AddStep("make user spectating", () => MultiplayerClient.ChangeState(MultiplayerUserState.Spectating)); AddStep("make user idle", () => MultiplayerClient.ChangeState(MultiplayerUserState.Idle)); } @@ -178,6 +186,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestCrownChangesStateWhenHostTransferred() { + setUpList(); AddStep("add user", () => MultiplayerClient.AddUser(new APIUser { Id = 3, @@ -201,6 +210,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestHostGetsPinnedToTop() { + setUpList(); AddStep("add user", () => MultiplayerClient.AddUser(new APIUser { Id = 3, @@ -218,8 +228,9 @@ namespace osu.Game.Tests.Visual.Multiplayer } [Test] - public void TestKickButtonOnlyPresentWhenHost() + public void TestKickButtonPresentWhenHost() { + setUpList(); AddStep("add user", () => MultiplayerClient.AddUser(new APIUser { Id = 3, @@ -238,9 +249,33 @@ namespace osu.Game.Tests.Visual.Multiplayer AddUntilStep("kick buttons visible", () => this.ChildrenOfType().Count(d => d.IsPresent) == 1); } + [Test] + public void TestKickButtonPresentWhenReferee() + { + AddStep("set up referee", () => MultiplayerClient.RoomSetupAction = r => r.Host!.Role = MultiplayerRoomUserRole.Referee); + setUpList(); + AddStep("add user", () => MultiplayerClient.AddUser(new APIUser + { + Id = 3, + Username = "Second", + CoverUrl = TestResources.COVER_IMAGE_3, + })); + + AddUntilStep("kick buttons visible", () => this.ChildrenOfType().Count(d => d.IsPresent) == 1); + + AddStep("make second user host", () => MultiplayerClient.TransferHost(3)); + + AddUntilStep("kick buttons visible", () => this.ChildrenOfType().Count(d => d.IsPresent) == 1); + + AddStep("make local user host again", () => MultiplayerClient.TransferHost(API.LocalUser.Value.Id)); + + AddUntilStep("kick buttons visible", () => this.ChildrenOfType().Count(d => d.IsPresent) == 1); + } + [Test] public void TestKickButtonKicks() { + setUpList(); AddStep("add user", () => MultiplayerClient.AddUser(new APIUser { Id = 3, @@ -258,6 +293,7 @@ namespace osu.Game.Tests.Visual.Multiplayer { const int users_count = 200; + setUpList(); AddStep("add many users", () => { for (int i = 0; i < users_count; i++) @@ -316,6 +352,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestUserWithMods() { + setUpList(); AddStep("add user", () => { MultiplayerClient.AddUser(new APIUser @@ -353,6 +390,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestUserWithStyle() { + setUpList(); AddStep("add users", () => { MultiplayerClient.AddUser(new APIUser @@ -380,6 +418,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestModOverlap() { + setUpList(); AddStep("add dummy mods", () => { MultiplayerClient.ChangeUserMods(new Mod[] @@ -438,6 +477,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestModsAndRuleset() { + setUpList(); AddStep("add another user", () => { MultiplayerClient.AddUser(new APIUser @@ -472,6 +512,7 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestTeams() { + setUpList(); AddStep("enable teams", () => MultiplayerClient.ChangeSettings(matchType: MatchType.TeamVersus)); AddAssert("one unique panel", () => this.ChildrenOfType().Select(p => p.Current.Value).Distinct().Count() == 1); diff --git a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs index 2f54551fa8..68f06d9f74 100644 --- a/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs +++ b/osu.Game.Tests/Visual/Multiplayer/TestSceneMultiplayerQueueList.cs @@ -41,10 +41,8 @@ namespace osu.Game.Tests.Visual.Multiplayer Dependencies.Cache(Realm); } - public override void SetUpSteps() + private void setUpRoom() { - base.SetUpSteps(); - AddStep("create room", () => room = CreateDefaultRoom()); AddStep("join room", () => JoinRoom(room)); WaitForJoined(); @@ -80,6 +78,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestDeleteButtonAlwaysVisibleForHost() { + setUpRoom(); + AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers); @@ -92,6 +92,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestDeleteButtonOnlyVisibleForItemOwnerIfNotHost() { + setUpRoom(); + AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers); @@ -108,9 +110,35 @@ namespace osu.Game.Tests.Visual.Multiplayer assertDeleteButtonVisibility(2, true); } + [Test] + public void TestDeleteButtonAlwaysVisibleForReferee() + { + AddStep("ensure host will be referee", () => MultiplayerClient.RoomSetupAction = r => r.Host!.Role = MultiplayerRoomUserRole.Referee); + setUpRoom(); + + AddStep("join other user", () => MultiplayerClient.AddUser(new APIUser { Id = 1234 })); + + AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); + AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers); + + addPlaylistItem(() => API.LocalUser.Value.OnlineID); + assertDeleteButtonVisibility(1, true); + addPlaylistItem(() => 1234); + assertDeleteButtonVisibility(2, true); + + AddStep("set host only queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.HostOnly }).WaitSafely()); + AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.HostOnly); + AddStep("set other user as host", () => MultiplayerClient.TransferHost(1234)); + + assertDeleteButtonVisibility(1, true); + assertDeleteButtonVisibility(2, true); + } + [Test] public void TestSingleItemDoesNotHaveDeleteButton() { + setUpRoom(); + AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers); @@ -120,6 +148,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestCurrentItemHasDeleteButtonIfNotSingle() { + setUpRoom(); + AddStep("set all players queue mode", () => MultiplayerClient.ChangeSettings(new MultiplayerRoomSettings { QueueMode = QueueMode.AllPlayers }).WaitSafely()); AddUntilStep("wait for queue mode change", () => MultiplayerClient.ClientAPIRoom?.QueueMode == QueueMode.AllPlayers); @@ -139,6 +169,8 @@ namespace osu.Game.Tests.Visual.Multiplayer [Test] public void TestChangeExistingItem() { + setUpRoom(); + AddStep("change beatmap", () => MultiplayerClient.EditPlaylistItem(new MultiplayerPlaylistItem { ID = playlist.Items[0].ID, diff --git a/osu.Game/Online/Multiplayer/MultiplayerClient.cs b/osu.Game/Online/Multiplayer/MultiplayerClient.cs index ff974e2e6d..5742548fb9 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerClient.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerClient.cs @@ -196,6 +196,11 @@ namespace osu.Game.Online.Multiplayer } } + /// + /// Whether the is a referee in the . + /// + public virtual bool IsReferee => LocalUser?.Role == MultiplayerRoomUserRole.Referee; + [Resolved] protected IAPIProvider API { get; private set; } = null!; diff --git a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs index 3c02565fa1..c55b4001c6 100644 --- a/osu.Game/Online/Multiplayer/MultiplayerRoom.cs +++ b/osu.Game/Online/Multiplayer/MultiplayerRoom.cs @@ -92,7 +92,7 @@ namespace osu.Game.Online.Multiplayer /// Determines whether a user is able to add playlist items to this room. /// /// The user to check. - public bool CanAddPlaylistItems(MultiplayerRoomUser user) => user.Equals(Host) || Settings.QueueMode != QueueMode.HostOnly; + public bool CanAddPlaylistItems(MultiplayerRoomUser user) => user.Equals(Host) || user.Role == MultiplayerRoomUserRole.Referee || Settings.QueueMode != QueueMode.HostOnly; public override string ToString() => $"RoomID:{RoomID} Host:{Host?.UserID} Users:{Users.Count} State:{State} Settings: [{Settings}]"; } diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs index 97f30035cf..196eafa20c 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MatchStartControl.cs @@ -32,6 +32,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match [Resolved] private MultiplayerClient client { get; set; } = null!; + private MatchStartCountdown? currentMatchStartCountdown => client.Room?.ActiveCountdowns.OfType().SingleOrDefault(); + private readonly MultiplayerReadyButton readyButton; private readonly MultiplayerCountdownButton countdownButton; @@ -111,22 +113,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match Debug.Assert(clickOperation == null); clickOperation = ongoingOperationTracker.BeginOperation(); - if (client.IsHost) + if (client.IsReferee) + { + if (client.Room.State == MultiplayerRoomState.Open && currentMatchStartCountdown == null) + startMatch(); + else if (client.Room.State == MultiplayerRoomState.WaitingForLoad || client.Room.State == MultiplayerRoomState.Playing) + abortMatch(); + } + else if (client.IsHost) { if (client.Room.State == MultiplayerRoomState.Open) { - if (isReady() && !client.Room.ActiveCountdowns.Any(c => c is MatchStartCountdown)) + if (isReady() && currentMatchStartCountdown == null) startMatch(); else toggleReady(); } else - { - if (dialogOverlay == null) - abortMatch(); - else - dialogOverlay.Push(new ConfirmAbortDialog(abortMatch, endOperation)); - } + abortMatch(); } else if (client.Room.State != MultiplayerRoomState.Closed) toggleReady(); @@ -146,7 +150,15 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match endOperation(); }); - void abortMatch() => client.AbortMatch().FireAndForget(endOperation, _ => endOperation()); + void performAbort() => client.AbortMatch().FireAndForget(endOperation, _ => endOperation()); + + void abortMatch() + { + if (dialogOverlay == null) + performAbort(); + else + dialogOverlay.Push(new ConfirmAbortDialog(performAbort, endOperation)); + } } private void startCountdown(TimeSpan duration) @@ -159,14 +171,13 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match private void cancelCountdown() { - if (client.Room == null) + if (client.Room == null || currentMatchStartCountdown == null) return; Debug.Assert(clickOperation == null); clickOperation = ongoingOperationTracker.BeginOperation(); - MultiplayerCountdown countdown = client.Room.ActiveCountdowns.Single(c => c is MatchStartCountdown); - client.SendMatchRequest(new StopCountdownRequest(countdown.ID)).ContinueWith(_ => endOperation()); + client.SendMatchRequest(new StopCountdownRequest(currentMatchStartCountdown.ID)).ContinueWith(_ => endOperation()); } private void endOperation() @@ -186,10 +197,10 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match var localUser = client.LocalUser; - int newCountReady = client.Room.Users.Count(u => u.State == MultiplayerUserState.Ready); - int newCountTotal = client.Room.Users.Count(u => u.State != MultiplayerUserState.Spectating); + int newCountReady = client.Room.Users.Count(u => u.Role == MultiplayerRoomUserRole.Player && u.State == MultiplayerUserState.Ready); + int newCountTotal = client.Room.Users.Count(u => u.Role == MultiplayerRoomUserRole.Player && u.State != MultiplayerUserState.Spectating); - if (!client.IsHost || client.Room.Settings.AutoStartEnabled) + if ((!client.IsHost && !client.IsReferee) || client.Room.Settings.AutoStartEnabled || client.Room.State != MultiplayerRoomState.Open) countdownButton.Hide(); else { @@ -214,12 +225,16 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match // When the local user is the host and spectating the match, the ready button should be enabled only if any users are ready. if (localUser?.State == MultiplayerUserState.Spectating) - readyButton.Enabled.Value &= client.IsHost && newCountReady > 0 && !client.Room.ActiveCountdowns.Any(c => c is MatchStartCountdown); + readyButton.Enabled.Value &= (client.IsHost || client.IsReferee) && newCountReady > 0 && currentMatchStartCountdown == null; - // When the local user is not the host, the button should only be enabled when no match is in progress. - if (!client.IsHost) + // When the local user is not the host or a referee, the button should only be enabled when no match is in progress. + if (!client.IsHost && !client.IsReferee) readyButton.Enabled.Value &= client.Room.State == MultiplayerRoomState.Open; + // As a referee, readying up should not be possible, so if there is no match going on and no users readied up, prevent a match start. + if (client.IsReferee) + readyButton.Enabled.Value &= client.Room.State != MultiplayerRoomState.Open || newCountReady > 0; + // At all times, the countdown button should only be enabled when no match is in progress. countdownButton.Enabled.Value &= client.Room.State == MultiplayerRoomState.Open; diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs index 50e996d266..c885e613e4 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerCountdownButton.cs @@ -120,7 +120,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match }); } - if (multiplayerClient.Room?.ActiveCountdowns.Any(c => c is MatchStartCountdown) == true && multiplayerClient.IsHost) + if (multiplayerClient.Room?.ActiveCountdowns.Any(c => c is MatchStartCountdown) == true && (multiplayerClient.IsHost || multiplayerClient.IsReferee)) { flow.Add(new RoundedButton { diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs index 876f1cbd56..cbad4a07e9 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/MultiplayerReadyButton.cs @@ -122,14 +122,24 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match var localUser = multiplayerClient.LocalUser; - int countReady = room.Users.Count(u => u.State == MultiplayerUserState.Ready); - int countTotal = room.Users.Count(u => u.State != MultiplayerUserState.Spectating); + int countReady = room.Users.Count(u => u.Role == MultiplayerRoomUserRole.Player && u.State == MultiplayerUserState.Ready); + int countTotal = room.Users.Count(u => u.Role == MultiplayerRoomUserRole.Player && u.State != MultiplayerUserState.Spectating); string countText = $"({countReady} / {countTotal} ready)"; - if (countdown != null) - { - string countdownText = $"Starting in {countdownTimeRemaining:mm\\:ss}"; + string? countdownText = countdown != null ? $"Starting in {countdownTimeRemaining:mm\\:ss}" : null; + if (multiplayerClient.IsReferee) + { + if (room.State == MultiplayerRoomState.Open) + Text = countReady == 0 ? $"Waiting for players... {countText}" : $"{countdownText ?? "Start match"} {countText}"; + else + Text = "Abort match"; + + return; + } + + if (countdownText != null) + { switch (localUser?.State) { default: @@ -196,7 +206,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match { default: // Show the abort button for the host as long as gameplay is in progress. - if (multiplayerClient.IsHost && room.State != MultiplayerRoomState.Open) + if ((multiplayerClient.IsHost || multiplayerClient.IsReferee) && room.State != MultiplayerRoomState.Open) setRed(); else setGreen(); @@ -204,7 +214,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match case MultiplayerUserState.Spectating: case MultiplayerUserState.Ready: - if (multiplayerClient.IsHost && !room.ActiveCountdowns.Any(c => c is MatchStartCountdown)) + if ((multiplayerClient.IsHost || multiplayerClient.IsReferee) && !room.ActiveCountdowns.Any(c => c is MatchStartCountdown)) setGreen(); else setYellow(); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs index dc6a713908..fbd0a4cb19 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Match/Playlist/MultiplayerQueueList.cs @@ -66,8 +66,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Match.Playlist if (multiplayerClient.Room == null) return; - bool isItemOwner = Item.OwnerID == api.LocalUser.Value.OnlineID || multiplayerClient.IsHost; - bool isValidItem = isItemOwner && !Item.Expired; + bool isItemOwnerOrReferee = Item.OwnerID == api.LocalUser.Value.OnlineID || multiplayerClient.IsHost || multiplayerClient.IsReferee; + bool isValidItem = isItemOwnerOrReferee && !Item.Expired; AllowDeletion = isValidItem && (Item.ID != multiplayerClient.Room.Settings.PlaylistItemId // This is an optimisation for the following check. diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs index ca8c4c7cfe..0c31e1f0db 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerMatchSubScreen.cs @@ -596,7 +596,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer break; default: - targetScreen.Push(new MultiplayerPlayerLoader(() => new MultiplayerPlayer(room, new PlaylistItem(client.Room.CurrentPlaylistItem), users))); + if (!client.IsReferee) + targetScreen.Push(new MultiplayerPlayerLoader(() => new MultiplayerPlayer(room, new PlaylistItem(client.Room.CurrentPlaylistItem), users))); break; } } @@ -676,8 +677,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer Ruleset.Value = ruleset; Mods.Value = client.LocalUser.Mods.Concat(item.RequiredMods).Select(m => m.ToMod(rulesetInstance)).ToArray(); - bool freemods = item.Freestyle || item.AllowedMods.Any(); - bool freestyle = item.Freestyle; + bool freemods = !client.IsReferee && (item.Freestyle || item.AllowedMods.Any()); + bool freestyle = !client.IsReferee && item.Freestyle; if (freemods) userModsSection.Show(); @@ -921,8 +922,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (client.Room.CanAddPlaylistItems(client.LocalUser) != true) return; - // If there's only one playlist item and we are the host, assume we want to change it. Else add a new one. - PlaylistItem? itemToEdit = client.IsHost && room.Playlist.Count == 1 ? room.Playlist.Single() : null; + // If there's only one playlist item and we are the host / a referee, assume we want to change it. Else add a new one. + PlaylistItem? itemToEdit = (client.IsHost || client.IsReferee) && room.Playlist.Count == 1 ? room.Playlist.Single() : null; ShowSongSelect(itemToEdit); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomPanel.cs index e52133b46b..9811d76846 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/MultiplayerRoomPanel.cs @@ -58,7 +58,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer if (client.Room == null || client.LocalUser == null) return; - ChangeSettingsButton.Alpha = client.IsHost ? 1 : 0; + ChangeSettingsButton.Alpha = client.IsHost || client.IsReferee ? 1 : 0; SelectedItem.Value = new PlaylistItem(client.Room.CurrentPlaylistItem); }); diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs index fb6001bda0..352deb375a 100644 --- a/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Participants/ParticipantPanel.cs @@ -295,7 +295,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants userStyleDisplay.FadeOut(fade_time); } - kickButton.Alpha = client.IsHost && !user.Equals(client.LocalUser) ? 1 : 0; + kickButton.Alpha = (client.IsHost || client.IsReferee) && !user.Equals(client.LocalUser) ? 1 : 0; crown.Alpha = client.Room.Host?.Equals(user) == true ? 1 : 0; } @@ -312,8 +312,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants if (user.UserID == api.LocalUser.Value.Id) return null; - // If the local user is not the host of the room. - if (client.Room.Host?.UserID != api.LocalUser.Value.Id) + if (!client.IsHost && !client.IsReferee) return null; int targetUser = user.UserID; @@ -322,8 +321,8 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants { new OsuMenuItem("Give host", MenuItemType.Standard, () => { - // Ensure the local user is still host. - if (!client.IsHost) + // Ensure the local user is still host / a referee. + if (!client.IsHost && !client.IsReferee) return; client.TransferHost(targetUser).FireAndForget(); @@ -331,7 +330,7 @@ namespace osu.Game.Screens.OnlinePlay.Multiplayer.Participants new OsuMenuItem("Kick", MenuItemType.Destructive, () => { // Ensure the local user is still host. - if (!client.IsHost) + if (!client.IsHost && !client.IsReferee) return; client.KickUser(targetUser).FireAndForget();