From 7e3d5baf5ee529a901953d1a4ed780067782da32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Tue, 14 Jan 2025 14:44:31 +0100 Subject: [PATCH 1/6] Notify users of who is spectating them Server-side part of https://github.com/ppy/osu/issues/22087. --- SampleSpectatorClient/SpectatorClient.cs | 13 +++++ .../SpectatorHubTest.cs | 13 ++++- .../Extensions/ServiceCollectionExtensions.cs | 1 + .../Hubs/Spectator/SpectatorHub.cs | 50 +++++++++++++++++++ .../Hubs/Spectator/SpectatorList.cs | 13 +++++ 5 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 osu.Server.Spectator/Hubs/Spectator/SpectatorList.cs diff --git a/SampleSpectatorClient/SpectatorClient.cs b/SampleSpectatorClient/SpectatorClient.cs index 0cf2f636..53efe869 100644 --- a/SampleSpectatorClient/SpectatorClient.cs +++ b/SampleSpectatorClient/SpectatorClient.cs @@ -64,6 +64,19 @@ Task ISpectatorClient.UserScoreProcessed(int userId, long scoreId) return Task.CompletedTask; } + Task ISpectatorClient.UserStartedWatching(SpectatorUser[] users) + { + foreach (var user in users) + Console.WriteLine($"{connection.ConnectionId} User {user.OnlineID} started watching you"); + return Task.CompletedTask; + } + + Task ISpectatorClient.UserEndedWatching(int userId) + { + Console.WriteLine($"{connection.ConnectionId} User {userId} ended watching you"); + return Task.CompletedTask; + } + public Task BeginPlaying(long? scoreToken, SpectatorState state) => connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), scoreToken, state); public Task SendFrames(FrameDataBundle data) => connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data); diff --git a/osu.Server.Spectator.Tests/SpectatorHubTest.cs b/osu.Server.Spectator.Tests/SpectatorHubTest.cs index 1893f184..626d8d9e 100644 --- a/osu.Server.Spectator.Tests/SpectatorHubTest.cs +++ b/osu.Server.Spectator.Tests/SpectatorHubTest.cs @@ -10,7 +10,6 @@ using Moq; using osu.Game.Beatmaps; using osu.Game.Online.API; -using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Spectator; using osu.Game.Replays.Legacy; using osu.Game.Rulesets.Osu.Mods; @@ -42,6 +41,7 @@ public class SpectatorHubTest public SpectatorHubTest() { var clientStates = new EntityStore(); + var spectatorLists = new EntityStore(); mockDatabase = new Mock(); mockDatabase.Setup(db => db.GetUsernameAsync(streamer_id)).ReturnsAsync(() => streamer_username); @@ -65,7 +65,7 @@ public SpectatorHubTest() var mockScoreProcessedSubscriber = new Mock(); - hub = new SpectatorHub(loggerFactory.Object, clientStates, databaseFactory.Object, scoreUploader, mockScoreProcessedSubscriber.Object); + hub = new SpectatorHub(loggerFactory.Object, clientStates, spectatorLists, databaseFactory.Object, scoreUploader, mockScoreProcessedSubscriber.Object); } [Fact] @@ -325,9 +325,12 @@ public async Task NewUserBeginsWatchingStream(bool ongoing) Mock> mockClients = new Mock>(); Mock mockCaller = new Mock(); + Mock mockStreamer = new Mock(); mockClients.Setup(clients => clients.Caller).Returns(mockCaller.Object); mockClients.Setup(clients => clients.All).Returns(mockCaller.Object); + mockClients.Setup(clients => clients.User(streamer_id.ToString())).Returns(mockStreamer.Object); + mockDatabase.Setup(db => db.GetUsernameAsync(watcher_id)).ReturnsAsync("watcher"); Mock mockGroups = new Mock(); @@ -362,6 +365,12 @@ public async Task NewUserBeginsWatchingStream(bool ongoing) mockGroups.Verify(groups => groups.AddToGroupAsync(connectionId, SpectatorHub.GetGroupId(streamer_id), default)); mockCaller.Verify(clients => clients.UserBeganPlaying(streamer_id, It.Is(m => m.Equals(state))), Times.Exactly(ongoing ? 2 : 0)); + mockStreamer.Verify(client => client.UserStartedWatching(It.Is(users => users.Single().OnlineID == watcher_id)), Times.Once); + + await hub.EndWatchingUser(streamer_id); + + mockGroups.Verify(groups => groups.RemoveFromGroupAsync(connectionId, SpectatorHub.GetGroupId(streamer_id), default)); + mockStreamer.Verify(client => client.UserEndedWatching(watcher_id), Times.Once); } [Fact] diff --git a/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs b/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs index c1d90a31..4bcfbc6b 100644 --- a/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs +++ b/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddHubEntities(this IServiceCollection serviceCollection) { return serviceCollection.AddSingleton>() + .AddSingleton>() .AddSingleton>() .AddSingleton>() .AddSingleton>() diff --git a/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs b/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs index 43c42b56..fdc51391 100644 --- a/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs +++ b/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs @@ -31,6 +31,7 @@ public class SpectatorHub : StatefulUserHub private const BeatmapOnlineStatus max_beatmap_status_for_replays = BeatmapOnlineStatus.Loved; + private readonly EntityStore spectatorLists; private readonly IDatabaseFactory databaseFactory; private readonly ScoreUploader scoreUploader; private readonly IScoreProcessedSubscriber scoreProcessedSubscriber; @@ -38,11 +39,13 @@ public class SpectatorHub : StatefulUserHub users, + EntityStore spectatorLists, IDatabaseFactory databaseFactory, ScoreUploader scoreUploader, IScoreProcessedSubscriber scoreProcessedSubscriber) : base(loggerFactory, users) { + this.spectatorLists = spectatorLists; this.databaseFactory = databaseFactory; this.scoreUploader = scoreUploader; this.scoreProcessedSubscriber = scoreProcessedSubscriber; @@ -203,11 +206,47 @@ public async Task StartWatchingUser(int userId) } await Groups.AddToGroupAsync(Context.ConnectionId, GetGroupId(userId)); + + int watcherId = Context.GetUserId(); + string? watcherUsername; + using (var db = databaseFactory.GetInstance()) + watcherUsername = await db.GetUsernameAsync(watcherId); + + if (watcherUsername == null) + return; + + var watcher = new SpectatorUser + { + OnlineID = watcherId, + Username = watcherUsername, + }; + + using (var usage = await spectatorLists.GetForUse(userId, createOnMissing: true)) + { + usage.Item ??= new SpectatorList(); + usage.Item.Spectators[watcherId] = watcher; + } + + await Clients.User(userId.ToString()).UserStartedWatching([watcher]); } public async Task EndWatchingUser(int userId) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, GetGroupId(userId)); + + int watcherId = Context.GetUserId(); + + using (var usage = await spectatorLists.TryGetForUse(userId)) + { + if (usage?.Item == null) + return; + + usage.Item.Spectators.Remove(watcherId); + if (usage.Item.Spectators.Count == 0) + usage.Destroy(); + } + + await Clients.User(userId.ToString()).UserEndedWatching(watcherId); } public override async Task OnConnectedAsync() @@ -217,6 +256,17 @@ public override async Task OnConnectedAsync() foreach (var kvp in GetAllStates()) await Clients.Caller.UserBeganPlaying((int)kvp.Key, kvp.Value.State!); + SpectatorUser[]? watchers = null; + + using (var usage = await spectatorLists.TryGetForUse(Context.GetUserId())) + { + if (usage?.Item != null) + watchers = usage.Item.Spectators.Values.ToArray(); + } + + if (watchers != null) + await Clients.Caller.UserStartedWatching(watchers); + await base.OnConnectedAsync(); } diff --git a/osu.Server.Spectator/Hubs/Spectator/SpectatorList.cs b/osu.Server.Spectator/Hubs/Spectator/SpectatorList.cs new file mode 100644 index 00000000..49540bd0 --- /dev/null +++ b/osu.Server.Spectator/Hubs/Spectator/SpectatorList.cs @@ -0,0 +1,13 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Collections.Generic; +using osu.Game.Online.Spectator; + +namespace osu.Server.Spectator.Hubs.Spectator +{ + public class SpectatorList + { + public Dictionary Spectators { get; } = new Dictionary(); + } +} From bd6d9bba392ac250d5d90a9b6593cecc0b0a8351 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Fri, 17 Jan 2025 11:53:16 +0100 Subject: [PATCH 2/6] Remove spectator tracking In discussion it appears that the consensus is that the player should not be able to spectate offline players, and as such the server is allowed to purge the list of spectators on disconnection of the streamer. This in turn obviates the need of doing any external tracking of who's spectating whom in the hub. --- .../Extensions/ServiceCollectionExtensions.cs | 1 - .../Hubs/Spectator/SpectatorHub.cs | 30 ------------------- .../Hubs/Spectator/SpectatorList.cs | 13 -------- 3 files changed, 44 deletions(-) delete mode 100644 osu.Server.Spectator/Hubs/Spectator/SpectatorList.cs diff --git a/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs b/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs index 4bcfbc6b..c1d90a31 100644 --- a/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs +++ b/osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs @@ -18,7 +18,6 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddHubEntities(this IServiceCollection serviceCollection) { return serviceCollection.AddSingleton>() - .AddSingleton>() .AddSingleton>() .AddSingleton>() .AddSingleton>() diff --git a/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs b/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs index fdc51391..c913e164 100644 --- a/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs +++ b/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs @@ -31,7 +31,6 @@ public class SpectatorHub : StatefulUserHub private const BeatmapOnlineStatus max_beatmap_status_for_replays = BeatmapOnlineStatus.Loved; - private readonly EntityStore spectatorLists; private readonly IDatabaseFactory databaseFactory; private readonly ScoreUploader scoreUploader; private readonly IScoreProcessedSubscriber scoreProcessedSubscriber; @@ -39,13 +38,11 @@ public class SpectatorHub : StatefulUserHub users, - EntityStore spectatorLists, IDatabaseFactory databaseFactory, ScoreUploader scoreUploader, IScoreProcessedSubscriber scoreProcessedSubscriber) : base(loggerFactory, users) { - this.spectatorLists = spectatorLists; this.databaseFactory = databaseFactory; this.scoreUploader = scoreUploader; this.scoreProcessedSubscriber = scoreProcessedSubscriber; @@ -221,12 +218,6 @@ public async Task StartWatchingUser(int userId) Username = watcherUsername, }; - using (var usage = await spectatorLists.GetForUse(userId, createOnMissing: true)) - { - usage.Item ??= new SpectatorList(); - usage.Item.Spectators[watcherId] = watcher; - } - await Clients.User(userId.ToString()).UserStartedWatching([watcher]); } @@ -236,16 +227,6 @@ public async Task EndWatchingUser(int userId) int watcherId = Context.GetUserId(); - using (var usage = await spectatorLists.TryGetForUse(userId)) - { - if (usage?.Item == null) - return; - - usage.Item.Spectators.Remove(watcherId); - if (usage.Item.Spectators.Count == 0) - usage.Destroy(); - } - await Clients.User(userId.ToString()).UserEndedWatching(watcherId); } @@ -256,17 +237,6 @@ public override async Task OnConnectedAsync() foreach (var kvp in GetAllStates()) await Clients.Caller.UserBeganPlaying((int)kvp.Key, kvp.Value.State!); - SpectatorUser[]? watchers = null; - - using (var usage = await spectatorLists.TryGetForUse(Context.GetUserId())) - { - if (usage?.Item != null) - watchers = usage.Item.Spectators.Values.ToArray(); - } - - if (watchers != null) - await Clients.Caller.UserStartedWatching(watchers); - await base.OnConnectedAsync(); } diff --git a/osu.Server.Spectator/Hubs/Spectator/SpectatorList.cs b/osu.Server.Spectator/Hubs/Spectator/SpectatorList.cs deleted file mode 100644 index 49540bd0..00000000 --- a/osu.Server.Spectator/Hubs/Spectator/SpectatorList.cs +++ /dev/null @@ -1,13 +0,0 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System.Collections.Generic; -using osu.Game.Online.Spectator; - -namespace osu.Server.Spectator.Hubs.Spectator -{ - public class SpectatorList - { - public Dictionary Spectators { get; } = new Dictionary(); - } -} From 8b1295d98131954cd6a1d4eaf18f6b07610f10a1 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 22 Jan 2025 17:06:51 +0900 Subject: [PATCH 3/6] Update game packages --- .../.idea/projectSettingsUpdater.xml | 1 + SampleMultiplayerClient/SampleMultiplayerClient.csproj | 2 +- SampleSpectatorClient/SampleSpectatorClient.csproj | 2 +- osu.Server.Spectator/osu.Server.Spectator.csproj | 10 +++++----- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.idea/.idea.osu.Server.Spectator/.idea/projectSettingsUpdater.xml b/.idea/.idea.osu.Server.Spectator/.idea/projectSettingsUpdater.xml index 86cc6c63..64af657f 100644 --- a/.idea/.idea.osu.Server.Spectator/.idea/projectSettingsUpdater.xml +++ b/.idea/.idea.osu.Server.Spectator/.idea/projectSettingsUpdater.xml @@ -1,6 +1,7 @@ + \ No newline at end of file diff --git a/SampleMultiplayerClient/SampleMultiplayerClient.csproj b/SampleMultiplayerClient/SampleMultiplayerClient.csproj index 44cad969..3faf8c79 100644 --- a/SampleMultiplayerClient/SampleMultiplayerClient.csproj +++ b/SampleMultiplayerClient/SampleMultiplayerClient.csproj @@ -11,7 +11,7 @@ - + diff --git a/SampleSpectatorClient/SampleSpectatorClient.csproj b/SampleSpectatorClient/SampleSpectatorClient.csproj index 44cad969..3faf8c79 100644 --- a/SampleSpectatorClient/SampleSpectatorClient.csproj +++ b/SampleSpectatorClient/SampleSpectatorClient.csproj @@ -11,7 +11,7 @@ - + diff --git a/osu.Server.Spectator/osu.Server.Spectator.csproj b/osu.Server.Spectator/osu.Server.Spectator.csproj index 0f19aaf4..c7d4fcbf 100644 --- a/osu.Server.Spectator/osu.Server.Spectator.csproj +++ b/osu.Server.Spectator/osu.Server.Spectator.csproj @@ -15,11 +15,11 @@ - - - - - + + + + + From f92bc58137886941f28da76e43e9b3b999c59706 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 22 Jan 2025 17:10:59 +0900 Subject: [PATCH 4/6] Remove left over test considerations --- osu.Server.Spectator.Tests/SpectatorHubTest.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/osu.Server.Spectator.Tests/SpectatorHubTest.cs b/osu.Server.Spectator.Tests/SpectatorHubTest.cs index 626d8d9e..8dc5aca2 100644 --- a/osu.Server.Spectator.Tests/SpectatorHubTest.cs +++ b/osu.Server.Spectator.Tests/SpectatorHubTest.cs @@ -41,7 +41,6 @@ public class SpectatorHubTest public SpectatorHubTest() { var clientStates = new EntityStore(); - var spectatorLists = new EntityStore(); mockDatabase = new Mock(); mockDatabase.Setup(db => db.GetUsernameAsync(streamer_id)).ReturnsAsync(() => streamer_username); @@ -65,7 +64,7 @@ public SpectatorHubTest() var mockScoreProcessedSubscriber = new Mock(); - hub = new SpectatorHub(loggerFactory.Object, clientStates, spectatorLists, databaseFactory.Object, scoreUploader, mockScoreProcessedSubscriber.Object); + hub = new SpectatorHub(loggerFactory.Object, clientStates, databaseFactory.Object, scoreUploader, mockScoreProcessedSubscriber.Object); } [Fact] From 5395794c057c7bf2da536732968ebc1bfaf5ee29 Mon Sep 17 00:00:00 2001 From: Dean Herbert Date: Wed, 22 Jan 2025 17:32:55 +0900 Subject: [PATCH 5/6] Add xmldoc for `SpectatorClientState` --- .../Hubs/Spectator/SpectatorClientState.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/osu.Server.Spectator/Hubs/Spectator/SpectatorClientState.cs b/osu.Server.Spectator/Hubs/Spectator/SpectatorClientState.cs index 54aa56c1..ce6f200a 100644 --- a/osu.Server.Spectator/Hubs/Spectator/SpectatorClientState.cs +++ b/osu.Server.Spectator/Hubs/Spectator/SpectatorClientState.cs @@ -11,10 +11,19 @@ namespace osu.Server.Spectator.Hubs.Spectator [Serializable] public class SpectatorClientState : ClientState { + /// + /// When a user is in gameplay, this is the state as conveyed at the start of the play session. + /// public SpectatorState? State; + /// + /// When a user is in gameplay, this is the imminent score. It will be updated throughout a play session. + /// public Score? Score; + /// + /// The score token as conveyed by the client at the beginning of a play session. + /// public long? ScoreToken; [JsonConstructor] From 19f0e0232807427e70dd5b8a191abb20a1620211 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bart=C5=82omiej=20Dach?= Date: Wed, 22 Jan 2025 11:06:52 +0100 Subject: [PATCH 6/6] Notify watched players when one of their watchers disconnects --- .../Hubs/Spectator/SpectatorClientState.cs | 6 +++++ .../Hubs/Spectator/SpectatorHub.cs | 22 ++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/osu.Server.Spectator/Hubs/Spectator/SpectatorClientState.cs b/osu.Server.Spectator/Hubs/Spectator/SpectatorClientState.cs index ce6f200a..2e6b4ea0 100644 --- a/osu.Server.Spectator/Hubs/Spectator/SpectatorClientState.cs +++ b/osu.Server.Spectator/Hubs/Spectator/SpectatorClientState.cs @@ -2,6 +2,7 @@ // See the LICENCE file in the repository root for full licence text. using System; +using System.Collections.Generic; using Newtonsoft.Json; using osu.Game.Online.Spectator; using osu.Game.Scoring; @@ -26,6 +27,11 @@ public class SpectatorClientState : ClientState /// public long? ScoreToken; + /// + /// The list of IDs of users that this client is currently watching. + /// + public HashSet WatchedUsers = new HashSet(); + [JsonConstructor] public SpectatorClientState(in string connectionId, in int userId) : base(connectionId, userId) diff --git a/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs b/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs index c913e164..892c5e81 100644 --- a/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs +++ b/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs @@ -154,7 +154,12 @@ public async Task EndPlaySession(SpectatorState state) } finally { - usage.Destroy(); + if (usage.Item != null) + { + usage.Item.State = null; + usage.Item.Score = null; + usage.Item.ScoreToken = null; + } } } @@ -202,6 +207,12 @@ public async Task StartWatchingUser(int userId) // user isn't tracked. } + using (var state = await GetOrCreateLocalUserState()) + { + var clientState = state.Item ??= new SpectatorClientState(Context.ConnectionId, Context.GetUserId()); + clientState.WatchedUsers.Add(userId); + } + await Groups.AddToGroupAsync(Context.ConnectionId, GetGroupId(userId)); int watcherId = Context.GetUserId(); @@ -225,6 +236,12 @@ public async Task EndWatchingUser(int userId) { await Groups.RemoveFromGroupAsync(Context.ConnectionId, GetGroupId(userId)); + using (var state = await GetOrCreateLocalUserState()) + { + var clientState = state.Item ??= new SpectatorClientState(Context.ConnectionId, Context.GetUserId()); + clientState.WatchedUsers.Remove(userId); + } + int watcherId = Context.GetUserId(); await Clients.User(userId.ToString()).UserEndedWatching(watcherId); @@ -245,6 +262,9 @@ protected override async Task CleanUpState(SpectatorClientState state) if (state.State != null) await endPlaySession(state.UserId, state.State); + foreach (int watchedUserId in state.WatchedUsers) + await Clients.User(watchedUserId.ToString()).UserEndedWatching(state.UserId); + await base.CleanUpState(state); }