Skip to content

Commit 7bab117

Browse files
authored
Allow referees to join matches via the lazer client to spectate (#453)
* Allow referees to join matches via the lazer client to spectate * Allow referees to perform room management actions via multiplayer hub too * Do not allow referees to perform actions related to direct gameplay * Document spectating-while-refereeing & its limitations * Fix spectating referee being removed from users on leaving room inside client * Allow referee to start match from client even when not readied up * Fix referee's player state getting into half broken state if the room gets closed via referee commands * Re-do referee kick/leave pathways I realised that my previous fixes weren't going far enough and really to avoid breakage *any* departure of the referee from their refereed room should prompt a kick from spectate in lazer as well.
1 parent b47df51 commit 7bab117

File tree

8 files changed

+188
-68
lines changed

8 files changed

+188
-68
lines changed

osu.Server.Spectator.PublicAPIDocs/referee-hub-api.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,4 +127,34 @@ in cases of known failures related to user input or other cases wherein the oper
127127
adjusted or it is performed in a different state of the room.
128128
These errors are listed in [`ThrowHelper`](xref:osu.Server.Spectator.Hubs.Referee.ThrowHelper).
129129

130+
## Spectating your match
131+
132+
In order to facilitate spectating or streaming of the match, referees are permitted to join matches using one account
133+
in a dual capacity: firstly via a refereeing client in order to invoke refereeing commands, and secondly, via the lazer
134+
client in order to spectate the match.
135+
136+
> [!WARNING]
137+
> Currently, to achieve this setup, a referee must join the room via the referee hub **first** and join the room via
138+
> the lazer client **second**. Attempting to perform these operations in the reverse order will not succeed.
139+
>
140+
> Similarly, disconnecting from the referee hub in any way (by leaving the room willfully, getting removed as referee,
141+
> or a transient disconnection), as well as closing the room, will kick the user from the room in the lazer client.
142+
>
143+
> In other words, the spectating session in lazer should be considered **completely secondary** to the refereeing
144+
> session in the referee hub.
145+
146+
In this setup:
147+
- Referees can perform room management actions both via the referee API and via the lazer client,
148+
to identical effect.
149+
- Some operations available via the referee API may not be exposed in the client.
150+
Currently that list includes:
151+
- [banning players](xref:osu.Server.Spectator.Hubs.Referee.IRefereeHubServer.BanUser(System.Int64,System.Int32)),
152+
- [adding](xref:osu.Server.Spectator.Hubs.Referee.IRefereeHubServer.AddReferee(System.Int64,System.Int32)) and
153+
[removing](xref:osu.Server.Spectator.Hubs.Referee.IRefereeHubServer.RemoveReferee(System.Int64,System.Int32))
154+
referees,
155+
- [moving users to a given team](xref:osu.Server.Spectator.Hubs.Referee.IRefereeHubServer.MoveUser(System.Int64,osu.Server.Spectator.Hubs.Referee.Models.Requests.MoveUserRequest)),
156+
- [(un)locking the room](xref:osu.Server.Spectator.Hubs.Referee.IRefereeHubServer.SetLockState(System.Int64,osu.Server.Spectator.Hubs.Referee.Models.Requests.SetLockStateRequest)).
157+
- Referees cannot participate in gameplay or perform actions relevant to gameplay participation, such as: readying up,
158+
progressing to gameplay, joining teams, selecting user mods or user style.
159+
130160
<!-- TODO: handling disconnections -->

osu.Server.Spectator/Extensions/MultiplayerRoomUserExtensions.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ public static class MultiplayerRoomUserExtensions
1212
/// Whether a user is in a state capable of starting gameplay.
1313
/// </summary>
1414
public static bool IsReadyForGameplay(this MultiplayerRoomUser user)
15-
=> user.BeatmapAvailability.State == DownloadState.LocallyAvailable && (user.State == MultiplayerUserState.Ready || user.State == MultiplayerUserState.Idle);
15+
=> user.Role == MultiplayerRoomUserRole.Player
16+
&& user.BeatmapAvailability.State == DownloadState.LocallyAvailable
17+
&& (user.State == MultiplayerUserState.Ready || user.State == MultiplayerUserState.Idle);
1618
}
1719
}

