1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-20 10:00:09 +08:00

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 <pe@ppy.sh>
This commit is contained in:
Bartłomiej Dach
2026-04-03 12:38:44 +02:00
committed by GitHub
Unverified
parent 5b6d215583
commit 31c1168d76
13 changed files with 352 additions and 94 deletions
@@ -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<MultiplayerUserState>()))
.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<MatchUserRequest>()))
.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<MultiplayerUserState>()))
.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<MatchUserRequest>()))
.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<MultiplayerReadyButton>();
// Start match
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("countdown button disabled", () => !this.ChildrenOfType<MultiplayerCountdownButton>().Single().Enabled.Value);
// Abort
ClickButtonWhenEnabled<MultiplayerReadyButton>();
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<MultiplayerReadyButton>();
[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<MultiplayerReadyButton>().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<MultiplayerReadyButton>().Single().Enabled.Value, () => Is.True);
setUpMatchCallbacks();
// start match
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("countdown button disabled", () => !this.ChildrenOfType<MultiplayerCountdownButton>().Single().Enabled.Value);
// Abort
// abort
ClickButtonWhenEnabled<MultiplayerReadyButton>();
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<MultiplayerReadyButton>().Single().Enabled.Value, () => Is.False);
AddStep("ready up a user", () => changeUserState(9, MultiplayerUserState.Ready));
AddAssert("button enabled", () => this.ChildrenOfType<MultiplayerReadyButton>().Single().Enabled.Value, () => Is.True);
setUpMatchCallbacks();
// start match
ClickButtonWhenEnabled<MultiplayerReadyButton>();
AddUntilStep("countdown button disabled", () => !this.ChildrenOfType<MultiplayerCountdownButton>().Single().Enabled.Value);
// abort
ClickButtonWhenEnabled<MultiplayerReadyButton>();
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<MultiplayerReadyButton>().Single().Enabled.Value, () => Is.False);
AddStep("ready up a user", () => changeUserState(9, MultiplayerUserState.Ready));
AddAssert("button enabled", () => this.ChildrenOfType<MultiplayerReadyButton>().Single().Enabled.Value, () => Is.True);
setUpMatchCallbacks();
AddUntilStep("countdown button shown", () => this.ChildrenOfType<MultiplayerCountdownButton>().SingleOrDefault()?.IsPresent == true);
ClickButtonWhenEnabled<MultiplayerCountdownButton>();
AddStep("click the first countdown button", () =>
{
var popoverButton = this.ChildrenOfType<Popover>().Single().ChildrenOfType<OsuButton>().First();
InputManager.MoveMouseTo(popoverButton);
InputManager.Click(MouseButton.Left);
});
AddStep("check request received", () =>
{
multiplayerClient.Verify(m => m.SendMatchRequest(It.Is<StartMatchCountdownRequest>(req =>
req.Duration == TimeSpan.FromSeconds(10)
)), Times.Once);
});
ClickButtonWhenEnabled<MultiplayerCountdownButton>();
AddStep("click the cancel button", () =>
{
var popoverButton = this.ChildrenOfType<Popover>().Single().ChildrenOfType<OsuButton>().Last();
InputManager.MoveMouseTo(popoverButton);
InputManager.Click(MouseButton.Left);
});
AddStep("check request received", () =>
{
multiplayerClient.Verify(m => m.SendMatchRequest(It.IsAny<StopCountdownRequest>()), Times.Once);
});
}
private void verifyGameplayStartFlow()
{
checkLocalUserState(MultiplayerUserState.Ready);
@@ -353,6 +353,30 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddAssert("button hidden", () => this.ChildrenOfType<MultiplayerRoomPanel>().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<MultiplayerMatchSettingsOverlay.CreateOrUpdateButton>();
AddUntilStep("wait for join", () => RoomJoined);
AddUntilStep("button visible", () => this.ChildrenOfType<MultiplayerRoomPanel>().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<MultiplayerRoomPanel>().Single().ChangeSettingsButton.Alpha, () => Is.GreaterThan(0));
}
[Test]
public void TestUserModSelectUpdatesWhenNotVisible()
{
@@ -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<ParticipantPanel>().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<ParticipantPanel>().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<ParticipantPanel>().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<StateDisplay>().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<ParticipantPanel.KickButton>().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<ParticipantPanel.KickButton>().Count(d => d.IsPresent) == 1);
AddStep("make second user host", () => MultiplayerClient.TransferHost(3));
AddUntilStep("kick buttons visible", () => this.ChildrenOfType<ParticipantPanel.KickButton>().Count(d => d.IsPresent) == 1);
AddStep("make local user host again", () => MultiplayerClient.TransferHost(API.LocalUser.Value.Id));
AddUntilStep("kick buttons visible", () => this.ChildrenOfType<ParticipantPanel.KickButton>().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<ParticipantPanel>().Select(p => p.Current.Value).Distinct().Count() == 1);
@@ -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,
@@ -196,6 +196,11 @@ namespace osu.Game.Online.Multiplayer
}
}
/// <summary>
/// Whether the <see cref="LocalUser"/> is a referee in the <see cref="Room"/>.
/// </summary>
public virtual bool IsReferee => LocalUser?.Role == MultiplayerRoomUserRole.Referee;
[Resolved]
protected IAPIProvider API { get; private set; } = null!;
@@ -92,7 +92,7 @@ namespace osu.Game.Online.Multiplayer
/// Determines whether a user is able to add playlist items to this room.
/// </summary>
/// <param name="user">The user to check.</param>
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}]";
}
@@ -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<MatchStartCountdown>().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;
@@ -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
{
@@ -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();
@@ -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.
@@ -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);
@@ -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);
});
@@ -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();