osu.Server.Spectator/Hubs/Multiplayer/MultiplayerEventDispatcher.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,14 @@ await logToDatabase(new multiplayer_realtime_room_event
294294
});
295295
}
296296

297+
/// <summary>
298+
/// A referee spectating the match inside the lazer client needs to be kicked from spectate.
299+
/// </summary>
300+
public async Task PostSpectatingRefereeKickedFromOwnMatchAsync(MultiplayerRoomUser user)
301+
{
302+
await multiplayerHubContext.Clients.User(user.UserID.ToString()).SendAsync(nameof(IMultiplayerClient.UserKicked), user);
303+
}
304+
297305
/// <summary>
298306
/// A user has been banned from the room.
299307
/// </summary>

osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ public async Task TransferHost(int userId)
184184

185185
room.Log($"Transferring host from {room.Host?.UserID} to {userId}");
186186

187-
ensureIsHost(room);
187+
ensureIsHostOrReferee(room);
188188

189189
await room.SetHost(userId);
190190
}
@@ -209,7 +209,7 @@ public async Task KickUser(int userId)
209209
if (userId == userUsage.Item.UserId)
210210
throw new InvalidStateException("Can't kick self");
211211

212-
ensureIsHost(room);
212+
ensureIsHostOrReferee(room);
213213

214214
var kickTarget = room.Users.FirstOrDefault(u => u.UserID == userId);
215215

@@ -325,12 +325,12 @@ public async Task SendMatchRequest(MatchUserRequest request)
325325
switch (request)
326326
{
327327
case StartMatchCountdownRequest startMatchCountdownRequest:
328-
ensureIsHost(room);
328+
ensureIsHostOrReferee(room);
329329
await room.StartMatchCountdown(startMatchCountdownRequest.Duration);
330330
break;
331331

332332
case StopCountdownRequest stopCountdownRequest:
333-
ensureIsHost(room);
333+
ensureIsHostOrReferee(room);
334334
await room.StopCountdown(stopCountdownRequest.ID);
335335
break;
336336

@@ -355,9 +355,9 @@ public async Task StartMatch()
355355
if (room == null)
356356
throw new InvalidOperationException("Attempted to operate on a null room");
357357

358-
ensureIsHost(room);
358+
ensureIsHostOrReferee(room);
359359

360-
if (room.Host != null && room.Host.State != MultiplayerUserState.Spectating && room.Host.State != MultiplayerUserState.Ready)
360+
if (room.Host != null && room.Host.State != MultiplayerUserState.Spectating && room.Host.State != MultiplayerUserState.Ready && room.Host.Role != MultiplayerRoomUserRole.Referee)
361361
throw new InvalidStateException("Can't start match when the host is not ready.");
362362

363363
if (room.Users.All(u => u.State != MultiplayerUserState.Ready))
@@ -380,7 +380,7 @@ public async Task AbortMatch()
380380
if (room == null)
381381
throw new InvalidOperationException("Attempted to operate on a null room");
382382

383-
ensureIsHost(room);
383+
ensureIsHostOrReferee(room);
384384

385385
await room.AbortMatch();
386386
}
@@ -485,7 +485,7 @@ public async Task ChangeSettings(MultiplayerRoomSettings settings)
485485
if (room == null)
486486
throw new InvalidOperationException("Attempted to operate on a null room");
487487

488-
ensureIsHost(room);
488+
ensureIsHostOrReferee(room);
489489

490490
settings.Name = await chatFilters.FilterAsync(settings.Name);
491491
await room.ChangeRoomSettings(settings);
@@ -510,9 +510,12 @@ protected override async Task CleanUpState(ItemUsage<MultiplayerClientState> sta
510510
/// <summary>
511511
/// Ensure the local user is the host of the room, and throw if they are not.
512512
/// </summary>
513-
private void ensureIsHost(MultiplayerRoom room)
513+
private void ensureIsHostOrReferee(MultiplayerRoom room)
514514
{
515-
if (room.Host?.UserID != Context.GetUserId())
515+
bool isHost = room.Host?.UserID == Context.GetUserId();
516+
bool isReferee = room.Users.FirstOrDefault(u => u.UserID == Context.GetUserId())?.Role == MultiplayerRoomUserRole.Referee;
517+
518+
if (!isHost && !isReferee)
516519
throw new NotHostException();
517520
}
518521

osu.Server.Spectator/Hubs/Multiplayer/MultiplayerRoomController.cs

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,6 @@ private async Task<MultiplayerRoom> joinOrCreateRoom(long roomId, IMultiplayerUs
7373
{
7474
room = roomUsage.Item ??= await ServerMultiplayerRoom.InitialiseAsync(roomId, this, databaseFactory, eventDispatcher, loggerFactory);
7575

76-
// this is a sanity check to keep *rooms* in a good state.
77-
// in theory the connection clean-up code should handle this correctly.
78-
if (room.Users.Any(u => u.UserID == roomUser.UserID))
79-
throw new InvalidOperationException($"User {roomUser.UserID} attempted to join room {room.RoomID} they are already present in.");
80-
8176
if (!await room.UserCanJoin(roomUser.UserID))
8277
throw new InvalidStateException("Not eligible to join this room.");
8378

@@ -92,7 +87,13 @@ private async Task<MultiplayerRoom> joinOrCreateRoom(long roomId, IMultiplayerUs
9287

9388
userState.AssociateWithRoom(roomId);
9489

95-
await room.AddUser(roomUser);
90+
var existingUser = room.Users.FirstOrDefault(u => u.UserID == roomUser.UserID);
91+
92+
if (existingUser == null)
93+
await room.AddUser(roomUser);
94+
else if (!isRefereeSpectatingOwnMatch(userState, existingUser))
95+
throw new InvalidOperationException($"User {roomUser.UserID} attempted to join room {room.RoomID} they are already present in.");
96+
9697
await userState.SubscribeToEvents(eventDispatcher, roomId);
9798

9899
room.Log(roomUser, "User joined");
@@ -208,7 +209,18 @@ private async Task removeUserFromRoom(IMultiplayerUserState state, ItemUsage<Ser
208209

209210
await state.UnsubscribeFromEvents(eventDispatcher, room.RoomID);
210211

211-
var user = await room.RemoveUser(state.UserId);
212+
var user = room.Users.FirstOrDefault(u => u.UserID == state.UserId);
213+
if (user == null)
214+
throw new InvalidStateException("User is not in the room.");
215+
216+
if (isRefereeSpectatingOwnMatch(state, user))
217+
{
218+
await eventDispatcher.PostSpectatingRefereeKickedFromOwnMatchAsync(user);
219+
return;
220+
}
221+
222+
await room.RemoveUser(state.UserId);
223+
212224
bool wasKick = removingUserId != user.UserID;
213225
room.Log(user, wasKick ? "User kicked" : "User left");
214226

@@ -254,6 +266,14 @@ private async Task removeUserFromRoom(IMultiplayerUserState state, ItemUsage<Ser
254266
}
255267
}
256268

269+
/// <remarks>
270+
/// We want to allow exceptions wherein existing referees can also join their refereed rooms via the client to spectate them.
271+
/// In that case, hook up all associations but don't add/remove the user in the room model.
272+
/// Notably this flow requires the user to join as referee <i>first</i> and as spectator in client <i>second</i>.
273+
/// </remarks>
274+
private static bool isRefereeSpectatingOwnMatch(IMultiplayerUserState state, MultiplayerRoomUser user)
275+
=> user.Role == MultiplayerRoomUserRole.Referee && state is MultiplayerClientState;
276+
257277
private async Task endMatch(MultiplayerRoom room, int disbandingUserId)
258278
{
259279
using (var db = databaseFactory.GetInstance())

osu.Server.Spectator/Hubs/Multiplayer/ServerMultiplayerRoom.cs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ public async Task AddUser(MultiplayerRoomUser user)
358358
/// </summary>
359359
/// <returns>The <see cref="MultiplayerRoomUser"/> who was removed.</returns>
360360
/// <exception cref="InvalidStateException">The user with the supplied <paramref name="userId"/> was not in the room.</exception>
361-
public async Task<MultiplayerRoomUser> RemoveUser(int userId)
361+
public async Task RemoveUser(int userId)
362362
{
363363
var user = Users.FirstOrDefault(u => u.UserID == userId);
364364

@@ -373,7 +373,6 @@ public async Task<MultiplayerRoomUser> RemoveUser(int userId)
373373

374374
await MatchController.HandleUserLeft(user);
375375
await UpdateRoomStateIfRequired();
376-
return user;
377376
}
378377

379378
/// <summary>
@@ -463,7 +462,7 @@ public async Task ChangeUserState(int userId, MultiplayerUserState newState)
463462

464463
Log(user, $"User changing state from {user.State} to {newState}");
465464

466-
ensureValidStateSwitch(user.State, newState);
465+
ensureValidStateSwitch(user.Role, user.State, newState);
467466

468467
await ChangeAndBroadcastUserState(user, newState);
469468

@@ -480,9 +479,10 @@ public async Task ChangeUserState(int userId, MultiplayerUserState newState)
480479
/// <summary>
481480
/// Given this room and a state transition, throw if there's an issue with the sequence of events.
482481
/// </summary>
482+
/// <param name="userRole">The user's role.</param>
483483
/// <param name="oldState">The old state.</param>
484484
/// <param name="newState">The new state.</param>
485-
private void ensureValidStateSwitch(MultiplayerUserState oldState, MultiplayerUserState newState)
485+
private void ensureValidStateSwitch(MultiplayerRoomUserRole userRole, MultiplayerUserState oldState, MultiplayerUserState newState)
486486
{
487487
switch (newState)
488488
{
@@ -541,6 +541,13 @@ private void ensureValidStateSwitch(MultiplayerUserState oldState, MultiplayerUs
541541
default:
542542
throw new ArgumentOutOfRangeException(nameof(newState), newState, null);
543543
}
544+
545+
if (userRole == MultiplayerRoomUserRole.Referee
546+
&& newState != MultiplayerUserState.Idle
547+
&& newState != MultiplayerUserState.Spectating)
548+
{
549+
throw new InvalidStateException("Referees cannot participate in the match.");
550+
}
544551
}
545552

546553
public async Task ChangeAndBroadcastUserState(MultiplayerRoomUser user, MultiplayerUserState state)
@@ -619,6 +626,9 @@ public async Task ChangeUserStyle(int userId, int? beatmapId, int? rulesetId)
619626
if (user == null)
620627
throw new InvalidStateException("User is not in the room.");
621628

629+
if (user.Role == MultiplayerRoomUserRole.Referee)
630+
throw new InvalidStateException("Referees do not participate in the match and cannot set their own style.");
631+
622632
await changeUserStyle(user, beatmapId, rulesetId);
623633
}
624634

@@ -681,6 +691,9 @@ public async Task ChangeUserMods(int userId, IEnumerable<APIMod> newMods)
681691
if (user == null)
682692
throw new InvalidStateException("User is not in the room.");
683693

694+
if (user.Role == MultiplayerRoomUserRole.Referee)
695+
throw new InvalidStateException("Referees do not participate in the match and cannot set their own mods.");
696+
684697
await changeUserMods(user, newMods);
685698
}
686699

@@ -919,6 +932,9 @@ public async Task VoteToSkipIntro(int userId)
919932
if (user == null)
920933
throw new InvalidStateException("User is not in the room.");
921934

935+
if (user.Role == MultiplayerRoomUserRole.Referee)
936+
throw new InvalidStateException("Referees do not participate in the match and cannot vote to skip.");
937+
922938
if (!user.State.IsGameplayState())
923939
throw new InvalidStateException("Cannot skip while not in gameplay.");
924940

osu.Server.Spectator/Hubs/Multiplayer/Standard/StandardMatchController.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,9 @@ public virtual Task HandleUserStateChanged(MultiplayerRoomUser user)
133133
return Task.CompletedTask;
134134
}
135135

136+
private bool isHostOrReferee(MultiplayerRoomUser user)
137+
=> user.Equals(room.Host) || user.Role == MultiplayerRoomUserRole.Referee;
138+
136139
/// <summary>
137140
/// Add a playlist item to the room's queue.
138141
/// </summary>
@@ -143,12 +146,11 @@ public virtual Task HandleUserStateChanged(MultiplayerRoomUser user)
143146
public virtual async Task AddPlaylistItem(MultiplayerPlaylistItem item, MultiplayerRoomUser user)
144147
{
145148
bool isHostOnly = room.Settings.QueueMode == QueueMode.HostOnly;
146-
bool isHostOrReferee = user.Equals(room.Host) || user.Role == MultiplayerRoomUserRole.Referee;
147149

148-
if (isHostOnly && !isHostOrReferee)
150+
if (isHostOnly && !isHostOrReferee(user))
149151
throw new NotHostException();
150152

151-
int limit = isHostOrReferee ? HOST_PLAYLIST_LIMIT : GUEST_PLAYLIST_LIMIT;
153+
int limit = isHostOrReferee(user) ? HOST_PLAYLIST_LIMIT : GUEST_PLAYLIST_LIMIT;
152154

153155
if (room.Playlist.Count(i => i.OwnerID == user.UserID && !i.Expired) >= limit)
154156
throw new InvalidStateException($"Can't enqueue more than {limit} items at once.");
@@ -218,8 +220,7 @@ public virtual async Task EditPlaylistItem(MultiplayerPlaylistItem item, Multipl
218220
if (existingItem == null)
219221
throw new InvalidStateException("Attempted to change an item that doesn't exist.");
220222

221-
bool isHostOrReferee = user.Equals(room.Host) || user.Role == MultiplayerRoomUserRole.Referee;
222-
if (existingItem.OwnerID != user.UserID && !isHostOrReferee)
223+
if (existingItem.OwnerID != user.UserID && !isHostOrReferee(user))
223224
throw new InvalidStateException("Attempted to change an item which is not owned by the user.");
224225

225226
if (existingItem.Expired)
@@ -243,7 +244,6 @@ public virtual async Task EditPlaylistItem(MultiplayerPlaylistItem item, Multipl
243244
public virtual async Task RemovePlaylistItem(long playlistItemId, MultiplayerRoomUser user)
244245
{
245246
var item = room.Playlist.FirstOrDefault(item => item.ID == playlistItemId);
246-
bool isHostOrReferee = user.Equals(room.Host) || user.Role == MultiplayerRoomUserRole.Referee;
247247

248248
if (item == null)
249249
throw new InvalidStateException("Item does not exist in the room.");
@@ -258,7 +258,7 @@ public virtual async Task RemovePlaylistItem(long playlistItemId, MultiplayerRoo
258258
throw new InvalidStateException("The current item in the room cannot be removed when currently being played.");
259259
}
260260

261-
if (item.OwnerID != user.UserID && !isHostOrReferee)
261+
if (item.OwnerID != user.UserID && !isHostOrReferee(user))
262262
throw new InvalidStateException("Attempted to remove an item which is not owned by the user.");
263263

264264
if (item.Expired)

0 commit comments

Comments
 (0)