├── .github └── workflows │ ├── pack.yml │ └── test.yml ├── .gitignore ├── .idea └── .idea.osu.Server.Spectator │ └── .idea │ ├── .name │ ├── indexLayout.xml │ ├── projectSettingsUpdater.xml │ └── vcs.xml ├── LICENCE ├── README.md ├── SampleMultiplayerClient ├── MultiplayerClient.cs ├── Program.cs └── SampleMultiplayerClient.csproj ├── SampleSpectatorClient ├── Program.cs ├── SampleSpectatorClient.csproj └── SpectatorClient.cs ├── UseLocalOsu.ps1 ├── UseLocalOsu.sh ├── osu.Server.Spectator.Tests ├── BuildUserCountUpdaterTest.cs ├── ChatFiltersTest.cs ├── ConcurrentConnectionLimiterTests.cs ├── DailyChallengeUpdaterTest.cs ├── EntityStoreTest.cs ├── Extensions │ └── EnumerableExtensionsTest.cs ├── JsonSerializationTests.cs ├── MessagePackSerializationTests.cs ├── MetadataHubTest.cs ├── Multiplayer │ ├── AutomaticForceStartTest.cs │ ├── BeatmapAvailabilityTests.cs │ ├── CountdownTest.cs │ ├── DelegatingMultiplayerClient.cs │ ├── FreestyleTests.cs │ ├── HostManagementTests.cs │ ├── MatchSpectatingTests.cs │ ├── MatchTypeRoomEventHookTests.cs │ ├── MatchTypeTests.cs │ ├── ModValidationTests.cs │ ├── MultiplayerAllPlayersQueueTests.cs │ ├── MultiplayerAllPlayersRoundRobinQueueTests.cs │ ├── MultiplayerFlowTests.cs │ ├── MultiplayerHostOnlyQueueTests.cs │ ├── MultiplayerInviteTest.cs │ ├── MultiplayerMatchStartCountdownTest.cs │ ├── MultiplayerQueueTests.cs │ ├── MultiplayerTest.cs │ ├── RoomInteropTest.cs │ ├── RoomParticipationTests.cs │ ├── RoomSettingsTests.cs │ ├── TeamVersusTests.cs │ ├── TestMultiplayerHub.cs │ └── UserStateManagementTests.cs ├── ScoreUploaderTests.cs ├── SpectatorHubTest.cs ├── StatefulUserHubTest.cs └── osu.Server.Spectator.Tests.csproj ├── osu.Server.Spectator.sln ├── osu.Server.Spectator.sln.DotSettings └── osu.Server.Spectator ├── .dockerignore ├── AppSettings.cs ├── Authentication └── ConfigureJwtBearerOptions.cs ├── ChatFilters.cs ├── CodeAnalysis └── BannedSymbols.txt ├── ConcurrentConnectionLimiter.cs ├── Database ├── DapperExtensions.cs ├── DatabaseAccess.cs ├── DatabaseFactory.cs ├── IDatabaseAccess.cs ├── IDatabaseFactory.cs └── Models │ ├── MatchStartedEventDetail.cs │ ├── SoloScore.cs │ ├── SoloScoreData.cs │ ├── bss_process_queue_item.cs │ ├── chat_filter.cs │ ├── database_beatmap.cs │ ├── database_match_type.cs │ ├── database_queue_mode.cs │ ├── database_room_status.cs │ ├── multiplayer_playlist_item.cs │ ├── multiplayer_realtime_room_event.cs │ ├── multiplayer_room.cs │ ├── multiplayer_scores_high.cs │ ├── osu_build.cs │ ├── phpbb_zebra.cs │ └── room_category.cs ├── Dockerfile ├── Entities ├── ConnectionState.cs ├── EntityStore.cs ├── IEntityStore.cs └── ItemUsage.cs ├── Extensions ├── EntityStoreExtensions.cs ├── EnumerableExtensions.cs ├── HubCallerContextExtensions.cs ├── MultiplayerPlaylistItemExtensions.cs └── ServiceCollectionExtensions.cs ├── GracefulShutdownManager.cs ├── Hubs ├── ClientState.cs ├── ILogTarget.cs ├── IStatefulUserHub.cs ├── LoggingHub.cs ├── Metadata │ ├── BuildUserCountUpdater.cs │ ├── DailyChallengeUpdater.cs │ ├── MetadataBroadcaster.cs │ ├── MetadataClientState.cs │ ├── MetadataHub.cs │ └── MultiplayerRoomStats.cs ├── Multiplayer │ ├── HeadToHead.cs │ ├── IMultiplayerHubContext.cs │ ├── MatchTypeImplementation.cs │ ├── MultiplayerClientState.cs │ ├── MultiplayerEventLogger.cs │ ├── MultiplayerHub.cs │ ├── MultiplayerHubContext.cs │ ├── MultiplayerQueue.cs │ ├── ServerMultiplayerRoom.cs │ └── TeamVersus.cs ├── ScoreUploader.cs ├── Spectator │ ├── IScoreProcessedSubscriber.cs │ ├── ScoreProcessedSubscriber.cs │ ├── SpectatorClientState.cs │ └── SpectatorHub.cs └── StatefulUserHub.cs ├── LegacyHelper.cs ├── LoggingHubFilter.cs ├── Program.cs ├── Properties ├── AssemblyInfo.cs └── launchSettings.json ├── S3.cs ├── ServerShuttingDownException.cs ├── Services ├── ISharedInterop.cs └── SharedInterop.cs ├── Startup.cs ├── StartupDevelopment.cs ├── Storage ├── FileScoreStorage.cs ├── IScoreStorage.cs └── S3ScoreStorage.cs ├── appsettings.Development.json ├── appsettings.json ├── oauth-public.key └── osu.Server.Spectator.csproj /.github/workflows/pack.yml: -------------------------------------------------------------------------------- 1 | name: Build and publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | tags: 8 | - '*' 9 | 10 | env: 11 | PRODUCTION_TRACK: ${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-') && 'production') || (github.ref_type == 'branch' && github.ref_name == 'master' && 'staging') || '' }} 12 | 13 | jobs: 14 | push_to_registry: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - 18 | name: Checkout 19 | uses: actions/checkout@v4 20 | - 21 | name: Docker meta 22 | id: meta 23 | uses: docker/metadata-action@v5 24 | with: 25 | # list of Docker images to use as base name for tags 26 | images: | 27 | pppy/osu-server-spectator 28 | # generate Docker tags based on the following events/attributes 29 | # on tag event: tag using git tag, and as latest if the tag doesn't contain hyphens (pre-releases) 30 | # on push event: tag using git sha, branch name and as latest-dev 31 | tags: | 32 | type=raw,value=latest,enable=${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }} 33 | type=raw,value=latest-dev,enable=${{ github.ref_type == 'branch' && github.ref_name == 'master' }} 34 | type=raw,value=${{ github.ref_name }} 35 | type=raw,value=${{ github.sha }},enable=${{ github.ref_type == 'branch' }} 36 | flavor: | 37 | latest=false 38 | - 39 | name: Set up Docker Buildx 40 | uses: docker/setup-buildx-action@v3 41 | - 42 | name: Login to DockerHub 43 | uses: docker/login-action@v3 44 | with: 45 | username: ${{ secrets.DOCKER_USERNAME }} 46 | password: ${{ secrets.DOCKER_PASSWORD }} 47 | - 48 | name: Build and push 49 | uses: docker/build-push-action@v5 50 | with: 51 | context: ./osu.Server.Spectator 52 | file: ./osu.Server.Spectator/Dockerfile 53 | platforms: linux/amd64 54 | push: true 55 | tags: ${{ steps.meta.outputs.tags }} 56 | labels: ${{ steps.meta.outputs.labels }} 57 | 58 | notify_pending_production_deploy: 59 | if: ${{ github.ref_type == 'tag' && !contains(github.ref_name, '-') }} 60 | runs-on: ubuntu-latest 61 | needs: 62 | - push_to_registry 63 | steps: 64 | - 65 | name: Submit pending deployment notification 66 | run: | 67 | export TITLE="Pending osu-server-spectator Production Deployment: $GITHUB_REF_NAME" 68 | export URL="https://github.com/ppy/osu-server-spectator/actions/runs/$GITHUB_RUN_ID" 69 | export DESCRIPTION="Docker image was built for tag $GITHUB_REF_NAME and awaiting approval for production deployment: 70 | [View Workflow Run]($URL)" 71 | export ACTOR_ICON="https://avatars.githubusercontent.com/u/$GITHUB_ACTOR_ID" 72 | 73 | BODY="$(jq --null-input '{ 74 | "embeds": [ 75 | { 76 | "title": env.TITLE, 77 | "color": 15098112, 78 | "description": env.DESCRIPTION, 79 | "url": env.URL, 80 | "author": { 81 | "name": env.GITHUB_ACTOR, 82 | "icon_url": env.ACTOR_ICON 83 | } 84 | } 85 | ] 86 | }')" 87 | 88 | curl \ 89 | -H "Content-Type: application/json" \ 90 | -d "$BODY" \ 91 | "${{ secrets.DISCORD_INFRA_WEBHOOK_URL }}" 92 | 93 | trigger_deploy: 94 | if: ${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-')) || (github.ref_type == 'branch' && github.ref_name == 'master') }} 95 | runs-on: ubuntu-latest 96 | needs: 97 | - push_to_registry 98 | environment: ${{ (github.ref_type == 'tag' && !contains(github.ref_name, '-') && 'production') || 'staging' }} 99 | steps: 100 | - 101 | name: Checkout 102 | uses: actions/checkout@v4 103 | with: 104 | # the "Create Sentry release" step relies on accessing git history 105 | # to find the SHA of the previous release and set the range of new commits in the release being deployed. 106 | # do a full clone rather than a shallow one to allow it to do that. 107 | fetch-depth: 0 108 | - 109 | name: Repository Dispatch 110 | uses: peter-evans/repository-dispatch@v3 111 | with: 112 | token: ${{ secrets.KUBERNETES_CONFIG_REPO_ACCESS_TOKEN }} 113 | repository: ppy/osu-kubernetes-config 114 | event-type: ${{ env.PRODUCTION_TRACK == 'staging' && 'dev-ppy-sh-deploy' || 'osu-server-spectator-deploy' }} 115 | client-payload: |- 116 | ${{ env.PRODUCTION_TRACK == 'staging' && format('{{ "values": {{ "osu-server-spectator": {{ "image": {{ "tag": "{0}" }} }} }} }}', github.sha) || format('{{ "dockerTag": "{0}" }}', github.ref_name) }} 117 | - 118 | name: Create Sentry release 119 | uses: getsentry/action-release@v1 120 | env: 121 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} 122 | SENTRY_ORG: ppy 123 | SENTRY_PROJECT: osu-server-spectator 124 | SENTRY_URL: https://sentry.ppy.sh/ 125 | with: 126 | environment: ${{ env.PRODUCTION_TRACK }} 127 | version: osu-server-spectator@${{ github.ref_type == 'branch' && github.sha || github.ref_type == 'tag' && github.ref_name }} 128 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: windows-latest 14 | name: Unit testing 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Install .NET 8.0.x 20 | uses: actions/setup-dotnet@v4 21 | with: 22 | dotnet-version: "8.0.x" 23 | 24 | - name: Test 25 | run: dotnet test 26 | -------------------------------------------------------------------------------- /.idea/.idea.osu.Server.Spectator/.idea/.name: -------------------------------------------------------------------------------- 1 | osu.Server.Spectator -------------------------------------------------------------------------------- /.idea/.idea.osu.Server.Spectator/.idea/indexLayout.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/.idea.osu.Server.Spectator/.idea/projectSettingsUpdater.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 8 | -------------------------------------------------------------------------------- /.idea/.idea.osu.Server.Spectator/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 ppy Pty Ltd . 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # osu-server-spectator [![dev chat](https://discordapp.com/api/guilds/188630481301012481/widget.png?style=shield)](https://discord.gg/ppy) 2 | 3 | A server that handles incoming and outgoing spectator data, for active players looking to watch or broadcast to others. 4 | 5 | # Testing 6 | 7 | To deploy this as part of a full osu! server stack deployment, [this wiki page](https://github.com/ppy/osu/wiki/Testing-web-server-full-stack-with-osu!) will serve as a good reference. 8 | 9 | ## Environment variables 10 | 11 | For advanced testing purposes. 12 | 13 | | Envvar name | Description | Default value | 14 | | :- | :- |:------------------| 15 | | `SAVE_REPLAYS` | Whether to save received replay frames from clients to replay files. `1` to enable, any other value to disable. | `""` (disabled) | 16 | | `REPLAY_UPLOAD_THREADS` | Number of threads to use when uploading complete replays. Must be positive number. | `1` | 17 | | `REPLAYS_PATH` | Local path to store complete replay files (`.osr`) to. Only used if [`FileScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/FileScoreStorage.cs) is active. | `./replays/` | 18 | | `S3_KEY` | An access key ID to use for uploading replays to [AWS S3](https://aws.amazon.com/s3/). Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` | 19 | | `S3_SECRET` | The secret access key to use for uploading replays to [AWS S3](https://aws.amazon.com/s3/). Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` | 20 | | `REPLAYS_BUCKET` | The name of the [AWS S3](https://aws.amazon.com/s3/) bucket to upload replays to. Only used if [`S3ScoreStorage`](https://github.com/ppy/osu-server-spectator/blob/master/osu.Server.Spectator/Storage/S3ScoreStorage.cs) is active. | `""` | 21 | | `TRACK_BUILD_USER_COUNTS` | Whether to track how many users are on a particular build of the game and report that information to the database at `DB_{HOST,PORT}`. `1` to enable, any other value to disable. | `""` (disabled) | 22 | | `SERVER_PORT` | Which port the server should listen on for incoming connections. | `80` | 23 | | `REDIS_HOST` | Connection string to `osu-web` Redis instance. | `localhost` | 24 | | `DD_AGENT_HOST` | Hostname under which the [Datadog](https://www.datadoghq.com/) agent host can be found. | `localhost` | 25 | | `DB_HOST` | Hostname under which the `osu-web` MySQL instance can be found. | `localhost` | 26 | | `DB_PORT` | Port under which the `osu-web` MySQL instance can be found. | `3306` | 27 | | `DB_USER` | Username to use when logging into the `osu-web` MySQL instance. | `osuweb` | 28 | | `SENTRY_DSN` | A valid Sentry DSN to use for logging application events. | `null` (required in production) | 29 | | `SHARED_INTEROP_DOMAIN` | The root URL of the osu-web instance to which shared interop calls should be submitted | `http://localhost:80` | 30 | | `SHARED_INTEROP_SECRET` | The value of the same environment variable that the target osu-web instance specifies in `.env`. | `null` (required) | 31 | -------------------------------------------------------------------------------- /SampleMultiplayerClient/SampleMultiplayerClient.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /SampleSpectatorClient/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Microsoft.AspNetCore.SignalR.Client; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using Microsoft.Extensions.Logging; 11 | using osu.Framework.Utils; 12 | using osu.Game.Online; 13 | using osu.Game.Online.Spectator; 14 | using osu.Game.Replays.Legacy; 15 | using osu.Game.Rulesets.Scoring; 16 | using osu.Game.Scoring; 17 | 18 | namespace SampleSpectatorClient 19 | { 20 | internal static class Program 21 | { 22 | public static async Task Main() 23 | { 24 | // ReSharper disable once CollectionNeverQueried.Local 25 | var clients = new List(); 26 | 27 | for (int i = 0; i < 5; i++) 28 | clients.Add(getConnectedClient()); 29 | 30 | var sendingClient = getConnectedClient(); 31 | 32 | while (true) 33 | { 34 | await sendingClient.BeginPlaying(0, new SpectatorState { BeatmapID = 88 }); 35 | 36 | Thread.Sleep(1000); 37 | 38 | Console.WriteLine("Writer starting playing.."); 39 | 40 | for (int i = 0; i < 50; i++) 41 | { 42 | await sendingClient.SendFrames(new FrameDataBundle( 43 | new FrameHeader(new ScoreInfo(), new ScoreProcessorStatistics()), 44 | new[] 45 | { 46 | new LegacyReplayFrame(i, RNG.Next(0, 512), RNG.Next(0, 512), ReplayButtonState.None) 47 | })); 48 | Thread.Sleep(50); 49 | } 50 | 51 | Console.WriteLine("Writer ending playing.."); 52 | 53 | await sendingClient.EndPlaying(new SpectatorState { BeatmapID = 88 }); 54 | 55 | Thread.Sleep(1000); 56 | } 57 | 58 | // ReSharper disable once FunctionNeverReturns 59 | } 60 | 61 | private static SpectatorClient getConnectedClient() 62 | { 63 | var connection = new HubConnectionBuilder() 64 | .AddMessagePackProtocol(options => { options.SerializerOptions = SignalRUnionWorkaroundResolver.OPTIONS; }) 65 | .WithUrl("http://localhost:80/spectator") 66 | .ConfigureLogging(logging => 67 | { 68 | logging.AddFilter("Microsoft.AspNetCore.SignalR", LogLevel.Debug); 69 | logging.AddConsole(); 70 | }) 71 | .Build(); 72 | 73 | var client = new SpectatorClient(connection); 74 | 75 | connection.Closed += async error => 76 | { 77 | Console.WriteLine($"Connection closed with error:{error}"); 78 | 79 | await connection.StartAsync(); 80 | }; 81 | 82 | connection.Reconnected += id => 83 | { 84 | Console.WriteLine($"Connected with id:{id}"); 85 | return Task.CompletedTask; 86 | }; 87 | 88 | while (true) 89 | { 90 | try 91 | { 92 | connection.StartAsync().Wait(); 93 | break; 94 | } 95 | catch 96 | { 97 | // try until connected 98 | } 99 | } 100 | 101 | Console.WriteLine($"client {connection.ConnectionId} connected!"); 102 | 103 | return client; 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /SampleSpectatorClient/SampleSpectatorClient.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net8.0 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /SampleSpectatorClient/SpectatorClient.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Threading.Tasks; 8 | using Microsoft.AspNetCore.SignalR.Client; 9 | using osu.Game.Online; 10 | using osu.Game.Online.Spectator; 11 | 12 | namespace SampleSpectatorClient 13 | { 14 | public class SpectatorClient : ISpectatorClient 15 | { 16 | private readonly HubConnection connection; 17 | 18 | private readonly List watchingUsers = new List(); 19 | 20 | public SpectatorClient(HubConnection connection) 21 | { 22 | this.connection = connection; 23 | 24 | // this is kind of SILLY 25 | // https://github.com/dotnet/aspnetcore/issues/15198 26 | connection.On(nameof(ISpectatorClient.UserBeganPlaying), ((ISpectatorClient)this).UserBeganPlaying); 27 | connection.On(nameof(ISpectatorClient.UserSentFrames), ((ISpectatorClient)this).UserSentFrames); 28 | connection.On(nameof(ISpectatorClient.UserFinishedPlaying), ((ISpectatorClient)this).UserFinishedPlaying); 29 | connection.On(nameof(ISpectatorClient.UserScoreProcessed), ((ISpectatorClient)this).UserScoreProcessed); 30 | connection.On(nameof(IStatefulUserHubClient.DisconnectRequested), ((IStatefulUserHubClient)this).DisconnectRequested); 31 | } 32 | 33 | Task ISpectatorClient.UserBeganPlaying(int userId, SpectatorState state) 34 | { 35 | if (watchingUsers.Contains(userId)) 36 | { 37 | Console.WriteLine($"{connection.ConnectionId} received began playing for already watched user {userId}"); 38 | } 39 | else 40 | { 41 | Console.WriteLine($"{connection.ConnectionId} requesting watch other user {userId}"); 42 | WatchUser(userId); 43 | watchingUsers.Add(userId); 44 | } 45 | 46 | return Task.CompletedTask; 47 | } 48 | 49 | Task ISpectatorClient.UserFinishedPlaying(int userId, SpectatorState state) 50 | { 51 | Console.WriteLine($"{connection.ConnectionId} Received user finished event {state}"); 52 | return Task.CompletedTask; 53 | } 54 | 55 | Task ISpectatorClient.UserSentFrames(int userId, FrameDataBundle data) 56 | { 57 | Console.WriteLine($"{connection.ConnectionId} Received frames from {userId}: {data.Frames.First()}"); 58 | return Task.CompletedTask; 59 | } 60 | 61 | Task ISpectatorClient.UserScoreProcessed(int userId, long scoreId) 62 | { 63 | Console.WriteLine($"{connection.ConnectionId} Processing score with ID {scoreId} for player {userId} completed"); 64 | return Task.CompletedTask; 65 | } 66 | 67 | Task ISpectatorClient.UserStartedWatching(SpectatorUser[] users) 68 | { 69 | foreach (var user in users) 70 | Console.WriteLine($"{connection.ConnectionId} User {user.OnlineID} started watching you"); 71 | return Task.CompletedTask; 72 | } 73 | 74 | Task ISpectatorClient.UserEndedWatching(int userId) 75 | { 76 | Console.WriteLine($"{connection.ConnectionId} User {userId} ended watching you"); 77 | return Task.CompletedTask; 78 | } 79 | 80 | public Task BeginPlaying(long? scoreToken, SpectatorState state) => connection.SendAsync(nameof(ISpectatorServer.BeginPlaySession), scoreToken, state); 81 | 82 | public Task SendFrames(FrameDataBundle data) => connection.SendAsync(nameof(ISpectatorServer.SendFrameData), data); 83 | 84 | public Task EndPlaying(SpectatorState state) => connection.SendAsync(nameof(ISpectatorServer.EndPlaySession), state); 85 | 86 | public Task WatchUser(int userId) => connection.SendAsync(nameof(ISpectatorServer.StartWatchingUser), userId); 87 | 88 | public Task DisconnectRequested() 89 | { 90 | Console.WriteLine($"{connection.ConnectionId} Disconnect requested"); 91 | return Task.CompletedTask; 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /UseLocalOsu.ps1: -------------------------------------------------------------------------------- 1 | # Run this script to use a local copy of osu rather than fetching it from nuget. 2 | # It expects the osu directory to be at the same level as the osu-tools directory 3 | 4 | 5 | $CSPROJ="osu.Server.Spectator/osu.Server.Spectator.csproj" 6 | $SLN="osu.Server.Spectator.sln" 7 | 8 | $DEPENDENCIES=@( 9 | "..\osu\osu.Game.Rulesets.Catch\osu.Game.Rulesets.Catch.csproj" 10 | "..\osu\osu.Game.Rulesets.Mania\osu.Game.Rulesets.Mania.csproj" 11 | "..\osu\osu.Game.Rulesets.Osu\osu.Game.Rulesets.Osu.csproj" 12 | "..\osu\osu.Game.Rulesets.Taiko\osu.Game.Rulesets.Taiko.csproj" 13 | "..\osu\osu.Game\osu.Game.csproj" 14 | ) 15 | 16 | 17 | dotnet remove $CSPROJ package ppy.osu.Game 18 | dotnet remove $CSPROJ package ppy.osu.Game.Rulesets.Osu 19 | dotnet remove $CSPROJ package ppy.osu.Game.Rulesets.Taiko 20 | dotnet remove $CSPROJ package ppy.osu.Game.Rulesets.Catch 21 | dotnet remove $CSPROJ package ppy.osu.Game.Rulesets.Mania 22 | 23 | dotnet sln $SLN add $DEPENDENCIES 24 | dotnet add $CSPROJ reference $DEPENDENCIES 25 | 26 | dotnet remove "SampleMultiplayerClient\SampleMultiplayerClient.csproj" package ppy.osu.Game 27 | dotnet add "SampleMultiplayerClient\SampleMultiplayerClient.csproj" reference "..\osu\osu.Game\osu.Game.csproj" 28 | 29 | dotnet remove "SampleSpectatorClient\SampleSpectatorClient.csproj" package ppy.osu.Game 30 | dotnet add "SampleSpectatorClient\SampleSpectatorClient.csproj" reference "..\osu\osu.Game\osu.Game.csproj" -------------------------------------------------------------------------------- /UseLocalOsu.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Run this script to use a local copy of osu rather than fetching it from nuget. 4 | # It expects the osu directory to be at the same level as the osu-tools directory 5 | 6 | 7 | CSPROJ="osu.Server.Spectator/osu.Server.Spectator.csproj" 8 | SLN="osu.Server.Spectator.sln" 9 | 10 | DEPENDENCIES="../osu/osu.Game.Rulesets.Catch/osu.Game.Rulesets.Catch.csproj 11 | ../osu/osu.Game.Rulesets.Mania/osu.Game.Rulesets.Mania.csproj 12 | ../osu/osu.Game.Rulesets.Osu/osu.Game.Rulesets.Osu.csproj 13 | ../osu/osu.Game.Rulesets.Taiko/osu.Game.Rulesets.Taiko.csproj 14 | ../osu/osu.Game/osu.Game.csproj" 15 | 16 | 17 | dotnet remove $CSPROJ package ppy.osu.Game 18 | dotnet remove $CSPROJ package ppy.osu.Game.Rulesets.Osu 19 | dotnet remove $CSPROJ package ppy.osu.Game.Rulesets.Taiko 20 | dotnet remove $CSPROJ package ppy.osu.Game.Rulesets.Catch 21 | dotnet remove $CSPROJ package ppy.osu.Game.Rulesets.Mania 22 | 23 | dotnet sln $SLN add $DEPENDENCIES 24 | dotnet add $CSPROJ reference $DEPENDENCIES 25 | 26 | dotnet remove "SampleMultiplayerClient/SampleMultiplayerClient.csproj" package ppy.osu.Game 27 | dotnet add "SampleMultiplayerClient/SampleMultiplayerClient.csproj" reference "../osu/osu.Game/osu.Game.csproj" 28 | 29 | dotnet remove "SampleSpectatorClient/SampleSpectatorClient.csproj" package ppy.osu.Game 30 | dotnet add "SampleSpectatorClient/SampleSpectatorClient.csproj" reference "../osu/osu.Game/osu.Game.csproj" -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/BuildUserCountUpdaterTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Threading.Tasks; 6 | using Microsoft.Extensions.Logging; 7 | using Moq; 8 | using osu.Server.Spectator.Database; 9 | using osu.Server.Spectator.Database.Models; 10 | using osu.Server.Spectator.Entities; 11 | using osu.Server.Spectator.Hubs.Metadata; 12 | using Xunit; 13 | 14 | namespace osu.Server.Spectator.Tests 15 | { 16 | public class BuildUserCountUpdaterTest 17 | { 18 | private readonly EntityStore clientStates; 19 | private readonly Mock databaseFactoryMock; 20 | private readonly Mock databaseAccessMock; 21 | private readonly Mock loggerFactoryMock; 22 | 23 | public BuildUserCountUpdaterTest() 24 | { 25 | clientStates = new EntityStore(); 26 | 27 | databaseFactoryMock = new Mock(); 28 | databaseAccessMock = new Mock(); 29 | loggerFactoryMock = new Mock(); 30 | loggerFactoryMock.Setup(factory => factory.CreateLogger(It.IsAny())) 31 | .Returns(new Mock().Object); 32 | databaseFactoryMock.Setup(df => df.GetInstance()).Returns(databaseAccessMock.Object); 33 | } 34 | 35 | [Fact] 36 | public async Task TestPeriodicUpdates() 37 | { 38 | databaseAccessMock.Setup(db => db.GetAllMainLazerBuildsAsync()) 39 | .ReturnsAsync(new[] 40 | { 41 | new osu_build { build_id = 1, hash = null, version = "2023.1208.0" }, 42 | new osu_build { build_id = 2, hash = null, version = "2023.1209.0" } 43 | }); 44 | databaseAccessMock.Setup(db => db.GetAllPlatformSpecificLazerBuildsAsync()) 45 | .ReturnsAsync(new[] 46 | { 47 | new osu_build { build_id = 101, hash = new byte[] { 0xCA, 0xFE, 0xBA, 0xBE }, version = "2023.1208.0-lazer-windows" }, 48 | new osu_build { build_id = 102, hash = new byte[] { 0xDE, 0xAD, 0xBE, 0xEF }, version = "2023.1208.0-lazer-ios" }, 49 | new osu_build { build_id = 103, hash = new byte[] { 0xC0, 0xC0, 0xC0, 0xC0 }, version = "2023.1209.0-lazer-windows" }, 50 | new osu_build { build_id = 104, hash = new byte[] { 0xFE, 0xDC, 0xBA, 0x98 }, version = "2023.1209.0-lazer-ios" } 51 | }); 52 | 53 | await trackUser(1, "cafebabe"); // 2023.1208.0-lazer-windows 54 | await trackUser(2, "cafebabe"); // 2023.1208.0-lazer-windows 55 | await trackUser(3, "c0c0c0c0"); // 2023.1209.0-lazer-windows 56 | await trackUser(4, "fedcba98"); // 2023.1209.0-lazer-ios 57 | await trackUser(5, "deadbeef"); // 2023.1208.0-lazer-ios 58 | await trackUser(6, "unknown"); 59 | 60 | AppSettings.TrackBuildUserCounts = true; 61 | var updater = new BuildUserCountUpdater(clientStates, databaseFactoryMock.Object, loggerFactoryMock.Object) 62 | { 63 | UpdateInterval = 50 64 | }; 65 | await Task.Delay(100); 66 | 67 | databaseAccessMock.Verify(db => db.UpdateBuildUserCountAsync(It.Is(build => build.version == "2023.1208.0" && build.users == 3)), Times.AtLeastOnce); 68 | databaseAccessMock.Verify(db => db.UpdateBuildUserCountAsync(It.Is(build => build.version == "2023.1209.0" && build.users == 2)), Times.AtLeastOnce); 69 | 70 | await disconnectUser(3); 71 | await disconnectUser(4); 72 | await Task.Delay(100); 73 | 74 | databaseAccessMock.Verify(db => db.UpdateBuildUserCountAsync(It.Is(build => build.version == "2023.1209.0" && build.users == 0)), Times.AtLeastOnce); 75 | 76 | updater.Dispose(); 77 | } 78 | 79 | private async Task trackUser(int userId, string versionHash) 80 | { 81 | using (var usage = await clientStates.GetForUse(userId, true)) 82 | usage.Item = new MetadataClientState(Guid.NewGuid().ToString(), userId, versionHash); 83 | } 84 | 85 | private async Task disconnectUser(int userId) 86 | { 87 | using (var usage = await clientStates.GetForUse(userId)) 88 | usage.Destroy(); 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/ChatFiltersTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Threading.Tasks; 5 | using Moq; 6 | using osu.Game.Online.Multiplayer; 7 | using osu.Server.Spectator.Database; 8 | using osu.Server.Spectator.Database.Models; 9 | using Xunit; 10 | 11 | namespace osu.Server.Spectator.Tests 12 | { 13 | public class ChatFiltersTest 14 | { 15 | private readonly Mock factoryMock; 16 | private readonly Mock databaseMock; 17 | 18 | public ChatFiltersTest() 19 | { 20 | factoryMock = new Mock(); 21 | databaseMock = new Mock(); 22 | 23 | factoryMock.Setup(factory => factory.GetInstance()).Returns(databaseMock.Object); 24 | } 25 | 26 | [Theory] 27 | [InlineData("bad phrase", "good phrase")] 28 | [InlineData("WHAT HAPPENS IF I SAY BAD THING IN CAPS", "WHAT HAPPENS IF I SAY good THING IN CAPS")] 29 | [InlineData("thing is bad", "thing is good")] 30 | [InlineData("look at this badness", "look at this goodness")] 31 | public async Task TestPlainFilterReplacement(string input, string expectedOutput) 32 | { 33 | databaseMock.Setup(db => db.GetAllChatFiltersAsync()).ReturnsAsync([ 34 | new chat_filter { match = "bad", replacement = "good" }, 35 | new chat_filter { match = "fullword", replacement = "okay", whitespace_delimited = true }, 36 | new chat_filter { match = "absolutely forbidden", replacement = "", block = true } 37 | ]); 38 | 39 | var filters = new ChatFilters(factoryMock.Object); 40 | 41 | Assert.Equal(expectedOutput, await filters.FilterAsync(input)); 42 | } 43 | 44 | [Theory] 45 | [InlineData("fullword at the start", "okay at the start")] 46 | [InlineData("FULLWORD IN CAPS!!", "okay IN CAPS!!")] 47 | [InlineData("at the end is fullword", "at the end is okay")] 48 | [InlineData("middle is where the fullword is", "middle is where the okay is")] 49 | [InlineData("anotherfullword is not replaced", "anotherfullword is not replaced")] 50 | [InlineData("fullword fullword2", "okay great")] 51 | [InlineData("fullwordfullword2", "fullwordfullword2")] 52 | [InlineData("i do a delimiter/inside", "i do a nice try")] 53 | public async Task TestWhitespaceDelimitedFilterReplacement(string input, string expectedOutput) 54 | { 55 | databaseMock.Setup(db => db.GetAllChatFiltersAsync()).ReturnsAsync([ 56 | new chat_filter { match = "bad", replacement = "good" }, 57 | new chat_filter { match = "fullword", replacement = "okay", whitespace_delimited = true }, 58 | new chat_filter { match = "fullword2", replacement = "great", whitespace_delimited = true }, 59 | new chat_filter { match = "delimiter/inside", replacement = "nice try", whitespace_delimited = true }, 60 | new chat_filter { match = "absolutely forbidden", replacement = "", block = true } 61 | ]); 62 | 63 | var filters = new ChatFilters(factoryMock.Object); 64 | 65 | Assert.Equal(expectedOutput, await filters.FilterAsync(input)); 66 | } 67 | 68 | [Theory] 69 | [InlineData("absolutely forbidden")] 70 | [InlineData("sPoNGeBoB SaYS aBSolUtElY FoRbIdDeN")] 71 | [InlineData("this is absolutely forbidden full stop!!!")] 72 | public async Task TestBlockingFilter(string input) 73 | { 74 | databaseMock.Setup(db => db.GetAllChatFiltersAsync()).ReturnsAsync([ 75 | new chat_filter { match = "bad", replacement = "good" }, 76 | new chat_filter { match = "fullword", replacement = "okay", whitespace_delimited = true }, 77 | new chat_filter { match = "absolutely forbidden", replacement = "", block = true } 78 | ]); 79 | 80 | var filters = new ChatFilters(factoryMock.Object); 81 | 82 | await Assert.ThrowsAsync(() => filters.FilterAsync(input)); 83 | } 84 | 85 | [Fact] 86 | public async Task TestLackOfBlockingFilters() 87 | { 88 | databaseMock.Setup(db => db.GetAllChatFiltersAsync()).ReturnsAsync([ 89 | new chat_filter { match = "bad", replacement = "good" }, 90 | new chat_filter { match = "fullword", replacement = "okay", whitespace_delimited = true }, 91 | ]); 92 | 93 | var filters = new ChatFilters(factoryMock.Object); 94 | 95 | await filters.FilterAsync("this should be completely fine"); // should not throw 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/DailyChallengeUpdaterTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Microsoft.AspNetCore.SignalR; 7 | using Microsoft.Extensions.Logging; 8 | using Moq; 9 | using osu.Game.Online.Metadata; 10 | using osu.Server.Spectator.Database; 11 | using osu.Server.Spectator.Database.Models; 12 | using osu.Server.Spectator.Hubs.Metadata; 13 | using Xunit; 14 | 15 | namespace osu.Server.Spectator.Tests 16 | { 17 | public class DailyChallengeUpdaterTest 18 | { 19 | private readonly Mock loggerFactoryMock; 20 | private readonly Mock databaseFactoryMock; 21 | private readonly Mock databaseAccessMock; 22 | private readonly Mock> metadataHubContextMock; 23 | private readonly Mock allClientsProxy; 24 | 25 | public DailyChallengeUpdaterTest() 26 | { 27 | loggerFactoryMock = new Mock(); 28 | loggerFactoryMock.Setup(factory => factory.CreateLogger(It.IsAny())) 29 | .Returns(new Mock().Object); 30 | 31 | databaseFactoryMock = new Mock(); 32 | databaseAccessMock = new Mock(); 33 | databaseFactoryMock.Setup(factory => factory.GetInstance()).Returns(databaseAccessMock.Object); 34 | 35 | metadataHubContextMock = new Mock>(); 36 | allClientsProxy = new Mock(); 37 | metadataHubContextMock.Setup(ctx => ctx.Clients.All).Returns(allClientsProxy.Object); 38 | } 39 | 40 | [Fact] 41 | public async Task TestChangeTracking() 42 | { 43 | databaseAccessMock.Setup(db => db.GetActiveDailyChallengeRoomsAsync()) 44 | .ReturnsAsync([new multiplayer_room { id = 4, category = room_category.daily_challenge }]); 45 | 46 | var updater = new DailyChallengeUpdater( 47 | loggerFactoryMock.Object, 48 | databaseFactoryMock.Object, 49 | metadataHubContextMock.Object) 50 | { 51 | UpdateInterval = 50 52 | }; 53 | 54 | var task = updater.StartAsync(default); 55 | await Task.Delay(100); 56 | 57 | allClientsProxy.Verify(proxy => proxy.SendCoreAsync( 58 | nameof(IMetadataClient.DailyChallengeUpdated), 59 | It.Is(args => ((DailyChallengeInfo?)args![0]).Value.RoomID == 4), 60 | It.IsAny()), 61 | Times.Once); 62 | 63 | databaseAccessMock.Setup(db => db.GetActiveDailyChallengeRoomsAsync()) 64 | .ReturnsAsync([]); 65 | await Task.Delay(100); 66 | 67 | allClientsProxy.Verify(proxy => proxy.SendCoreAsync( 68 | nameof(IMetadataClient.DailyChallengeUpdated), 69 | It.Is(args => args[0] == null), 70 | It.IsAny()), 71 | Times.Once); 72 | 73 | databaseAccessMock.Setup(db => db.GetActiveDailyChallengeRoomsAsync()) 74 | .ReturnsAsync([new multiplayer_room { id = 5, category = room_category.daily_challenge }]); 75 | await Task.Delay(100); 76 | 77 | allClientsProxy.Verify(proxy => proxy.SendCoreAsync( 78 | nameof(IMetadataClient.DailyChallengeUpdated), 79 | It.Is(args => ((DailyChallengeInfo?)args![0]).HasValue && ((DailyChallengeInfo?)args[0]).Value.RoomID == 5), 80 | It.IsAny()), 81 | Times.Once); 82 | 83 | await updater.StopAsync(default); 84 | await task; 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/Extensions/EnumerableExtensionsTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using osu.Server.Spectator.Extensions; 8 | using Xunit; 9 | 10 | namespace osu.Server.Spectator.Tests.Extensions 11 | { 12 | public class EnumerableExtensionsTest 13 | { 14 | [Fact] 15 | public void InterleaveNoCollectionsDoesNotReturnAny() 16 | { 17 | IEnumerable> collections = Array.Empty>(); 18 | Assert.Empty(collections.Interleave()); 19 | } 20 | 21 | [Fact] 22 | public void InterleaveEmptyCollectionsDoesNotReturnAny() 23 | { 24 | IEnumerable> collections = new[] { Array.Empty(), Array.Empty() }; 25 | Assert.Empty(collections.Interleave()); 26 | } 27 | 28 | [Fact] 29 | public void InterleaveSingleCollectionReturnsAllElementsFromCollection() 30 | { 31 | const int count = 10; 32 | 33 | IEnumerable> collections = new[] { Enumerable.Range(0, count) }; 34 | 35 | IEnumerable[] result = collections.Interleave().ToArray(); 36 | Assert.Equal(count, result.Length); 37 | 38 | for (int i = 0; i < count; i++) 39 | Assert.Equal(new[] { i }, result[i]); 40 | } 41 | 42 | [Fact] 43 | public void InterleaveCollectionsOfSameCountReturnsInterleavedElements() 44 | { 45 | const int count = 10; 46 | 47 | IEnumerable> collections = new[] { Enumerable.Range(0, count), Enumerable.Range(0, count).Reverse() }; 48 | 49 | IEnumerable[] result = collections.Interleave().ToArray(); 50 | Assert.Equal(count, result.Length); 51 | 52 | for (int i = 0; i < count; i++) 53 | Assert.Equal(new[] { i, count - i - 1 }, result[i]); 54 | } 55 | 56 | [Fact] 57 | public void InterleaveCollectionsOfDifferentLengthContinuesToCompletion() 58 | { 59 | const int max_count = 10; 60 | const int second_count = 5; 61 | const int third_count = 2; 62 | 63 | IEnumerable> collections = new[] { Enumerable.Range(0, max_count), Enumerable.Range(0, second_count), Enumerable.Range(0, third_count) }; 64 | 65 | IEnumerable[] result = collections.Interleave().ToArray(); 66 | Assert.Equal(max_count, result.Length); 67 | 68 | for (int i = 0; i < max_count; i++) 69 | { 70 | List expected = new List { i }; 71 | 72 | if (i < second_count) 73 | expected.Add(i); 74 | 75 | if (i < third_count) 76 | expected.Add(i); 77 | 78 | Assert.Equal(expected, result[i]); 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/JsonSerializationTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using Newtonsoft.Json; 7 | using osu.Framework.Extensions.ObjectExtensions; 8 | using osu.Game.Online; 9 | using osu.Game.Online.Multiplayer; 10 | using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; 11 | using Xunit; 12 | 13 | namespace osu.Server.Spectator.Tests 14 | { 15 | public class JsonSerializationTests 16 | { 17 | private readonly JsonSerializerSettings settings = new JsonSerializerSettings 18 | { 19 | Converters = new List 20 | { 21 | new SignalRDerivedTypeWorkaroundJsonConverter(), 22 | }, 23 | }; 24 | 25 | [Fact] 26 | public void TestMatchUserStateSerialization() 27 | { 28 | var state = new TeamVersusUserState 29 | { 30 | TeamID = 5, 31 | }; 32 | 33 | var serialized = JsonConvert.SerializeObject(state, settings); 34 | 35 | var deserializedState = JsonConvert.DeserializeObject(serialized, settings); 36 | var deserializedRoomState = deserializedState as TeamVersusUserState; 37 | 38 | Assert.NotNull(deserializedRoomState); 39 | 40 | Assert.Equal(state.TeamID, deserializedRoomState.AsNonNull().TeamID); 41 | } 42 | 43 | [Fact] 44 | public void TestMatchRoomStateSerialization() 45 | { 46 | var state = new TeamVersusRoomState 47 | { 48 | Teams = 49 | { 50 | new MultiplayerTeam 51 | { 52 | ID = 1, Name = "test" 53 | } 54 | } 55 | }; 56 | var serialized = JsonConvert.SerializeObject(state, settings); 57 | 58 | var deserializedState = JsonConvert.DeserializeObject(serialized, settings).AsNonNull(); 59 | 60 | var teamVersusRoomState = (TeamVersusRoomState)deserializedState; 61 | 62 | Assert.Equal(state.Teams.Count, teamVersusRoomState.Teams.Count); 63 | Assert.Equal(state.Teams.First().ID, teamVersusRoomState.Teams.First().ID); 64 | Assert.Equal(state.Teams.First().Name, teamVersusRoomState.Teams.First().Name); 65 | } 66 | 67 | [Fact] 68 | public void TestMultiplayerRoomSerialization() 69 | { 70 | MultiplayerRoom room = new MultiplayerRoom(1234) 71 | { 72 | MatchState = new TeamVersusRoomState(), 73 | Users = 74 | { 75 | new MultiplayerRoomUser(888), 76 | } 77 | }; 78 | 79 | var serialized = JsonConvert.SerializeObject(room, settings); 80 | 81 | var deserialisedRoom = JsonConvert.DeserializeObject(serialized, settings).AsNonNull(); 82 | 83 | Assert.Equal(room.RoomID, deserialisedRoom.RoomID); 84 | Assert.Equal(room.Users.Count, deserialisedRoom.Users.Count); 85 | Assert.Equal(room.Users.First().UserID, deserialisedRoom.Users.First().UserID); 86 | Assert.Equal(typeof(TeamVersusRoomState), deserialisedRoom.MatchState?.GetType()); 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/MessagePackSerializationTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Linq; 5 | using MessagePack; 6 | using osu.Game.Online.Multiplayer; 7 | using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; 8 | using Xunit; 9 | 10 | namespace osu.Server.Spectator.Tests 11 | { 12 | public class MessagePackSerializationTests 13 | { 14 | [Fact(Skip = "Won't work without abstract class definitions (temporarily removed).")] 15 | public void TestMatchUserStateSerialization() 16 | { 17 | var state = new TeamVersusUserState 18 | { 19 | TeamID = 5, 20 | }; 21 | 22 | var serialized = MessagePackSerializer.Serialize((MatchUserState)state); 23 | 24 | var deserializedState = MessagePackSerializer.Deserialize(serialized); 25 | var deserializedRoomState = deserializedState as TeamVersusUserState; 26 | 27 | Assert.NotNull(deserializedRoomState); 28 | 29 | Assert.Equal(state.TeamID, deserializedRoomState.TeamID); 30 | } 31 | 32 | [Fact(Skip = "Won't work without abstract class definitions (temporarily removed).")] 33 | public void TestMatchRoomStateSerialization() 34 | { 35 | var state = new TeamVersusRoomState 36 | { 37 | Teams = 38 | { 39 | new MultiplayerTeam 40 | { 41 | ID = 1, Name = "test" 42 | } 43 | } 44 | }; 45 | var serialized = MessagePackSerializer.Serialize((MatchRoomState)state); 46 | 47 | var deserializedState = MessagePackSerializer.Deserialize(serialized); 48 | var deserializedRoomState = deserializedState as TeamVersusRoomState; 49 | 50 | Assert.NotNull(deserializedRoomState); 51 | 52 | Assert.Equal(state.Teams.Count, deserializedRoomState.Teams.Count); 53 | Assert.Equal(state.Teams.First().ID, deserializedRoomState.Teams.First().ID); 54 | Assert.Equal(state.Teams.First().Name, deserializedRoomState.Teams.First().Name); 55 | } 56 | 57 | [Fact(Skip = "Won't work without abstract class definitions (temporarily removed).")] 58 | public void TestMultiplayerRoomSerialization() 59 | { 60 | MultiplayerRoom room = new MultiplayerRoom(1234) 61 | { 62 | Users = 63 | { 64 | new MultiplayerRoomUser(888), 65 | } 66 | }; 67 | 68 | var serialized = MessagePackSerializer.Serialize(room); 69 | 70 | var deserialisedRoom = MessagePackSerializer.Deserialize(serialized); 71 | 72 | Assert.Equal(room.RoomID, deserialisedRoom.RoomID); 73 | Assert.Equal(room.Users.Count, deserialisedRoom.Users.Count); 74 | Assert.Equal(room.Users.First().UserID, deserialisedRoom.Users.First().UserID); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/Multiplayer/AutomaticForceStartTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Diagnostics; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | using Moq; 8 | using osu.Game.Online.Multiplayer; 9 | using osu.Game.Online.Multiplayer.Countdown; 10 | using Xunit; 11 | 12 | namespace osu.Server.Spectator.Tests.Multiplayer 13 | { 14 | public class AutomaticForceStartTest : MultiplayerTest 15 | { 16 | [Fact] 17 | public async Task CountdownStartsWhenMatchStarts() 18 | { 19 | await Hub.JoinRoom(ROOM_ID); 20 | await MarkCurrentUserReadyAndAvailable(); 21 | 22 | await Hub.StartMatch(); 23 | 24 | using (var usage = await Hub.GetRoom(ROOM_ID)) 25 | Assert.NotNull(usage.Item!.FindCountdownOfType()); 26 | } 27 | 28 | [Fact] 29 | public async Task CountdownStopsWhenAllPlayersAbort() 30 | { 31 | await Hub.JoinRoom(ROOM_ID); 32 | await MarkCurrentUserReadyAndAvailable(); 33 | 34 | await Hub.StartMatch(); 35 | await Hub.AbortGameplay(); 36 | 37 | using (var usage = await Hub.GetRoom(ROOM_ID)) 38 | Assert.Null(usage.Item!.FindCountdownOfType()); 39 | } 40 | 41 | [Fact] 42 | public async Task LoadingUsersAbortWhenCountdownEnds() 43 | { 44 | await Hub.JoinRoom(ROOM_ID); 45 | await MarkCurrentUserReadyAndAvailable(); 46 | await Hub.StartMatch(); 47 | 48 | await skipToEndOfCountdown(); 49 | 50 | using (var usage = await Hub.GetRoom(ROOM_ID)) 51 | { 52 | var room = usage.Item; 53 | Debug.Assert(room != null); 54 | 55 | Assert.True(room.State == MultiplayerRoomState.Open); 56 | Assert.Equal(MultiplayerUserState.Idle, room.Users.Single(u => u.UserID == USER_ID).State); 57 | 58 | UserReceiver.Verify(r => r.GameplayAborted(It.Is(reason => reason == GameplayAbortReason.LoadTookTooLong)), Times.Once); 59 | UserReceiver.Verify(r => r.GameplayStarted(), Times.Never); 60 | } 61 | } 62 | 63 | [Fact] 64 | public async Task LoadedUsersStartWhenCountdownEnds() 65 | { 66 | await Hub.JoinRoom(ROOM_ID); 67 | await MarkCurrentUserReadyAndAvailable(); 68 | await Hub.StartMatch(); 69 | await Hub.ChangeState(MultiplayerUserState.Loaded); 70 | 71 | await skipToEndOfCountdown(); 72 | 73 | using (var usage = await Hub.GetRoom(ROOM_ID)) 74 | { 75 | var room = usage.Item; 76 | Debug.Assert(room != null); 77 | 78 | Assert.True(room.State == MultiplayerRoomState.Playing); 79 | Assert.Equal(MultiplayerUserState.Playing, room.Users.Single(u => u.UserID == USER_ID).State); 80 | 81 | UserReceiver.Verify(r => r.GameplayAborted(It.Is(reason => reason == GameplayAbortReason.LoadTookTooLong)), Times.Never); 82 | UserReceiver.Verify(r => r.GameplayStarted(), Times.Once); 83 | } 84 | } 85 | 86 | [Fact] 87 | public async Task ReadyAndLoadedUsersStartWhenCountdownEnds() 88 | { 89 | await Hub.JoinRoom(ROOM_ID); 90 | await MarkCurrentUserReadyAndAvailable(); 91 | 92 | SetUserContext(ContextUser2); 93 | await Hub.JoinRoom(ROOM_ID); 94 | await MarkCurrentUserReadyAndAvailable(); 95 | 96 | // User 1 becomes ready for gameplay. 97 | SetUserContext(ContextUser); 98 | await Hub.StartMatch(); 99 | await Hub.ChangeState(MultiplayerUserState.Loaded); 100 | await Hub.ChangeState(MultiplayerUserState.ReadyForGameplay); 101 | 102 | // User 2 becomes loaded. 103 | SetUserContext(ContextUser2); 104 | await Hub.ChangeState(MultiplayerUserState.Loaded); 105 | 106 | await skipToEndOfCountdown(); 107 | 108 | using (var usage = await Hub.GetRoom(ROOM_ID)) 109 | { 110 | var room = usage.Item; 111 | Debug.Assert(room != null); 112 | 113 | Assert.True(room.State == MultiplayerRoomState.Playing); 114 | Assert.Equal(MultiplayerUserState.Playing, room.Users.Single(u => u.UserID == USER_ID).State); 115 | Assert.Equal(MultiplayerUserState.Playing, room.Users.Single(u => u.UserID == USER_ID_2).State); 116 | 117 | UserReceiver.Verify(r => r.GameplayStarted(), Times.Once); 118 | User2Receiver.Verify(r => r.GameplayStarted(), Times.Once); 119 | } 120 | } 121 | 122 | [Fact] 123 | public async Task CountdownCannotBeStopped() 124 | { 125 | await Hub.JoinRoom(ROOM_ID); 126 | await MarkCurrentUserReadyAndAvailable(); 127 | await Hub.StartMatch(); 128 | 129 | int countdownId; 130 | using (var usage = await Hub.GetRoom(ROOM_ID)) 131 | countdownId = usage.Item!.FindCountdownOfType()!.ID; 132 | 133 | await Assert.ThrowsAsync(async () => await Hub.SendMatchRequest(new StopCountdownRequest(countdownId))); 134 | 135 | using (var usage = await Hub.GetRoom(ROOM_ID)) 136 | Assert.NotNull(usage.Item!.FindCountdownOfType()); 137 | } 138 | 139 | private async Task skipToEndOfCountdown() 140 | { 141 | Task task; 142 | 143 | using (var usage = await Hub.GetRoom(ROOM_ID)) 144 | { 145 | var room = usage.Item; 146 | Debug.Assert(room != null); 147 | 148 | task = room.SkipToEndOfCountdown(room.FindCountdownOfType()); 149 | } 150 | 151 | try 152 | { 153 | await task; 154 | } 155 | catch (TaskCanceledException) 156 | { 157 | // don't care if task was cancelled. 158 | } 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/Multiplayer/BeatmapAvailabilityTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using Moq; 7 | using osu.Game.Online; 8 | using osu.Game.Online.Multiplayer; 9 | using osu.Game.Online.Rooms; 10 | using Xunit; 11 | 12 | namespace osu.Server.Spectator.Tests.Multiplayer 13 | { 14 | public class BeatmapAvailabilityTests : MultiplayerTest 15 | { 16 | [Fact] 17 | public async Task ClientCantChangeAvailabilityWhenNotJoinedRoom() 18 | { 19 | await Assert.ThrowsAsync(() => Hub.ChangeBeatmapAvailability(BeatmapAvailability.Importing())); 20 | } 21 | 22 | [Fact] 23 | public async Task AvailabilityChangeBroadcastedOnlyOnChange() 24 | { 25 | await Hub.JoinRoom(ROOM_ID); 26 | 27 | await Hub.ChangeBeatmapAvailability(BeatmapAvailability.Importing()); 28 | Receiver.Verify(b => b.UserBeatmapAvailabilityChanged(USER_ID, It.Is(b2 => b2.State == DownloadState.Importing)), Times.Once); 29 | 30 | // should not fire a second time. 31 | await Hub.ChangeBeatmapAvailability(BeatmapAvailability.Importing()); 32 | Receiver.Verify(b => b.UserBeatmapAvailabilityChanged(USER_ID, It.Is(b2 => b2.State == DownloadState.Importing)), Times.Once); 33 | } 34 | 35 | [Fact] 36 | public async Task OnlyClientsInSameRoomReceiveAvailabilityChange() 37 | { 38 | await Hub.JoinRoom(ROOM_ID); 39 | 40 | SetUserContext(ContextUser2); 41 | await Hub.JoinRoom(ROOM_ID_2); 42 | 43 | var user1Availability = BeatmapAvailability.Importing(); 44 | var user2Availability = BeatmapAvailability.Downloading(0.5f); 45 | 46 | SetUserContext(ContextUser); 47 | await Hub.ChangeBeatmapAvailability(user1Availability); 48 | using (var room = await Rooms.GetForUse(ROOM_ID)) 49 | Assert.True(room.Item?.Users.Single().BeatmapAvailability.Equals(user1Availability)); 50 | 51 | SetUserContext(ContextUser2); 52 | await Hub.ChangeBeatmapAvailability(user2Availability); 53 | using (var room2 = await Rooms.GetForUse(ROOM_ID_2)) 54 | Assert.True(room2.Item?.Users.Single().BeatmapAvailability.Equals(user2Availability)); 55 | 56 | Receiver.Verify(c1 => c1.UserBeatmapAvailabilityChanged(USER_ID, It.Is(b => b.Equals(user1Availability))), Times.Once); 57 | Receiver.Verify(c1 => c1.UserBeatmapAvailabilityChanged(USER_ID_2, It.Is(b => b.Equals(user2Availability))), Times.Never); 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/Multiplayer/DelegatingMultiplayerClient.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Reflection; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | using Microsoft.AspNetCore.SignalR; 10 | using osu.Game.Online.API; 11 | using osu.Game.Online.Multiplayer; 12 | using osu.Game.Online.Rooms; 13 | 14 | namespace osu.Server.Spectator.Tests.Multiplayer 15 | { 16 | /// 17 | /// Used in testing. Delegates calls to one or more s. 18 | /// Note: All members must be virtual!! 19 | /// 20 | public class DelegatingMultiplayerClient : IMultiplayerClient, ISingleClientProxy 21 | { 22 | public virtual IEnumerable Clients => Enumerable.Empty(); 23 | 24 | public virtual async Task RoomStateChanged(MultiplayerRoomState state) 25 | { 26 | foreach (var c in Clients) 27 | await c.RoomStateChanged(state); 28 | } 29 | 30 | public virtual async Task UserJoined(MultiplayerRoomUser user) 31 | { 32 | foreach (var c in Clients) 33 | await c.UserJoined(user); 34 | } 35 | 36 | public virtual async Task UserLeft(MultiplayerRoomUser user) 37 | { 38 | foreach (var c in Clients) 39 | await c.UserLeft(user); 40 | } 41 | 42 | public virtual async Task UserKicked(MultiplayerRoomUser user) 43 | { 44 | foreach (var c in Clients) 45 | await c.UserKicked(user); 46 | } 47 | 48 | public virtual async Task Invited(int invitedBy, long roomID, string password) 49 | { 50 | foreach (var c in Clients) 51 | await c.Invited(invitedBy, roomID, password); 52 | } 53 | 54 | public virtual async Task HostChanged(int userId) 55 | { 56 | foreach (var c in Clients) 57 | await c.HostChanged(userId); 58 | } 59 | 60 | public virtual async Task SettingsChanged(MultiplayerRoomSettings newSettings) 61 | { 62 | foreach (var c in Clients) 63 | await c.SettingsChanged(newSettings); 64 | } 65 | 66 | public virtual async Task UserStateChanged(int userId, MultiplayerUserState state) 67 | { 68 | foreach (var c in Clients) 69 | await c.UserStateChanged(userId, state); 70 | } 71 | 72 | public virtual async Task MatchUserStateChanged(int userId, MatchUserState state) 73 | { 74 | foreach (var c in Clients) 75 | await c.MatchUserStateChanged(userId, state); 76 | } 77 | 78 | public virtual async Task MatchRoomStateChanged(MatchRoomState state) 79 | { 80 | foreach (var c in Clients) 81 | await c.MatchRoomStateChanged(state); 82 | } 83 | 84 | public virtual async Task MatchEvent(MatchServerEvent e) 85 | { 86 | foreach (var c in Clients) 87 | await c.MatchEvent(e); 88 | } 89 | 90 | public virtual async Task UserBeatmapAvailabilityChanged(int userId, BeatmapAvailability beatmapAvailability) 91 | { 92 | foreach (var c in Clients) 93 | await c.UserBeatmapAvailabilityChanged(userId, beatmapAvailability); 94 | } 95 | 96 | public virtual async Task UserStyleChanged(int userId, int? beatmapId, int? rulesetId) 97 | { 98 | foreach (var c in Clients) 99 | await c.UserStyleChanged(userId, beatmapId, rulesetId); 100 | } 101 | 102 | public virtual async Task UserModsChanged(int userId, IEnumerable mods) 103 | { 104 | foreach (var c in Clients) 105 | await c.UserModsChanged(userId, mods); 106 | } 107 | 108 | public virtual async Task LoadRequested() 109 | { 110 | foreach (var c in Clients) 111 | await c.LoadRequested(); 112 | } 113 | 114 | public virtual async Task GameplayAborted(GameplayAbortReason reason) 115 | { 116 | foreach (var c in Clients) 117 | await c.GameplayAborted(reason); 118 | } 119 | 120 | public virtual async Task GameplayStarted() 121 | { 122 | foreach (var c in Clients) 123 | await c.GameplayStarted(); 124 | } 125 | 126 | public virtual async Task ResultsReady() 127 | { 128 | foreach (var c in Clients) 129 | await c.ResultsReady(); 130 | } 131 | 132 | public virtual async Task PlaylistItemAdded(MultiplayerPlaylistItem item) 133 | { 134 | foreach (var c in Clients) 135 | await c.PlaylistItemAdded(item); 136 | } 137 | 138 | public virtual async Task PlaylistItemRemoved(long playlistItemId) 139 | { 140 | foreach (var c in Clients) 141 | await c.PlaylistItemRemoved(playlistItemId); 142 | } 143 | 144 | public virtual async Task PlaylistItemChanged(MultiplayerPlaylistItem item) 145 | { 146 | foreach (var c in Clients) 147 | await c.PlaylistItemChanged(item); 148 | } 149 | 150 | public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = new CancellationToken()) 151 | { 152 | return (Task)GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.Public)!.Invoke(this, args)!; 153 | } 154 | 155 | public Task InvokeCoreAsync(string method, object?[] args, CancellationToken cancellationToken) 156 | { 157 | return (Task)GetType().GetMethod(method, BindingFlags.Instance | BindingFlags.Public)!.Invoke(this, args)!; 158 | } 159 | 160 | public async Task DisconnectRequested() 161 | { 162 | foreach (var c in Clients) 163 | await c.DisconnectRequested(); 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/Multiplayer/MatchSpectatingTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Threading.Tasks; 5 | using Moq; 6 | using osu.Game.Online.Multiplayer; 7 | using Xunit; 8 | 9 | namespace osu.Server.Spectator.Tests.Multiplayer 10 | { 11 | public class MatchSpectatingTests : MultiplayerTest 12 | { 13 | [Fact] 14 | public async Task CanTransitionBetweenIdleAndSpectating() 15 | { 16 | await Hub.JoinRoom(ROOM_ID); 17 | await Hub.ChangeState(MultiplayerUserState.Spectating); 18 | await Hub.ChangeState(MultiplayerUserState.Idle); 19 | } 20 | 21 | [Fact] 22 | public async Task CanTransitionFromReadyToSpectating() 23 | { 24 | await Hub.JoinRoom(ROOM_ID); 25 | await MarkCurrentUserReadyAndAvailable(); 26 | await Hub.ChangeState(MultiplayerUserState.Spectating); 27 | } 28 | 29 | [Fact] 30 | public async Task SpectatingUserStateDoesNotChange() 31 | { 32 | await Hub.JoinRoom(ROOM_ID); 33 | await MarkCurrentUserReadyAndAvailable(); 34 | 35 | SetUserContext(ContextUser2); 36 | await Hub.JoinRoom(ROOM_ID); 37 | await Hub.ChangeState(MultiplayerUserState.Spectating); 38 | 39 | SetUserContext(ContextUser); 40 | 41 | await Hub.StartMatch(); 42 | Receiver.Verify(c => c.LoadRequested(), Times.Once); 43 | Clients.Verify(clients => clients.Client(ContextUser2.Object.ConnectionId).UserStateChanged(USER_ID_2, MultiplayerUserState.WaitingForLoad), Times.Never); 44 | 45 | await Hub.ChangeState(MultiplayerUserState.Loaded); 46 | await Hub.ChangeState(MultiplayerUserState.ReadyForGameplay); 47 | UserReceiver.Verify(c => c.GameplayStarted(), Times.Once); 48 | Clients.Verify(clients => clients.Client(ContextUser2.Object.ConnectionId).UserStateChanged(USER_ID_2, MultiplayerUserState.Playing), Times.Never); 49 | 50 | await Hub.ChangeState(MultiplayerUserState.FinishedPlay); 51 | Receiver.Verify(c => c.ResultsReady(), Times.Once); 52 | Clients.Verify(clients => clients.Client(ContextUser2.Object.ConnectionId).UserStateChanged(USER_ID_2, MultiplayerUserState.Results), Times.Never); 53 | } 54 | 55 | [Fact] 56 | public async Task SpectatingHostCanStartMatch() 57 | { 58 | await Hub.JoinRoom(ROOM_ID); 59 | await Hub.ChangeState(MultiplayerUserState.Spectating); 60 | 61 | SetUserContext(ContextUser2); 62 | await Hub.JoinRoom(ROOM_ID); 63 | await MarkCurrentUserReadyAndAvailable(); 64 | 65 | SetUserContext(ContextUser); 66 | await Hub.StartMatch(); 67 | Receiver.Verify(c => c.LoadRequested(), Times.Once); 68 | } 69 | 70 | [Fact] 71 | public async Task SpectatingUserReceivesLoadRequestedAfterGameplayStarted() 72 | { 73 | await Hub.JoinRoom(ROOM_ID); 74 | await MarkCurrentUserReadyAndAvailable(); 75 | await Hub.StartMatch(); 76 | Receiver.Verify(c => c.LoadRequested(), Times.Once); 77 | 78 | SetUserContext(ContextUser2); 79 | await Hub.JoinRoom(ROOM_ID); 80 | await Hub.ChangeState(MultiplayerUserState.Spectating); 81 | Caller.Verify(c => c.LoadRequested(), Times.Once); 82 | 83 | // Ensure no other clients received LoadRequested(). 84 | Receiver.Verify(c => c.LoadRequested(), Times.Once); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/Multiplayer/MatchTypeRoomEventHookTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Threading.Tasks; 5 | using Moq; 6 | using osu.Game.Online.Multiplayer; 7 | using osu.Game.Online.Rooms; 8 | using osu.Server.Spectator.Hubs.Multiplayer; 9 | using Xunit; 10 | 11 | namespace osu.Server.Spectator.Tests.Multiplayer 12 | { 13 | /// 14 | /// Tests covering propagation of events through to the via callbacks. 15 | /// 16 | public class MatchTypeRoomEventHookTests : MultiplayerTest 17 | { 18 | [Fact] 19 | public async Task NewUserJoinedTriggersRulesetHook() 20 | { 21 | var hub = new Mock(); 22 | var room = new ServerMultiplayerRoom(1, hub.Object) 23 | { 24 | Playlist = 25 | { 26 | new MultiplayerPlaylistItem 27 | { 28 | BeatmapID = 3333, 29 | BeatmapChecksum = "3333" 30 | }, 31 | } 32 | }; 33 | 34 | await room.Initialise(DatabaseFactory.Object); 35 | 36 | Mock typeImplementation = new Mock(room, hub.Object); 37 | room.MatchTypeImplementation = typeImplementation.Object; 38 | 39 | room.AddUser(new MultiplayerRoomUser(1)); 40 | 41 | typeImplementation.Verify(m => m.HandleUserJoined(It.IsAny()), Times.Once()); 42 | } 43 | 44 | [Fact] 45 | public async Task UserLeavesTriggersRulesetHook() 46 | { 47 | var hub = new Mock(); 48 | var room = new ServerMultiplayerRoom(1, hub.Object) 49 | { 50 | Playlist = 51 | { 52 | new MultiplayerPlaylistItem 53 | { 54 | BeatmapID = 3333, 55 | BeatmapChecksum = "3333" 56 | }, 57 | } 58 | }; 59 | 60 | await room.Initialise(DatabaseFactory.Object); 61 | 62 | var user = new MultiplayerRoomUser(1); 63 | 64 | room.AddUser(user); 65 | 66 | Mock typeImplementation = new Mock(room, hub.Object); 67 | room.MatchTypeImplementation = typeImplementation.Object; 68 | 69 | room.RemoveUser(user); 70 | typeImplementation.Verify(m => m.HandleUserLeft(It.IsAny()), Times.Once()); 71 | } 72 | 73 | [Fact] 74 | public async Task TypeChangeTriggersInitialJoins() 75 | { 76 | var hub = new Mock(); 77 | var room = new ServerMultiplayerRoom(1, hub.Object) 78 | { 79 | Playlist = 80 | { 81 | new MultiplayerPlaylistItem 82 | { 83 | BeatmapID = 3333, 84 | BeatmapChecksum = "3333" 85 | }, 86 | } 87 | }; 88 | 89 | await room.Initialise(DatabaseFactory.Object); 90 | 91 | // join a number of users initially to the room 92 | for (int i = 0; i < 5; i++) 93 | room.AddUser(new MultiplayerRoomUser(i)); 94 | 95 | // change the match type 96 | Mock typeImplementation = new Mock(room, hub.Object); 97 | room.MatchTypeImplementation = typeImplementation.Object; 98 | 99 | // ensure the match type received hook events for all already joined users. 100 | typeImplementation.Verify(m => m.HandleUserJoined(It.IsAny()), Times.Exactly(5)); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/Multiplayer/MultiplayerInviteTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Threading.Tasks; 5 | using Moq; 6 | using osu.Game.Online.Multiplayer; 7 | using osu.Server.Spectator.Database.Models; 8 | using Xunit; 9 | 10 | namespace osu.Server.Spectator.Tests.Multiplayer; 11 | 12 | public class MultiplayerInviteTest : MultiplayerTest 13 | { 14 | [Fact] 15 | public async Task UserCanInviteFriends() 16 | { 17 | SetUserContext(ContextUser); 18 | await Hub.JoinRoom(ROOM_ID); 19 | 20 | Database.Setup(d => d.GetUserRelation(It.IsAny(), It.IsAny())).ReturnsAsync(new phpbb_zebra { friend = true }); 21 | 22 | SetUserContext(ContextUser); 23 | await Hub.InvitePlayer(USER_ID_2); 24 | 25 | User2Receiver.Verify(r => r.Invited( 26 | USER_ID, 27 | ROOM_ID, 28 | string.Empty 29 | ), Times.Once); 30 | } 31 | 32 | [Fact] 33 | public async Task UserCantInviteUserTheyBlocked() 34 | { 35 | SetUserContext(ContextUser); 36 | await Hub.JoinRoom(ROOM_ID); 37 | 38 | Database.Setup(d => d.GetUserRelation(USER_ID, USER_ID_2)).ReturnsAsync(new phpbb_zebra { foe = true }); 39 | Database.Setup(d => d.GetUserRelation(USER_ID_2, USER_ID)).ReturnsAsync(new phpbb_zebra { friend = true }); 40 | 41 | SetUserContext(ContextUser); 42 | await Assert.ThrowsAsync(() => Hub.InvitePlayer(USER_ID_2)); 43 | 44 | User2Receiver.Verify(r => r.Invited( 45 | It.IsAny(), 46 | It.IsAny(), 47 | It.IsAny() 48 | ), Times.Never); 49 | } 50 | 51 | [Fact] 52 | public async Task UserCantInviteUserTheyAreBlockedBy() 53 | { 54 | SetUserContext(ContextUser); 55 | await Hub.JoinRoom(ROOM_ID); 56 | 57 | Database.Setup(d => d.GetUserRelation(USER_ID, USER_ID_2)).ReturnsAsync(new phpbb_zebra { friend = true }); 58 | Database.Setup(d => d.GetUserRelation(USER_ID_2, USER_ID)).ReturnsAsync(new phpbb_zebra { foe = true }); 59 | 60 | SetUserContext(ContextUser); 61 | await Assert.ThrowsAsync(() => Hub.InvitePlayer(USER_ID_2)); 62 | 63 | User2Receiver.Verify(r => r.Invited( 64 | It.IsAny(), 65 | It.IsAny(), 66 | It.IsAny() 67 | ), Times.Never); 68 | } 69 | 70 | [Fact] 71 | public async Task UserCantInviteUserWithDisabledPMs() 72 | { 73 | SetUserContext(ContextUser); 74 | await Hub.JoinRoom(ROOM_ID); 75 | 76 | Database.Setup(d => d.GetUserAllowsPMs(USER_ID_2)).ReturnsAsync(false); 77 | 78 | SetUserContext(ContextUser); 79 | await Assert.ThrowsAsync(() => Hub.InvitePlayer(USER_ID_2)); 80 | 81 | User2Receiver.Verify(r => r.Invited( 82 | It.IsAny(), 83 | It.IsAny(), 84 | It.IsAny() 85 | ), Times.Never); 86 | } 87 | 88 | [Fact] 89 | public async Task UserCantInviteRestrictedUser() 90 | { 91 | SetUserContext(ContextUser); 92 | await Hub.JoinRoom(ROOM_ID); 93 | 94 | Database.Setup(d => d.GetUserRelation(It.IsAny(), It.IsAny())).ReturnsAsync(new phpbb_zebra { friend = true }); 95 | Database.Setup(d => d.IsUserRestrictedAsync(It.IsAny())).ReturnsAsync(true); 96 | 97 | SetUserContext(ContextUser); 98 | await Assert.ThrowsAsync(() => Hub.InvitePlayer(USER_ID_2)); 99 | 100 | User2Receiver.Verify(r => r.Invited( 101 | It.IsAny(), 102 | It.IsAny(), 103 | It.IsAny() 104 | ), Times.Never); 105 | } 106 | 107 | [Fact] 108 | public async Task UserCanInviteUserWithEnabledPMs() 109 | { 110 | SetUserContext(ContextUser); 111 | await Hub.JoinRoom(ROOM_ID); 112 | 113 | Database.Setup(d => d.GetUserAllowsPMs(USER_ID_2)).ReturnsAsync(true); 114 | 115 | SetUserContext(ContextUser); 116 | await Hub.InvitePlayer(USER_ID_2); 117 | 118 | User2Receiver.Verify(r => r.Invited( 119 | USER_ID, 120 | ROOM_ID, 121 | string.Empty 122 | ), Times.Once); 123 | } 124 | 125 | [Fact] 126 | public async Task UserCanInviteIntoRoomWithPassword() 127 | { 128 | const string password = "password"; 129 | 130 | Database.Setup(db => db.GetRealtimeRoomAsync(It.IsAny())) 131 | .Callback(InitialiseRoom) 132 | .ReturnsAsync(new multiplayer_room 133 | { 134 | password = password, 135 | user_id = USER_ID 136 | }); 137 | 138 | SetUserContext(ContextUser); 139 | await Hub.JoinRoomWithPassword(ROOM_ID, password); 140 | 141 | Database.Setup(d => d.GetUserAllowsPMs(USER_ID_2)).ReturnsAsync(true); 142 | 143 | SetUserContext(ContextUser); 144 | await Hub.InvitePlayer(USER_ID_2); 145 | 146 | User2Receiver.Verify(r => r.Invited( 147 | USER_ID, 148 | ROOM_ID, 149 | password 150 | ), Times.Once); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | using Moq; 8 | using osu.Game.Online.Multiplayer; 9 | using Xunit; 10 | 11 | namespace osu.Server.Spectator.Tests.Multiplayer 12 | { 13 | public class RoomInteropTest : MultiplayerTest 14 | { 15 | [Fact] 16 | public async Task CreateRoom() 17 | { 18 | LegacyIO.Setup(io => io.CreateRoomAsync(It.IsAny(), It.IsAny())) 19 | .ReturnsAsync(() => ROOM_ID); 20 | 21 | await Hub.CreateRoom(new MultiplayerRoom(0)); 22 | LegacyIO.Verify(io => io.CreateRoomAsync(USER_ID, It.IsAny()), Times.Once); 23 | LegacyIO.Verify(io => io.AddUserToRoomAsync(USER_ID, ROOM_ID, It.IsAny()), Times.Once); 24 | 25 | using (var usage = await Hub.GetRoom(ROOM_ID)) 26 | { 27 | Assert.NotNull(usage.Item); 28 | Assert.Equal(USER_ID, usage.Item.Users.Single().UserID); 29 | } 30 | } 31 | 32 | [Fact] 33 | public async Task LeaveRoom() 34 | { 35 | await Hub.JoinRoom(ROOM_ID); 36 | LegacyIO.Verify(io => io.RemoveUserFromRoomAsync(USER_ID, ROOM_ID), Times.Never); 37 | 38 | await Hub.LeaveRoom(); 39 | LegacyIO.Verify(io => io.RemoveUserFromRoomAsync(USER_ID, ROOM_ID), Times.Once); 40 | 41 | await Assert.ThrowsAsync(() => Hub.GetRoom(ROOM_ID)); 42 | } 43 | 44 | [Fact] 45 | public async Task KickUser() 46 | { 47 | await Hub.JoinRoom(ROOM_ID); 48 | 49 | SetUserContext(ContextUser2); 50 | await Hub.JoinRoom(ROOM_ID); 51 | 52 | SetUserContext(ContextUser); 53 | await Hub.KickUser(USER_ID_2); 54 | 55 | LegacyIO.Verify(io => io.RemoveUserFromRoomAsync(USER_ID, ROOM_ID), Times.Never); 56 | LegacyIO.Verify(io => io.RemoveUserFromRoomAsync(USER_ID_2, ROOM_ID), Times.Once); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/Multiplayer/TestMultiplayerHub.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using Microsoft.AspNetCore.SignalR; 5 | using Microsoft.Extensions.Logging; 6 | using osu.Server.Spectator.Database; 7 | using osu.Server.Spectator.Entities; 8 | using osu.Server.Spectator.Hubs.Multiplayer; 9 | using osu.Server.Spectator.Services; 10 | 11 | namespace osu.Server.Spectator.Tests.Multiplayer 12 | { 13 | public class TestMultiplayerHub : MultiplayerHub 14 | { 15 | public new MultiplayerHubContext HubContext => base.HubContext; 16 | 17 | public TestMultiplayerHub( 18 | ILoggerFactory loggerFactory, 19 | EntityStore rooms, 20 | EntityStore users, 21 | IDatabaseFactory databaseFactory, 22 | ChatFilters chatFilters, 23 | IHubContext hubContext, 24 | ISharedInterop sharedInterop, 25 | MultiplayerEventLogger multiplayerEventLogger) 26 | : base(loggerFactory, rooms, users, databaseFactory, chatFilters, hubContext, sharedInterop, multiplayerEventLogger) 27 | { 28 | } 29 | 30 | public bool CheckRoomExists(long roomId) 31 | { 32 | try 33 | { 34 | using (var usage = Rooms.GetForUse(roomId).Result) 35 | return usage.Item != null; 36 | } 37 | catch 38 | { 39 | // probably not tracked. 40 | return false; 41 | } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/StatefulUserHubTest.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Threading.Tasks; 7 | using Microsoft.AspNetCore.SignalR; 8 | using Microsoft.Extensions.Caching.Distributed; 9 | using Microsoft.Extensions.Caching.Memory; 10 | using Microsoft.Extensions.Logging; 11 | using Microsoft.Extensions.Options; 12 | using Moq; 13 | using osu.Game.Online; 14 | using osu.Server.Spectator.Entities; 15 | using osu.Server.Spectator.Extensions; 16 | using osu.Server.Spectator.Hubs; 17 | using Xunit; 18 | 19 | namespace osu.Server.Spectator.Tests 20 | { 21 | public class StatefulUserHubTest 22 | { 23 | private readonly TestStatefulHub hub; 24 | 25 | private const int user_id = 1234; 26 | 27 | private readonly Mock mockContext; 28 | 29 | private readonly EntityStore userStates; 30 | 31 | public StatefulUserHubTest() 32 | { 33 | var loggerFactoryMock = new Mock(); 34 | loggerFactoryMock.Setup(factory => factory.CreateLogger(It.IsAny())) 35 | .Returns(new Mock().Object); 36 | 37 | MemoryDistributedCache cache = new MemoryDistributedCache(Options.Create(new MemoryDistributedCacheOptions())); 38 | 39 | userStates = new EntityStore(); 40 | hub = new TestStatefulHub(loggerFactoryMock.Object, userStates); 41 | 42 | mockContext = new Mock(); 43 | mockContext.Setup(context => context.UserIdentifier).Returns(user_id.ToString()); 44 | 45 | setNewConnectionId(); 46 | 47 | hub.Context = mockContext.Object; 48 | } 49 | 50 | [Fact] 51 | public async Task ConnectDisconnectStateCleanup() 52 | { 53 | await hub.OnConnectedAsync(); 54 | 55 | await hub.CreateUserState(); 56 | 57 | await hub.OnDisconnectedAsync(null); 58 | 59 | await Assert.ThrowsAsync(() => userStates.GetForUse(user_id)); 60 | } 61 | 62 | [Fact] 63 | public async Task SameUserConnectsTwiceDestroysPreviousState() 64 | { 65 | await hub.OnConnectedAsync(); 66 | await hub.CreateUserState(); 67 | 68 | using (var state = await userStates.GetForUse(user_id)) 69 | { 70 | ClientState? firstState = state.Item; 71 | 72 | Assert.NotNull(firstState); 73 | Assert.Equal(mockContext.Object.ConnectionId, firstState.ConnectionId); 74 | } 75 | 76 | // connect a second time as the same user without disconnecting the original connection. 77 | setNewConnectionId(); 78 | await hub.OnConnectedAsync(); 79 | 80 | // original state should have been destroyed. 81 | await Assert.ThrowsAsync(() => userStates.GetForUse(user_id)); 82 | } 83 | 84 | [Fact] 85 | public async Task SameUserOldConnectionDoesntDestroyNewState() 86 | { 87 | await hub.OnConnectedAsync(); 88 | await hub.CreateUserState(); 89 | 90 | string originalConnectionId = mockContext.Object.ConnectionId; 91 | 92 | // connect a second time as the same user without disconnecting the original connection. 93 | setNewConnectionId(); 94 | await hub.OnConnectedAsync(); 95 | 96 | // original state should have been destroyed. 97 | await Assert.ThrowsAsync(() => userStates.GetForUse(user_id)); 98 | 99 | // create a state using the second connection. 100 | await hub.CreateUserState(); 101 | string lastConnectedConnectionId = mockContext.Object.ConnectionId; 102 | 103 | // ensure disconnecting the original connection does nothing. 104 | setNewConnectionId(originalConnectionId); 105 | await hub.OnDisconnectedAsync(null); 106 | 107 | using (var state = await userStates.GetForUse(user_id)) 108 | Assert.Equal(lastConnectedConnectionId, state.Item?.ConnectionId); 109 | } 110 | 111 | private void setNewConnectionId(string? connectionId = null) => 112 | mockContext.Setup(context => context.ConnectionId).Returns(connectionId ?? Guid.NewGuid().ToString()); 113 | 114 | private class TestStatefulHub : StatefulUserHub 115 | { 116 | public TestStatefulHub(ILoggerFactory loggerFactory, EntityStore userStates) 117 | : base(loggerFactory, userStates) 118 | { 119 | } 120 | 121 | public async Task CreateUserState() 122 | { 123 | using (var state = await GetOrCreateLocalUserState()) 124 | state.Item = new ClientState(Context.ConnectionId, Context.GetUserId()); 125 | } 126 | } 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /osu.Server.Spectator.Tests/osu.Server.Spectator.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | 6 | false 7 | 8 | enable 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | all 18 | 19 | 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | all 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /osu.Server.Spectator.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Server.Spectator", "osu.Server.Spectator\osu.Server.Spectator.csproj", "{25BA3E1C-81EC-4DFB-9476-E7C6132CAD12}" 4 | EndProject 5 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "osu.Server.Spectator.Tests", "osu.Server.Spectator.Tests\osu.Server.Spectator.Tests.csproj", "{8FA8D3BE-FC98-45F6-98FC-07F70DD2483B}" 6 | EndProject 7 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleSpectatorClient", "SampleSpectatorClient\SampleSpectatorClient.csproj", "{E126B79F-50B9-4448-8DE6-564BB820EE9D}" 8 | EndProject 9 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SampleMultiplayerClient", "SampleMultiplayerClient\SampleMultiplayerClient.csproj", "{1F6CE8F8-1CF5-455B-9064-63CC12410B56}" 10 | EndProject 11 | Global 12 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 13 | Debug|Any CPU = Debug|Any CPU 14 | Release|Any CPU = Release|Any CPU 15 | EndGlobalSection 16 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 17 | {25BA3E1C-81EC-4DFB-9476-E7C6132CAD12}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 18 | {25BA3E1C-81EC-4DFB-9476-E7C6132CAD12}.Debug|Any CPU.Build.0 = Debug|Any CPU 19 | {25BA3E1C-81EC-4DFB-9476-E7C6132CAD12}.Release|Any CPU.ActiveCfg = Release|Any CPU 20 | {25BA3E1C-81EC-4DFB-9476-E7C6132CAD12}.Release|Any CPU.Build.0 = Release|Any CPU 21 | {8FA8D3BE-FC98-45F6-98FC-07F70DD2483B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 22 | {8FA8D3BE-FC98-45F6-98FC-07F70DD2483B}.Debug|Any CPU.Build.0 = Debug|Any CPU 23 | {8FA8D3BE-FC98-45F6-98FC-07F70DD2483B}.Release|Any CPU.ActiveCfg = Release|Any CPU 24 | {8FA8D3BE-FC98-45F6-98FC-07F70DD2483B}.Release|Any CPU.Build.0 = Release|Any CPU 25 | {E126B79F-50B9-4448-8DE6-564BB820EE9D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 26 | {E126B79F-50B9-4448-8DE6-564BB820EE9D}.Debug|Any CPU.Build.0 = Debug|Any CPU 27 | {E126B79F-50B9-4448-8DE6-564BB820EE9D}.Release|Any CPU.ActiveCfg = Release|Any CPU 28 | {E126B79F-50B9-4448-8DE6-564BB820EE9D}.Release|Any CPU.Build.0 = Release|Any CPU 29 | {1F6CE8F8-1CF5-455B-9064-63CC12410B56}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 30 | {1F6CE8F8-1CF5-455B-9064-63CC12410B56}.Debug|Any CPU.Build.0 = Debug|Any CPU 31 | {1F6CE8F8-1CF5-455B-9064-63CC12410B56}.Release|Any CPU.ActiveCfg = Release|Any CPU 32 | {1F6CE8F8-1CF5-455B-9064-63CC12410B56}.Release|Any CPU.Build.0 = Release|Any CPU 33 | EndGlobalSection 34 | EndGlobal 35 | -------------------------------------------------------------------------------- /osu.Server.Spectator/.dockerignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | -------------------------------------------------------------------------------- /osu.Server.Spectator/AppSettings.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | 6 | namespace osu.Server.Spectator 7 | { 8 | public static class AppSettings 9 | { 10 | public static bool SaveReplays { get; set; } 11 | public static int ReplayUploaderConcurrency { get; set; } 12 | 13 | #region For use with FileScoreStorage 14 | 15 | public static string ReplaysPath { get; set; } 16 | 17 | #endregion 18 | 19 | #region For use with S3ScoreStorage 20 | 21 | public static string S3Key { get; } 22 | public static string S3Secret { get; } 23 | public static string ReplaysBucket { get; } 24 | 25 | #endregion 26 | 27 | public static bool TrackBuildUserCounts { get; set; } 28 | 29 | public static string ServerPort { get; set; } 30 | public static string RedisHost { get; } 31 | public static string DataDogAgentHost { get; set; } 32 | 33 | public static string DatabaseHost { get; } 34 | public static string DatabaseUser { get; } 35 | public static string DatabasePort { get; } 36 | 37 | public static string SharedInteropDomain { get; } 38 | public static string SharedInteropSecret { get; } 39 | 40 | public static string? SentryDsn { get; } 41 | 42 | static AppSettings() 43 | { 44 | SaveReplays = Environment.GetEnvironmentVariable("SAVE_REPLAYS") == "1"; 45 | ReplayUploaderConcurrency = int.Parse(Environment.GetEnvironmentVariable("REPLAY_UPLOAD_THREADS") ?? "1"); 46 | ArgumentOutOfRangeException.ThrowIfNegativeOrZero(ReplayUploaderConcurrency); 47 | 48 | ReplaysPath = Environment.GetEnvironmentVariable("REPLAYS_PATH") ?? "replays"; 49 | S3Key = Environment.GetEnvironmentVariable("S3_KEY") ?? string.Empty; 50 | S3Secret = Environment.GetEnvironmentVariable("S3_SECRET") ?? string.Empty; 51 | ReplaysBucket = Environment.GetEnvironmentVariable("REPLAYS_BUCKET") ?? string.Empty; 52 | TrackBuildUserCounts = Environment.GetEnvironmentVariable("TRACK_BUILD_USER_COUNTS") == "1"; 53 | 54 | ServerPort = Environment.GetEnvironmentVariable("SERVER_PORT") ?? "80"; 55 | RedisHost = Environment.GetEnvironmentVariable("REDIS_HOST") ?? "localhost"; 56 | DataDogAgentHost = Environment.GetEnvironmentVariable("DD_AGENT_HOST") ?? "localhost"; 57 | 58 | DatabaseHost = Environment.GetEnvironmentVariable("DB_HOST") ?? "localhost"; 59 | DatabaseUser = Environment.GetEnvironmentVariable("DB_USER") ?? "osuweb"; 60 | DatabasePort = Environment.GetEnvironmentVariable("DB_PORT") ?? "3306"; 61 | 62 | SharedInteropDomain = Environment.GetEnvironmentVariable("SHARED_INTEROP_DOMAIN") ?? "http://localhost:8080"; 63 | SharedInteropSecret = Environment.GetEnvironmentVariable("SHARED_INTEROP_SECRET") ?? string.Empty; 64 | 65 | SentryDsn = Environment.GetEnvironmentVariable("SENTRY_DSN"); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Authentication/ConfigureJwtBearerOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.IO; 6 | using System.Security.Cryptography; 7 | using Microsoft.AspNetCore.Authentication.JwtBearer; 8 | using Microsoft.Extensions.Options; 9 | using Microsoft.Extensions.Logging; 10 | using Microsoft.IdentityModel.JsonWebTokens; 11 | using Microsoft.IdentityModel.Tokens; 12 | using Org.BouncyCastle.Crypto.Parameters; 13 | using Org.BouncyCastle.Security; 14 | using osu.Server.Spectator.Database; 15 | 16 | namespace osu.Server.Spectator.Authentication 17 | { 18 | public class ConfigureJwtBearerOptions : IConfigureNamedOptions 19 | { 20 | private readonly IDatabaseFactory databaseFactory; 21 | private readonly ILoggerFactory loggerFactory; 22 | 23 | public ConfigureJwtBearerOptions(IDatabaseFactory databaseFactory, ILoggerFactory loggerFactory) 24 | { 25 | this.databaseFactory = databaseFactory; 26 | this.loggerFactory = loggerFactory; 27 | } 28 | 29 | public void Configure(JwtBearerOptions options) 30 | { 31 | var rsa = getKeyProvider(); 32 | 33 | options.TokenValidationParameters = new TokenValidationParameters 34 | { 35 | IssuerSigningKey = new RsaSecurityKey(rsa), 36 | ValidAudience = "5", // should match the client ID assigned to osu! in the osu-web target deploy. 37 | // TODO: figure out why this isn't included in the token. 38 | ValidateIssuer = false, 39 | ValidIssuer = "https://osu.ppy.sh/" 40 | }; 41 | 42 | options.Events = new JwtBearerEvents 43 | { 44 | OnTokenValidated = async context => 45 | { 46 | var jwtToken = (JsonWebToken)context.SecurityToken; 47 | int tokenUserId = int.Parse(jwtToken.Subject); 48 | 49 | using (var db = databaseFactory.GetInstance()) 50 | { 51 | // check expiry/revocation against database 52 | var userId = await db.GetUserIdFromTokenAsync(jwtToken); 53 | 54 | if (userId != tokenUserId) 55 | { 56 | loggerFactory.CreateLogger("JsonWebToken").LogInformation("Token revoked or expired"); 57 | context.Fail("Token has expired or been revoked"); 58 | } 59 | } 60 | }, 61 | }; 62 | } 63 | 64 | public void Configure(string? name, JwtBearerOptions options) 65 | => Configure(options); 66 | 67 | /// 68 | /// borrowed from https://stackoverflow.com/a/54323524 69 | /// 70 | private static RSACryptoServiceProvider getKeyProvider() 71 | { 72 | string key = File.ReadAllText("oauth-public.key"); 73 | 74 | key = key.Replace("-----BEGIN PUBLIC KEY-----", ""); 75 | key = key.Replace("-----END PUBLIC KEY-----", ""); 76 | key = key.Replace("\n", ""); 77 | 78 | var keyBytes = Convert.FromBase64String(key); 79 | 80 | var asymmetricKeyParameter = PublicKeyFactory.CreateKey(keyBytes); 81 | var rsaKeyParameters = (RsaKeyParameters)asymmetricKeyParameter; 82 | var rsaParameters = new RSAParameters 83 | { 84 | Modulus = rsaKeyParameters.Modulus.ToByteArrayUnsigned(), 85 | Exponent = rsaKeyParameters.Exponent.ToByteArrayUnsigned() 86 | }; 87 | 88 | var rsa = new RSACryptoServiceProvider(); 89 | rsa.ImportParameters(rsaParameters); 90 | 91 | return rsa; 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /osu.Server.Spectator/ChatFilters.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Text.RegularExpressions; 8 | using System.Threading.Tasks; 9 | using osu.Game.Online.Multiplayer; 10 | using osu.Server.Spectator.Database; 11 | using osu.Server.Spectator.Database.Models; 12 | 13 | namespace osu.Server.Spectator 14 | { 15 | public class ChatFilters 16 | { 17 | private readonly IDatabaseFactory factory; 18 | 19 | private bool filtersInitialised; 20 | private Regex? blockRegex; 21 | 22 | private readonly List<(string match, string replacement)> nonWhitespaceDelimitedReplaces = new List<(string, string)>(); 23 | private readonly List<(Regex match, string replacement)> whitespaceDelimitedReplaces = new List<(Regex, string)>(); 24 | 25 | public ChatFilters(IDatabaseFactory factory) 26 | { 27 | this.factory = factory; 28 | } 29 | 30 | public async Task FilterAsync(string input) 31 | { 32 | if (!filtersInitialised) 33 | await initialiseFilters(); 34 | 35 | if (blockRegex?.Match(input).Success == true) 36 | throw new InvalidStateException("You can't say that."); 37 | 38 | // this is a touch inefficient due to string allocs, 39 | // but there's no way for `StringBuilder` to do case-insensitive replaces on strings 40 | // or any replaces on regexes at all... 41 | 42 | foreach (var filter in nonWhitespaceDelimitedReplaces) 43 | input = input.Replace(filter.match, filter.replacement, StringComparison.OrdinalIgnoreCase); 44 | 45 | foreach (var filter in whitespaceDelimitedReplaces) 46 | input = filter.match.Replace(input, filter.replacement); 47 | 48 | return input; 49 | } 50 | 51 | private async Task initialiseFilters() 52 | { 53 | using var db = factory.GetInstance(); 54 | var allFilters = await db.GetAllChatFiltersAsync(); 55 | 56 | var blockingFilters = allFilters.Where(f => f.block).ToArray(); 57 | if (blockingFilters.Length > 0) 58 | blockRegex = new Regex(string.Join('|', blockingFilters.Select(singleFilterRegex)), RegexOptions.Compiled | RegexOptions.IgnoreCase); 59 | 60 | foreach (var nonBlockingFilter in allFilters.Where(f => !f.block)) 61 | { 62 | if (nonBlockingFilter.whitespace_delimited) 63 | { 64 | whitespaceDelimitedReplaces.Add(( 65 | new Regex(singleFilterRegex(nonBlockingFilter), RegexOptions.Compiled | RegexOptions.IgnoreCase), 66 | nonBlockingFilter.replacement)); 67 | } 68 | else 69 | { 70 | nonWhitespaceDelimitedReplaces.Add((nonBlockingFilter.match, nonBlockingFilter.replacement)); 71 | } 72 | } 73 | 74 | filtersInitialised = true; 75 | } 76 | 77 | private static string singleFilterRegex(chat_filter filter) 78 | { 79 | string term = Regex.Escape(filter.match); 80 | if (filter.whitespace_delimited) 81 | term = $@"\b{term}\b"; 82 | return term; 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /osu.Server.Spectator/CodeAnalysis/BannedSymbols.txt: -------------------------------------------------------------------------------- 1 | T:osu.Framework.Logging.Logger;Don't use osu!framework logger. Use Microsoft.Extensions.Logging.ILogger instead through DI. 2 | T:System.Console;Don't use Console for logging. Use Microsoft.Extensions.Logging.ILogger instead through DI. 3 | -------------------------------------------------------------------------------- /osu.Server.Spectator/ConcurrentConnectionLimiter.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Threading.Tasks; 8 | using Microsoft.AspNetCore.SignalR; 9 | using Microsoft.Extensions.DependencyInjection; 10 | using Microsoft.Extensions.Logging; 11 | using osu.Framework.Extensions.TypeExtensions; 12 | using osu.Game.Online; 13 | using osu.Server.Spectator.Entities; 14 | using osu.Server.Spectator.Extensions; 15 | using osu.Server.Spectator.Hubs; 16 | 17 | namespace osu.Server.Spectator 18 | { 19 | public class ConcurrentConnectionLimiter : IHubFilter 20 | { 21 | private readonly EntityStore connectionStates; 22 | 23 | private readonly IServiceProvider serviceProvider; 24 | private readonly ILogger logger; 25 | 26 | private static readonly IEnumerable stateful_user_hubs 27 | = typeof(IStatefulUserHub).Assembly.GetTypes().Where(type => typeof(IStatefulUserHub).IsAssignableFrom(type) && typeof(Hub).IsAssignableFrom(type) && !type.IsInterface && !type.IsAbstract).ToArray(); 28 | 29 | public ConcurrentConnectionLimiter( 30 | EntityStore connectionStates, 31 | IServiceProvider serviceProvider, 32 | ILoggerFactory loggerFactory) 33 | { 34 | this.connectionStates = connectionStates; 35 | this.serviceProvider = serviceProvider; 36 | logger = loggerFactory.CreateLogger(nameof(ConcurrentConnectionLimiter)); 37 | } 38 | 39 | public async Task OnConnectedAsync(HubLifetimeContext context, Func next) 40 | { 41 | await registerConnection(context); 42 | await next(context); 43 | } 44 | 45 | private async Task registerConnection(HubLifetimeContext context) 46 | { 47 | int userId = context.Context.GetUserId(); 48 | 49 | using (var userState = await connectionStates.GetForUse(userId, true)) 50 | { 51 | if (userState.Item == null) 52 | { 53 | log(context, "connection from first client instance"); 54 | userState.Item = new ConnectionState(context); 55 | return; 56 | } 57 | 58 | if (userState.Item.IsConnectionFromSameClient(context)) 59 | { 60 | // The assumption is that the client has already dropped the old connection, 61 | // so we don't bother to ask for a disconnection. 62 | 63 | log(context, "subsequent connection from same client instance, registering"); 64 | // Importantly, this will replace the old connection, ensuring it cannot be 65 | // used to communicate on anymore. 66 | userState.Item.RegisterConnectionId(context); 67 | return; 68 | } 69 | 70 | log(context, "connection from new client instance, dropping existing state"); 71 | 72 | foreach (var hubType in stateful_user_hubs) 73 | { 74 | var hubContextType = typeof(IHubContext<>).MakeGenericType(hubType); 75 | var hubContext = serviceProvider.GetRequiredService(hubContextType) as IHubContext; 76 | 77 | if (userState.Item.ConnectionIds.TryGetValue(hubType, out string? connectionId)) 78 | { 79 | hubContext?.Clients.Client(connectionId) 80 | .SendCoreAsync(nameof(IStatefulUserHubClient.DisconnectRequested), Array.Empty()); 81 | } 82 | } 83 | 84 | log(context, "existing state dropped"); 85 | userState.Item = new ConnectionState(context); 86 | } 87 | } 88 | 89 | private void log(HubLifetimeContext context, string message) 90 | => logger.LogInformation("[user:{user}] [connection:{connection}] [hub:{hub}] {message}", 91 | context.Context.GetUserId(), 92 | context.Context.ConnectionId, 93 | context.Hub.GetType().ReadableName(), 94 | message); 95 | 96 | public async ValueTask InvokeMethodAsync(HubInvocationContext invocationContext, Func> next) 97 | { 98 | int userId = invocationContext.Context.GetUserId(); 99 | 100 | using (var userState = await connectionStates.GetForUse(userId)) 101 | { 102 | if (userState.Item?.ExistingConnectionMatches(invocationContext) != true) 103 | throw new InvalidOperationException($"State is not valid for this connection, context: {LoggingHubFilter.GetMethodCallDisplayString(invocationContext)})"); 104 | } 105 | 106 | return await next(invocationContext); 107 | } 108 | 109 | public async Task OnDisconnectedAsync(HubLifetimeContext context, Exception? exception, Func next) 110 | { 111 | // if `exception` isn't null then the disconnection is not clean, 112 | // so don't unregister yet in hopes that the user will return after a transient network failure or similar. 113 | if (exception == null) 114 | await unregisterConnection(context, exception); 115 | await next(context, exception); 116 | } 117 | 118 | private async Task unregisterConnection(HubLifetimeContext context, Exception? exception) 119 | { 120 | int userId = context.Context.GetUserId(); 121 | 122 | using (var userState = await connectionStates.GetForUse(userId, true)) 123 | { 124 | if (userState.Item?.ExistingConnectionMatches(context) == true) 125 | { 126 | log(context, "disconnected from hub"); 127 | userState.Item!.ConnectionIds.Remove(context.Hub.GetType()); 128 | } 129 | 130 | if (userState.Item?.ConnectionIds.Count == 0) 131 | { 132 | log(context, "all connections closed, destroying state"); 133 | userState.Destroy(); 134 | } 135 | } 136 | } 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/DapperExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Data; 6 | using System.Threading; 7 | using Dapper; 8 | 9 | namespace osu.Server.Spectator.Database 10 | { 11 | public static class DapperExtensions 12 | { 13 | // see https://stackoverflow.com/questions/12510299/get-datetime-as-utc-with-dapper 14 | public class DateTimeOffsetTypeHandler : SqlMapper.TypeHandler 15 | { 16 | public override void SetValue(IDbDataParameter parameter, DateTimeOffset value) 17 | { 18 | switch (parameter.DbType) 19 | { 20 | case DbType.DateTime: 21 | case DbType.DateTime2: 22 | case DbType.AnsiString: // Seems to be some MySQL type mapping here 23 | parameter.Value = value.UtcDateTime; 24 | break; 25 | 26 | case DbType.DateTimeOffset: 27 | parameter.Value = value; 28 | break; 29 | 30 | default: 31 | throw new InvalidOperationException("DateTimeOffset must be assigned to a DbType.DateTime SQL field."); 32 | } 33 | } 34 | 35 | public override DateTimeOffset Parse(object value) 36 | { 37 | switch (value) 38 | { 39 | case DateTime time: 40 | return new DateTimeOffset(DateTime.SpecifyKind(time, DateTimeKind.Utc), TimeSpan.Zero); 41 | 42 | case DateTimeOffset dto: 43 | return dto; 44 | 45 | default: 46 | throw new InvalidOperationException("Must be DateTime or DateTimeOffset object to be mapped."); 47 | } 48 | } 49 | } 50 | 51 | private static int dateTimeOffsetMapperInstalled; 52 | 53 | public static void InstallDateTimeOffsetMapper() 54 | { 55 | // Assumes SqlMapper.ResetTypeHandlers() is never called. 56 | if (Interlocked.CompareExchange(ref dateTimeOffsetMapperInstalled, 1, 0) == 0) 57 | { 58 | // First remove the default type map between typeof(DateTimeOffset) => DbType.DateTimeOffset (not valid for MySQL) 59 | SqlMapper.RemoveTypeMap(typeof(DateTimeOffset)); 60 | SqlMapper.RemoveTypeMap(typeof(DateTimeOffset?)); 61 | 62 | // This handles nullable value types automatically e.g. DateTimeOffset? 63 | SqlMapper.AddTypeHandler(typeof(DateTimeOffset), new DateTimeOffsetTypeHandler()); 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/DatabaseFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using Microsoft.Extensions.Logging; 5 | 6 | namespace osu.Server.Spectator.Database 7 | { 8 | public class DatabaseFactory : IDatabaseFactory 9 | { 10 | private readonly ILoggerFactory loggerFactory; 11 | 12 | public DatabaseFactory(ILoggerFactory loggerFactory) 13 | { 14 | this.loggerFactory = loggerFactory; 15 | } 16 | 17 | public IDatabaseAccess GetInstance() => new DatabaseAccess(loggerFactory); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/IDatabaseFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | namespace osu.Server.Spectator.Database 5 | { 6 | public interface IDatabaseFactory 7 | { 8 | IDatabaseAccess GetInstance(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/MatchStartedEventDetail.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using Newtonsoft.Json; 7 | using Newtonsoft.Json.Converters; 8 | 9 | namespace osu.Server.Spectator.Database.Models 10 | { 11 | // ReSharper disable InconsistentNaming 12 | 13 | [Serializable] 14 | public class MatchStartedEventDetail 15 | { 16 | [JsonProperty("room_type")] 17 | [JsonConverter(typeof(StringEnumConverter))] 18 | public database_match_type room_type { get; set; } 19 | 20 | [JsonProperty("teams")] 21 | public Dictionary? teams { get; set; } 22 | } 23 | 24 | [JsonConverter(typeof(StringEnumConverter))] 25 | public enum room_team 26 | { 27 | blue = 1, 28 | red = 2, 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/SoloScore.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.ComponentModel.DataAnnotations.Schema; 6 | using System.Diagnostics.CodeAnalysis; 7 | using Newtonsoft.Json; 8 | using osu.Game.Online.API.Requests.Responses; 9 | using osu.Game.Scoring; 10 | 11 | namespace osu.Server.Spectator.Database.Models 12 | { 13 | [SuppressMessage("ReSharper", "InconsistentNaming")] 14 | [Serializable] 15 | [Table("scores")] 16 | public class SoloScore 17 | { 18 | public ulong id { get; set; } 19 | 20 | public uint user_id { get; set; } 21 | 22 | public uint beatmap_id { get; set; } 23 | 24 | public ushort ruleset_id { get; set; } 25 | 26 | public bool has_replay { get; set; } 27 | public bool preserve { get; set; } 28 | public bool ranked { get; set; } = true; 29 | 30 | public ScoreRank rank { get; set; } 31 | 32 | public bool passed { get; set; } = true; 33 | 34 | public float accuracy { get; set; } 35 | 36 | public uint max_combo { get; set; } 37 | 38 | public uint total_score { get; set; } 39 | 40 | public SoloScoreData ScoreData = new SoloScoreData(); 41 | 42 | public string data 43 | { 44 | get => JsonConvert.SerializeObject(ScoreData); 45 | set 46 | { 47 | var soloScoreData = JsonConvert.DeserializeObject(value); 48 | if (soloScoreData != null) 49 | ScoreData = soloScoreData; 50 | } 51 | } 52 | 53 | public double? pp { get; set; } 54 | 55 | public ulong? legacy_score_id { get; set; } 56 | public uint? legacy_total_score { get; set; } 57 | 58 | public DateTimeOffset? started_at { get; set; } 59 | public DateTimeOffset ended_at { get; set; } 60 | 61 | public override string ToString() => $"score_id: {id} user_id: {user_id}"; 62 | 63 | public ushort? build_id { get; set; } 64 | 65 | public SoloScoreInfo ToScoreInfo() => new SoloScoreInfo 66 | { 67 | BeatmapID = (int)beatmap_id, 68 | RulesetID = ruleset_id, 69 | BuildID = build_id, 70 | Passed = passed, 71 | TotalScore = total_score, 72 | Accuracy = accuracy, 73 | UserID = (int)user_id, 74 | MaxCombo = (int)max_combo, 75 | Rank = rank, 76 | StartedAt = started_at, 77 | EndedAt = ended_at, 78 | Mods = ScoreData.Mods, 79 | Statistics = ScoreData.Statistics, 80 | MaximumStatistics = ScoreData.MaximumStatistics, 81 | LegacyTotalScore = (int?)legacy_total_score, 82 | LegacyScoreId = legacy_score_id, 83 | ID = id, 84 | PP = pp, 85 | HasReplay = has_replay 86 | }; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/SoloScoreData.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using Newtonsoft.Json; 7 | using osu.Game.Online.API; 8 | using osu.Game.Rulesets.Scoring; 9 | 10 | namespace osu.Server.Spectator.Database.Models 11 | { 12 | [Serializable] 13 | public class SoloScoreData 14 | { 15 | [JsonProperty("mods")] 16 | public APIMod[] Mods { get; set; } = Array.Empty(); 17 | 18 | [JsonProperty("statistics")] 19 | public Dictionary Statistics { get; set; } = new Dictionary(); 20 | 21 | [JsonProperty("maximum_statistics")] 22 | public Dictionary MaximumStatistics { get; set; } = new Dictionary(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/bss_process_queue_item.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | 6 | // ReSharper disable InconsistentNaming (matches database table) 7 | 8 | namespace osu.Server.Spectator.Database.Models 9 | { 10 | [Serializable] 11 | public class bss_process_queue_item 12 | { 13 | public int queue_id; 14 | public int beatmapset_id; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/chat_filter.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | 6 | // ReSharper disable InconsistentNaming (matches database table) 7 | 8 | namespace osu.Server.Spectator.Database.Models 9 | { 10 | [Serializable] 11 | public class chat_filter 12 | { 13 | public long id { get; set; } 14 | public string match { get; set; } = string.Empty; 15 | public string replacement { get; set; } = string.Empty; 16 | public bool block { get; set; } 17 | public bool whitespace_delimited { get; set; } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/database_beatmap.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | // ReSharper disable InconsistentNaming (matches database table) 5 | 6 | using System; 7 | using osu.Game.Beatmaps; 8 | 9 | namespace osu.Server.Spectator.Database.Models 10 | { 11 | [Serializable] 12 | public class database_beatmap 13 | { 14 | public int beatmap_id { get; set; } 15 | public int beatmapset_id { get; set; } 16 | public string? checksum { get; set; } 17 | public BeatmapOnlineStatus approved { get; set; } 18 | public double difficultyrating { get; set; } 19 | public ushort playmode { get; set; } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/database_match_type.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using osu.Game.Online.Rooms; 6 | 7 | // ReSharper disable InconsistentNaming (matches database table) 8 | 9 | namespace osu.Server.Spectator.Database.Models 10 | { 11 | // ReSharper disable once InconsistentNaming 12 | [Serializable] 13 | public enum database_match_type 14 | { 15 | playlists, 16 | head_to_head, 17 | team_versus, 18 | } 19 | 20 | public static class DatabaseMatchTypeExtensions 21 | { 22 | public static MatchType ToMatchType(this database_match_type type) 23 | { 24 | switch (type) 25 | { 26 | case database_match_type.playlists: 27 | return MatchType.Playlists; 28 | 29 | case database_match_type.head_to_head: 30 | return MatchType.HeadToHead; 31 | 32 | case database_match_type.team_versus: 33 | return MatchType.TeamVersus; 34 | 35 | default: 36 | throw new ArgumentOutOfRangeException(nameof(type)); 37 | } 38 | } 39 | 40 | public static database_match_type ToDatabaseMatchType(this MatchType type) 41 | { 42 | switch (type) 43 | { 44 | case MatchType.Playlists: 45 | return database_match_type.playlists; 46 | 47 | case MatchType.HeadToHead: 48 | return database_match_type.head_to_head; 49 | 50 | case MatchType.TeamVersus: 51 | return database_match_type.team_versus; 52 | 53 | default: 54 | throw new ArgumentOutOfRangeException(nameof(type)); 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/database_queue_mode.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using osu.Game.Online.Multiplayer; 6 | 7 | namespace osu.Server.Spectator.Database.Models 8 | { 9 | // ReSharper disable once InconsistentNaming 10 | [Serializable] 11 | public enum database_queue_mode 12 | { 13 | host_only, 14 | all_players, 15 | all_players_round_robin 16 | } 17 | 18 | public static class DatabaseQueueModeExtensions 19 | { 20 | public static QueueMode ToQueueMode(this database_queue_mode mode) 21 | { 22 | switch (mode) 23 | { 24 | case database_queue_mode.host_only: 25 | return QueueMode.HostOnly; 26 | 27 | case database_queue_mode.all_players: 28 | return QueueMode.AllPlayers; 29 | 30 | case database_queue_mode.all_players_round_robin: 31 | return QueueMode.AllPlayersRoundRobin; 32 | 33 | default: 34 | throw new ArgumentOutOfRangeException(nameof(mode)); 35 | } 36 | } 37 | 38 | public static database_queue_mode ToDatabaseQueueMode(this QueueMode mode) 39 | { 40 | switch (mode) 41 | { 42 | case QueueMode.HostOnly: 43 | return database_queue_mode.host_only; 44 | 45 | case QueueMode.AllPlayers: 46 | return database_queue_mode.all_players; 47 | 48 | case QueueMode.AllPlayersRoundRobin: 49 | return database_queue_mode.all_players_round_robin; 50 | 51 | default: 52 | throw new ArgumentOutOfRangeException(nameof(mode)); 53 | } 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/database_room_status.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using osu.Game.Online.Multiplayer; 6 | 7 | namespace osu.Server.Spectator.Database.Models 8 | { 9 | // ReSharper disable once InconsistentNaming 10 | [Serializable] 11 | public enum database_room_status 12 | { 13 | idle, 14 | playing 15 | } 16 | 17 | public static class DatabaseRoomStatusExtensions 18 | { 19 | public static database_room_status ToDatabaseRoomStatus(this MultiplayerRoomState state) 20 | { 21 | switch (state) 22 | { 23 | case MultiplayerRoomState.Open: 24 | case MultiplayerRoomState.Closed: 25 | return database_room_status.idle; 26 | 27 | case MultiplayerRoomState.WaitingForLoad: 28 | case MultiplayerRoomState.Playing: 29 | return database_room_status.playing; 30 | 31 | default: 32 | throw new ArgumentOutOfRangeException(nameof(state), state, null); 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/multiplayer_playlist_item.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Threading.Tasks; 6 | using Newtonsoft.Json; 7 | using osu.Game.Online.API; 8 | using osu.Game.Online.Rooms; 9 | 10 | // ReSharper disable InconsistentNaming (matches database table) 11 | 12 | namespace osu.Server.Spectator.Database.Models 13 | { 14 | [Serializable] 15 | public class multiplayer_playlist_item 16 | { 17 | public long id { get; set; } 18 | public int owner_id { get; set; } 19 | public long room_id { get; set; } 20 | public int beatmap_id { get; set; } 21 | public short ruleset_id { get; set; } 22 | public ushort? playlist_order { get; set; } 23 | public string? allowed_mods { get; set; } 24 | public string? required_mods { get; set; } 25 | public bool freestyle { get; set; } 26 | 27 | /// 28 | /// Changes to this property will not be persisted to the database. 29 | /// 30 | public DateTimeOffset? created_at { get; set; } 31 | 32 | /// 33 | /// Changes to this property will not be persisted to the database. 34 | /// 35 | public DateTimeOffset? updated_at { get; set; } 36 | 37 | public bool expired { get; set; } 38 | 39 | /// 40 | /// Changes to this property will not be persisted to the database. 41 | /// 42 | public DateTimeOffset? played_at { get; set; } 43 | 44 | // for deserialization 45 | public multiplayer_playlist_item() 46 | { 47 | } 48 | 49 | /// 50 | /// Creates a playlist item model from an for the given room ID. 51 | /// 52 | /// The room ID to create the playlist item model for. 53 | /// The to retrieve data from. 54 | public multiplayer_playlist_item(long roomId, MultiplayerPlaylistItem item) 55 | { 56 | id = item.ID; 57 | owner_id = item.OwnerID; 58 | room_id = roomId; 59 | beatmap_id = item.BeatmapID; 60 | ruleset_id = (short)item.RulesetID; 61 | required_mods = JsonConvert.SerializeObject(item.RequiredMods); 62 | allowed_mods = JsonConvert.SerializeObject(item.AllowedMods); 63 | freestyle = item.Freestyle; 64 | updated_at = DateTimeOffset.Now; 65 | expired = item.Expired; 66 | playlist_order = item.PlaylistOrder; 67 | played_at = item.PlayedAt; 68 | } 69 | 70 | public async Task ToMultiplayerPlaylistItem(IDatabaseAccess db) 71 | { 72 | var beatmap = await db.GetBeatmapAsync(beatmap_id); 73 | var playlistItem = new MultiplayerPlaylistItem 74 | { 75 | ID = id, 76 | OwnerID = owner_id, 77 | BeatmapID = beatmap_id, 78 | BeatmapChecksum = beatmap?.checksum ?? string.Empty, 79 | RulesetID = ruleset_id, 80 | RequiredMods = JsonConvert.DeserializeObject(required_mods ?? string.Empty) ?? Array.Empty(), 81 | AllowedMods = JsonConvert.DeserializeObject(allowed_mods ?? string.Empty) ?? Array.Empty(), 82 | Freestyle = freestyle, 83 | Expired = expired, 84 | PlaylistOrder = playlist_order ?? 0, 85 | PlayedAt = played_at, 86 | StarRating = beatmap?.difficultyrating ?? 0.0 87 | }; 88 | return playlistItem; 89 | } 90 | 91 | public multiplayer_playlist_item Clone() => (multiplayer_playlist_item)MemberwiseClone(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/multiplayer_realtime_room_event.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | 6 | namespace osu.Server.Spectator.Database.Models 7 | { 8 | // ReSharper disable InconsistentNaming 9 | 10 | public class multiplayer_realtime_room_event 11 | { 12 | public long id { get; set; } 13 | public long room_id { get; set; } 14 | public string event_type { get; set; } = string.Empty; 15 | public long? playlist_item_id { get; set; } 16 | public int? user_id { get; set; } 17 | public DateTimeOffset created_at { get; set; } 18 | public DateTimeOffset updated_at { get; set; } 19 | public string? event_detail { get; set; } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/multiplayer_room.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | 6 | // ReSharper disable InconsistentNaming (matches database table) 7 | 8 | namespace osu.Server.Spectator.Database.Models 9 | { 10 | [Serializable] 11 | public class multiplayer_room 12 | { 13 | public long id { get; set; } 14 | public int user_id { get; set; } 15 | public string name { get; set; } = string.Empty; 16 | public string password { get; set; } = string.Empty; 17 | public int channel_id { get; set; } 18 | public DateTimeOffset starts_at { get; set; } 19 | public DateTimeOffset? ends_at { get; set; } 20 | public byte max_attempts { get; set; } 21 | public int participant_count { get; set; } 22 | public DateTimeOffset? created_at { get; set; } 23 | public DateTimeOffset? updated_at { get; set; } 24 | public DateTimeOffset? deleted_at { get; set; } 25 | public room_category category { get; set; } 26 | public database_room_status status { get; set; } 27 | public database_match_type type { get; set; } 28 | public database_queue_mode queue_mode { get; set; } 29 | public ushort auto_start_duration { get; set; } 30 | public bool auto_skip { get; set; } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/multiplayer_scores_high.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | 6 | namespace osu.Server.Spectator.Database.Models 7 | { 8 | // ReSharper disable InconsistentNaming 9 | [Serializable] 10 | public class multiplayer_scores_high 11 | { 12 | public ulong id { get; set; } 13 | public ulong? score_id { get; set; } 14 | public uint user_id { get; set; } 15 | public ulong playlist_item_id { get; set; } 16 | public uint total_score { get; set; } 17 | public float accuracy { get; set; } 18 | public float? pp { get; set; } 19 | public uint attempts { get; set; } 20 | public DateTimeOffset created_at { get; set; } 21 | public DateTimeOffset updated_at { get; set; } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/osu_build.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | 6 | // ReSharper disable InconsistentNaming 7 | 8 | namespace osu.Server.Spectator.Database.Models 9 | { 10 | [Serializable] 11 | public class osu_build 12 | { 13 | public uint build_id { get; set; } 14 | public string? version { get; set; } 15 | public byte[]? hash { get; set; } 16 | public uint users { get; set; } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/phpbb_zebra.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | 6 | // ReSharper disable InconsistentNaming (matches database table) 7 | 8 | namespace osu.Server.Spectator.Database.Models 9 | { 10 | [Serializable] 11 | public class phpbb_zebra 12 | { 13 | public int user_id { get; set; } 14 | public int zebra_id { get; set; } 15 | public bool friend { get; set; } 16 | public bool foe { get; set; } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Database/Models/room_category.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | 6 | namespace osu.Server.Spectator.Database.Models 7 | { 8 | // ReSharper disable once InconsistentNaming 9 | [Serializable] 10 | public enum room_category 11 | { 12 | normal, 13 | spotlights, 14 | featured_artist, 15 | daily_challenge, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build-env 2 | WORKDIR /app 3 | 4 | # Copy csproj and restore as distinct layers 5 | COPY *.csproj ./ 6 | 7 | RUN dotnet restore 8 | 9 | # Copy everything else and build 10 | COPY . ./ 11 | RUN dotnet publish -c Release -o out 12 | # get rid of bloat 13 | RUN rm -rf ./out/runtimes ./out/osu.Game.Resources.dll 14 | 15 | # Build runtime image 16 | FROM mcr.microsoft.com/dotnet/aspnet:8.0 17 | WORKDIR /app 18 | COPY --from=build-env /app/out . 19 | ENTRYPOINT ["dotnet", "osu.Server.Spectator.dll"] 20 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Entities/ConnectionState.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using Microsoft.AspNetCore.SignalR; 7 | using osu.Game.Online; 8 | using osu.Server.Spectator.Extensions; 9 | 10 | #pragma warning disable CS0618 // Type or member is obsolete 11 | 12 | namespace osu.Server.Spectator.Entities 13 | { 14 | /// 15 | /// Maintains the connection state of a single client (notably, client, not user) across multiple hubs. 16 | /// 17 | public class ConnectionState 18 | { 19 | /// 20 | /// A client-side generated GUID identifying the client instance connecting to this server. 21 | /// This is used to control user uniqueness. 22 | /// 23 | public readonly Guid? ClientSessionId; 24 | 25 | /// 26 | /// The unique ID of the JWT the user is using to authenticate. 27 | /// 28 | /// 29 | /// This was previously used as a method of controlling user uniqueness / limiting concurrency, 30 | /// but it turned out to be a bad fit for the purpose (see https://github.com/ppy/osu/issues/26338#issuecomment-2222935517). 31 | /// 32 | [Obsolete("Use ClientSessionId instead.")] // Can be removed 2024-08-18 33 | public readonly string TokenId; 34 | 35 | /// 36 | /// The connection IDs of the user for each hub type. 37 | /// 38 | /// 39 | /// In SignalR, connection IDs are unique per connection. 40 | /// Because we use multiple hubs and a user is expected to be connected to each hub individually, 41 | /// we use a dictionary to track connections across all hubs for a specific user. 42 | /// 43 | public readonly Dictionary ConnectionIds = new Dictionary(); 44 | 45 | public ConnectionState(HubLifetimeContext context) 46 | { 47 | TokenId = context.Context.GetTokenId(); 48 | 49 | if (tryGetClientSessionID(context, out var clientSessionId)) 50 | ClientSessionId = clientSessionId; 51 | 52 | RegisterConnectionId(context); 53 | } 54 | 55 | /// 56 | /// Registers the provided hub/connection context, replacing any existing connection for the hub type. 57 | /// 58 | /// The hub context to retrieve information from. 59 | public void RegisterConnectionId(HubLifetimeContext context) 60 | => ConnectionIds[context.Hub.GetType()] = context.Context.ConnectionId; 61 | 62 | public bool IsConnectionFromSameClient(HubLifetimeContext context) 63 | { 64 | if (tryGetClientSessionID(context, out var clientSessionId)) 65 | return ClientSessionId == clientSessionId; 66 | 67 | // Legacy pathway using JTI claim left for compatibility with older clients – can be removed 2024-08-18 68 | return TokenId == context.Context.GetTokenId(); 69 | } 70 | 71 | public bool ExistingConnectionMatches(HubInvocationContext context) 72 | { 73 | bool hubRegistered = ConnectionIds.TryGetValue(context.Hub.GetType(), out string? registeredConnectionId); 74 | bool connectionIdMatches = registeredConnectionId == context.Context.ConnectionId; 75 | 76 | return hubRegistered && connectionIdMatches; 77 | } 78 | 79 | public bool ExistingConnectionMatches(HubLifetimeContext context) 80 | { 81 | bool hubRegistered = ConnectionIds.TryGetValue(context.Hub.GetType(), out string? registeredConnectionId); 82 | bool connectionIdMatches = registeredConnectionId == context.Context.ConnectionId; 83 | 84 | return hubRegistered && connectionIdMatches; 85 | } 86 | 87 | private static bool tryGetClientSessionID(HubLifetimeContext context, out Guid clientSessionId) 88 | { 89 | clientSessionId = Guid.Empty; 90 | return context.Context.GetHttpContext()?.Request.Headers.TryGetValue(HubClientConnector.CLIENT_SESSION_ID_HEADER, out var value) == true 91 | && Guid.TryParse(value, out clientSessionId); 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Entities/IEntityStore.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | namespace osu.Server.Spectator.Entities 5 | { 6 | public interface IEntityStore 7 | { 8 | /// 9 | /// Number of entities remaining in use. 10 | /// 11 | int RemainingUsages { get; } 12 | 13 | /// 14 | /// A display name for the managed entity. 15 | /// 16 | string EntityName { get; } 17 | 18 | /// 19 | /// Inform this entity store that a server shutdown transition is in progress, and new entities should not be allowed. 20 | /// 21 | void StopAcceptingEntities(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Entities/ItemUsage.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using osu.Framework.Allocation; 5 | 6 | namespace osu.Server.Spectator.Entities 7 | { 8 | /// 9 | /// A usage of an item, returned after ensuring locked control. 10 | /// Should be disposed after usage. If is null at the point of disposal, the state will automatically be destroyed and no longer be tracked. 11 | /// 12 | public class ItemUsage : InvokeOnDisposal.TrackedEntity> 13 | where T : class 14 | { 15 | private readonly EntityStore.TrackedEntity entity; 16 | 17 | public T? Item 18 | { 19 | get => entity.Item; 20 | set => entity.Item = value; 21 | } 22 | 23 | public ItemUsage(in EntityStore.TrackedEntity entity) 24 | : base(entity, returnLock) 25 | { 26 | this.entity = entity; 27 | } 28 | 29 | /// 30 | /// Mark this item as no longer used. Will remove any tracking overhead. 31 | /// 32 | public void Destroy() 33 | { 34 | Item = null; 35 | entity.Destroy(); 36 | } 37 | 38 | private static void returnLock(EntityStore.TrackedEntity entity) 39 | { 40 | if (!entity.IsDestroyed && entity.Item == null) 41 | entity.Destroy(); 42 | 43 | entity.ReleaseLock(); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Extensions/EntityStoreExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using osu.Server.Spectator.Entities; 5 | using osu.Server.Spectator.Hubs; 6 | 7 | namespace osu.Server.Spectator.Extensions 8 | { 9 | public static class EntityStoreExtensions 10 | { 11 | /// 12 | /// Retrieves the connection ID for a user from an , bypassing locking. 13 | /// 14 | /// 15 | /// May be used while nested in a locked entity usage (via ). 16 | /// 17 | /// The user entity store. 18 | /// The user ID. 19 | /// The user state. 20 | /// The connection ID for the user matching the given . A non-null return does not mean that the user hasn't been disconnected. 21 | public static string? GetConnectionIdForUser(this EntityStore store, long id) 22 | where TUserState : ClientState 23 | { 24 | return store.GetEntityUnsafe(id)?.ConnectionId; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Extensions/EnumerableExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | 7 | namespace osu.Server.Spectator.Extensions 8 | { 9 | public static class EnumerableExtensions 10 | { 11 | /// 12 | /// Interleaves sequential elements from one or more collections, returning groups of interleaved elements. 13 | /// 14 | /// 15 | /// Runtime complexity is O(n * m) where n is the given number of collections 16 | /// and m is the maximum number of elements in of any collection. 17 | /// 18 | /// The collections to interleave. 19 | /// The type of element in each collection. 20 | /// 21 | /// For the collections: 22 | /// 23 | /// A = [A1, A2, A3, A4] 24 | /// B = [B1, B2] 25 | /// C = [C1, C2, C3] 26 | /// 27 | /// This returns the groups of elements [A1, B1, C1], [A2, B2, C2], [A3, C3], [A4]. 28 | /// 29 | public static IEnumerable> Interleave(this IEnumerable> collections) 30 | { 31 | var enumerators = new List>(); 32 | 33 | try 34 | { 35 | foreach (var c in collections) 36 | enumerators.Add(c.GetEnumerator()); 37 | 38 | while (true) 39 | { 40 | T[] interleaved = enumerators.Where(it => it.MoveNext()) 41 | .Select(it => it.Current) 42 | .ToArray(); // The enumerators must be consumed immediately due to lazy evaluation. 43 | 44 | if (interleaved.Length == 0) 45 | break; 46 | 47 | yield return interleaved; 48 | } 49 | } 50 | finally 51 | { 52 | foreach (var it in enumerators) 53 | it.Dispose(); 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Extensions/HubCallerContextExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using Microsoft.AspNetCore.SignalR; 6 | 7 | namespace osu.Server.Spectator.Extensions 8 | { 9 | public static class HubCallerContextExtensions 10 | { 11 | /// 12 | /// Returns the osu! user id for the supplied . 13 | /// 14 | public static int GetUserId(this HubCallerContext context) 15 | { 16 | if (context.UserIdentifier == null) 17 | throw new InvalidOperationException($"Attempted to get user id with null {nameof(context.UserIdentifier)}"); 18 | 19 | return int.Parse(context.UserIdentifier); 20 | } 21 | 22 | /// 23 | /// Returns the ID of the authorisation token (more accurately, the jti claim) 24 | /// for the supplied . 25 | /// This is used for the purpose of identifying individual client instances 26 | /// and preventing multiple concurrent sessions from being active. 27 | /// 28 | public static string GetTokenId(this HubCallerContext context) 29 | { 30 | return context.User?.FindFirst(claim => claim.Type == "jti")?.Value 31 | ?? throw new InvalidOperationException("Could not retrieve JWT ID claim from token"); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Extensions/MultiplayerPlaylistItemExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Collections.Generic; 5 | using System.Diagnostics.CodeAnalysis; 6 | using System.Linq; 7 | using osu.Game.Online.API; 8 | using osu.Game.Online.Multiplayer; 9 | using osu.Game.Online.Rooms; 10 | using osu.Game.Utils; 11 | 12 | namespace osu.Server.Spectator.Extensions 13 | { 14 | public static class MultiplayerPlaylistItemExtensions 15 | { 16 | /// 17 | /// Checks whether the given mods are compatible with the current playlist item's mods and ruleset. 18 | /// 19 | /// The to validate the user mods against. 20 | /// The to validate the mods of. 21 | /// The proposed user mods to check against the . 22 | /// The set of mods which _are_ valid. 23 | /// Whether all user mods are valid for the . 24 | public static bool ValidateUserMods(this MultiplayerPlaylistItem item, MultiplayerRoomUser user, IEnumerable proposedMods, [NotNullWhen(false)] out IEnumerable? validMods) 25 | { 26 | var ruleset = LegacyHelper.GetRulesetFromLegacyID(user.RulesetId ?? item.RulesetID); 27 | 28 | bool proposedWereValid = true; 29 | proposedWereValid &= ModUtils.InstantiateValidModsForRuleset(ruleset, proposedMods, out var valid); 30 | 31 | // Freestyle unconditionally allows all freemods. 32 | if (!item.Freestyle) 33 | { 34 | // check allowed by room 35 | foreach (var mod in valid.ToList()) 36 | { 37 | if (item.AllowedMods.All(m => m.Acronym != mod.Acronym)) 38 | { 39 | valid.Remove(mod); 40 | proposedWereValid = false; 41 | } 42 | } 43 | } 44 | 45 | // check valid as combination 46 | if (!ModUtils.CheckCompatibleSet(item.RequiredMods.Select(m => m.ToMod(ruleset)).Concat(valid), out var invalid)) 47 | { 48 | proposedWereValid = false; 49 | foreach (var mod in invalid) 50 | valid.Remove(mod); 51 | } 52 | 53 | validMods = valid.Select(m => new APIMod(m)); 54 | 55 | return proposedWereValid; 56 | } 57 | 58 | /// 59 | /// Ensures that a 's required and allowed mods are compatible with each other and the room's ruleset. 60 | /// 61 | /// If the mods are invalid. 62 | public static void EnsureModsValid(this MultiplayerPlaylistItem item) 63 | { 64 | var ruleset = LegacyHelper.GetRulesetFromLegacyID(item.RulesetID); 65 | 66 | // check against ruleset 67 | if (!ModUtils.InstantiateValidModsForRuleset(ruleset, item.RequiredMods, out var requiredMods)) 68 | { 69 | var invalidRequiredAcronyms = string.Join(',', item.RequiredMods.Where(m => requiredMods.All(valid => valid.Acronym != m.Acronym)).Select(m => m.Acronym)); 70 | throw new InvalidStateException($"Invalid mods were selected for specified ruleset: {invalidRequiredAcronyms}"); 71 | } 72 | 73 | if (!ModUtils.InstantiateValidModsForRuleset(ruleset, item.AllowedMods, out var allowedMods)) 74 | { 75 | var invalidAllowedAcronyms = string.Join(',', item.AllowedMods.Where(m => allowedMods.All(valid => valid.Acronym != m.Acronym)).Select(m => m.Acronym)); 76 | throw new InvalidStateException($"Invalid mods were selected for specified ruleset: {invalidAllowedAcronyms}"); 77 | } 78 | 79 | if (!ModUtils.CheckCompatibleSet(requiredMods, out var invalid)) 80 | throw new InvalidStateException($"Invalid combination of required mods: {string.Join(',', invalid.Select(m => m.Acronym))}"); 81 | 82 | if (!ModUtils.CheckValidRequiredModsForMultiplayer(requiredMods, item.Freestyle, out invalid)) 83 | throw new InvalidStateException($"Invalid required mods were selected: {string.Join(',', invalid.Select(m => m.Acronym))}"); 84 | 85 | if (!ModUtils.CheckValidAllowedModsForMultiplayer(allowedMods, item.Freestyle, out invalid)) 86 | throw new InvalidStateException($"Invalid free mods were selected: {string.Join(',', invalid.Select(m => m.Acronym))}"); 87 | 88 | // check aggregate combinations with each allowed mod individually. 89 | foreach (var allowedMod in allowedMods) 90 | { 91 | if (!ModUtils.CheckCompatibleSet(requiredMods.Concat(new[] { allowedMod }), out invalid)) 92 | throw new InvalidStateException($"Invalid combination of required and allowed mods: {string.Join(',', invalid.Select(m => m.Acronym))}"); 93 | } 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Extensions/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using Microsoft.Extensions.DependencyInjection; 5 | using osu.Server.Spectator.Database; 6 | using osu.Server.Spectator.Entities; 7 | using osu.Server.Spectator.Hubs; 8 | using osu.Server.Spectator.Hubs.Metadata; 9 | using osu.Server.Spectator.Hubs.Multiplayer; 10 | using osu.Server.Spectator.Hubs.Spectator; 11 | using osu.Server.Spectator.Services; 12 | using osu.Server.Spectator.Storage; 13 | using StackExchange.Redis; 14 | 15 | namespace osu.Server.Spectator.Extensions 16 | { 17 | public static class ServiceCollectionExtensions 18 | { 19 | public static IServiceCollection AddHubEntities(this IServiceCollection serviceCollection) 20 | { 21 | return serviceCollection.AddHttpClient() 22 | .AddSingleton() 23 | .AddSingleton>() 24 | .AddSingleton>() 25 | .AddSingleton>() 26 | .AddSingleton>() 27 | .AddSingleton>() 28 | .AddSingleton() 29 | .AddSingleton() 30 | .AddSingleton() 31 | .AddSingleton() 32 | .AddSingleton() 33 | .AddSingleton() 34 | .AddSingleton() 35 | .AddSingleton() 36 | .AddHostedService(ctx => ctx.GetRequiredService()) 37 | .AddSingleton(); 38 | } 39 | 40 | /// 41 | /// Adds MySQL () and Redis () services. 42 | /// 43 | public static IServiceCollection AddDatabaseServices(this IServiceCollection serviceCollection) 44 | { 45 | return serviceCollection.AddSingleton() 46 | .AddSingleton(_ => ConnectionMultiplexer.Connect(AppSettings.RedisHost)); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /osu.Server.Spectator/GracefulShutdownManager.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Linq; 7 | using System.Text; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using Microsoft.Extensions.Hosting; 11 | using Microsoft.Extensions.Logging; 12 | using osu.Game.Online.Multiplayer; 13 | using osu.Server.Spectator.Entities; 14 | using osu.Server.Spectator.Hubs; 15 | using osu.Server.Spectator.Hubs.Metadata; 16 | using osu.Server.Spectator.Hubs.Multiplayer; 17 | using osu.Server.Spectator.Hubs.Spectator; 18 | 19 | namespace osu.Server.Spectator 20 | { 21 | /// 22 | /// Ensures that shutdown is delayed until any existing usages have ceased. 23 | /// 24 | public class GracefulShutdownManager 25 | { 26 | // This should probably be configurable in the future. 27 | // 6 hours is way too long, but set initially to test the whole process out. 28 | // We can manually override this for immediate shutdown if/when required from a kubernetes or docker level. 29 | public static readonly TimeSpan TIME_BEFORE_FORCEFUL_SHUTDOWN = TimeSpan.FromHours(6); 30 | 31 | private readonly List dependentStores = new List(); 32 | private readonly EntityStore roomStore; 33 | private readonly BuildUserCountUpdater buildUserCountUpdater; 34 | private readonly ILogger logger; 35 | 36 | public GracefulShutdownManager( 37 | EntityStore roomStore, 38 | EntityStore clientStateStore, 39 | IHostApplicationLifetime hostApplicationLifetime, 40 | ScoreUploader scoreUploader, 41 | EntityStore connectionStateStore, 42 | EntityStore metadataClientStore, 43 | BuildUserCountUpdater buildUserCountUpdater, 44 | ILoggerFactory loggerFactory) 45 | { 46 | this.roomStore = roomStore; 47 | this.buildUserCountUpdater = buildUserCountUpdater; 48 | logger = loggerFactory.CreateLogger(nameof(GracefulShutdownManager)); 49 | 50 | dependentStores.Add(roomStore); 51 | dependentStores.Add(clientStateStore); 52 | dependentStores.Add(scoreUploader); 53 | dependentStores.Add(connectionStateStore); 54 | dependentStores.Add(metadataClientStore); 55 | 56 | // Importantly, we don't care to block `MultiplayerClientState` stores because they can only be created 57 | // if a `ServerMultiplayerRoom` is first in existence. 58 | // More so, we want to allow these states to be created so existing rooms can continue to function until they are disbanded. 59 | 60 | hostApplicationLifetime.ApplicationStopping.Register(shutdownSafely); 61 | } 62 | 63 | private void shutdownSafely() 64 | { 65 | logger.LogInformation("Server shutdown triggered"); 66 | 67 | // stop tracking user counts. 68 | // it is presumed that another instance will take over doing so. 69 | buildUserCountUpdater.Dispose(); 70 | 71 | foreach (var store in dependentStores) 72 | store.StopAcceptingEntities(); 73 | 74 | performOnAllRooms(async r => 75 | { 76 | await r.StartCountdown(new ServerShuttingDownCountdown 77 | { 78 | TimeRemaining = TIME_BEFORE_FORCEFUL_SHUTDOWN 79 | }); 80 | }).Wait(); 81 | 82 | TimeSpan timeWaited = new TimeSpan(); 83 | TimeSpan timeBetweenChecks = TimeSpan.FromSeconds(10); 84 | 85 | var stringBuilder = new StringBuilder(); 86 | 87 | while (timeWaited < TIME_BEFORE_FORCEFUL_SHUTDOWN) 88 | { 89 | var remaining = dependentStores.Select(store => (store.EntityName, store.RemainingUsages)); 90 | 91 | if (remaining.Sum(s => s.RemainingUsages) == 0) 92 | break; 93 | 94 | stringBuilder.Clear(); 95 | stringBuilder.AppendLine("Waiting for usages of existing entities to finish..."); 96 | foreach (var r in remaining) 97 | stringBuilder.AppendLine($"{r.EntityName,10}: {r.RemainingUsages}"); 98 | logger.LogInformation(stringBuilder.ToString()); 99 | 100 | Thread.Sleep(timeBetweenChecks); 101 | timeWaited = timeWaited.Add(timeBetweenChecks); 102 | } 103 | 104 | logger.LogInformation("All entities cleaned up. Server shutdown unblocking."); 105 | } 106 | 107 | private async Task performOnAllRooms(Func action) 108 | { 109 | var rooms = roomStore.GetAllEntities(); 110 | 111 | foreach (var roomId in rooms.Select(r => r.Key)) 112 | { 113 | using (ItemUsage roomUsage = await roomStore.GetForUse(roomId)) 114 | { 115 | if (roomUsage.Item != null) 116 | await action(roomUsage.Item); 117 | } 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/ClientState.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | 6 | namespace osu.Server.Spectator.Hubs 7 | { 8 | [Serializable] 9 | public class ClientState 10 | { 11 | /// 12 | /// The connection ID of the owner of this state. 13 | /// 14 | public readonly string ConnectionId; 15 | 16 | /// 17 | /// The user ID of the owner of this state. 18 | /// 19 | public readonly int UserId; 20 | 21 | public ClientState(in string connectionId, in int userId) 22 | { 23 | UserId = userId; 24 | ConnectionId = connectionId; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/ILogTarget.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using Microsoft.Extensions.Logging; 6 | 7 | namespace osu.Server.Spectator.Hubs 8 | { 9 | internal interface ILogTarget 10 | { 11 | void Log(string message, LogLevel logLevel = LogLevel.Information); 12 | 13 | void Error(string message, Exception exception); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/IStatefulUserHub.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | namespace osu.Server.Spectator.Hubs 5 | { 6 | /// 7 | /// Marker interface for . 8 | /// Allows bypassing generic constraints. 9 | /// 10 | public interface IStatefulUserHub 11 | { 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/LoggingHub.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Microsoft.AspNetCore.SignalR; 8 | using Microsoft.Extensions.Logging; 9 | using Sentry; 10 | using StatsdClient; 11 | 12 | namespace osu.Server.Spectator.Hubs 13 | { 14 | public class LoggingHub : Hub, ILogTarget 15 | where TClient : class 16 | { 17 | protected string Name; 18 | 19 | private readonly ILogger logger; 20 | 21 | // ReSharper disable once StaticMemberInGenericType 22 | private static int totalConnected; 23 | 24 | public LoggingHub(ILoggerFactory loggerFactory) 25 | { 26 | Name = GetType().Name.Replace("Hub", string.Empty); 27 | 28 | logger = loggerFactory.CreateLogger(Name); 29 | } 30 | 31 | public override async Task OnConnectedAsync() 32 | { 33 | Log("Connected"); 34 | DogStatsd.Gauge($"{Name}.connected", Interlocked.Increment(ref totalConnected)); 35 | await base.OnConnectedAsync(); 36 | } 37 | 38 | public override async Task OnDisconnectedAsync(Exception? exception) 39 | { 40 | Log("User disconnected"); 41 | DogStatsd.Gauge($"{Name}.connected", Interlocked.Decrement(ref totalConnected)); 42 | 43 | await base.OnDisconnectedAsync(exception); 44 | } 45 | 46 | protected void Log(string message, LogLevel logLevel = LogLevel.Information) => logger.Log(logLevel, "[user:{userId}] {message}", 47 | getLoggableUserIdentifier(), 48 | message.Trim()); 49 | 50 | protected void Error(string message, Exception exception) => logger.LogError(exception, "[user:{userId}] {message)}", 51 | getLoggableUserIdentifier(), 52 | message.Trim()); 53 | 54 | private string getLoggableUserIdentifier() => Context.UserIdentifier ?? "???"; 55 | 56 | #region Implementation of ILogTarget 57 | 58 | void ILogTarget.Error(string message, Exception exception) 59 | { 60 | Error(message, exception); 61 | 62 | SentrySdk.CaptureException(exception, scope => 63 | { 64 | scope.User = new SentryUser 65 | { 66 | Id = Context.UserIdentifier 67 | }; 68 | }); 69 | } 70 | 71 | void ILogTarget.Log(string message, LogLevel logLevel) => Log(message, logLevel); 72 | 73 | #endregion 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/Metadata/DailyChallengeUpdater.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Linq; 6 | using System.Threading; 7 | using System.Threading.Tasks; 8 | using Microsoft.AspNetCore.SignalR; 9 | using Microsoft.Extensions.Hosting; 10 | using Microsoft.Extensions.Logging; 11 | using osu.Game.Online.Metadata; 12 | using osu.Server.Spectator.Database; 13 | 14 | namespace osu.Server.Spectator.Hubs.Metadata 15 | { 16 | public interface IDailyChallengeUpdater : IHostedService 17 | { 18 | DailyChallengeInfo? Current { get; } 19 | } 20 | 21 | public class DailyChallengeUpdater : BackgroundService, IDailyChallengeUpdater 22 | { 23 | /// 24 | /// Amount of time (in milliseconds) between subsequent polls for the current beatmap of the day. 25 | /// 26 | public int UpdateInterval = 60_000; 27 | 28 | public DailyChallengeInfo? Current { get; private set; } 29 | 30 | private readonly ILogger logger; 31 | private readonly IDatabaseFactory databaseFactory; 32 | private readonly IHubContext hubContext; 33 | 34 | public DailyChallengeUpdater( 35 | ILoggerFactory loggerFactory, 36 | IDatabaseFactory databaseFactory, 37 | IHubContext hubContext) 38 | { 39 | logger = loggerFactory.CreateLogger(nameof(DailyChallengeUpdater)); 40 | this.databaseFactory = databaseFactory; 41 | this.hubContext = hubContext; 42 | } 43 | 44 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 45 | { 46 | while (!stoppingToken.IsCancellationRequested) 47 | { 48 | try 49 | { 50 | await updateDailyChallengeInfo(stoppingToken); 51 | } 52 | catch (Exception ex) 53 | { 54 | logger.LogError(ex, "Failed to update beatmap of the day"); 55 | } 56 | 57 | await Task.Delay(UpdateInterval, stoppingToken); 58 | } 59 | } 60 | 61 | private async Task updateDailyChallengeInfo(CancellationToken cancellationToken) 62 | { 63 | using var db = databaseFactory.GetInstance(); 64 | 65 | var activeRooms = (await db.GetActiveDailyChallengeRoomsAsync()).ToList(); 66 | 67 | if (activeRooms.Count > 1) 68 | { 69 | logger.LogWarning("More than one active 'beatmap of the day' room detected (ids: {roomIds}). Will only use the first one.", 70 | string.Join(',', activeRooms.Select(room => room.id))); 71 | } 72 | 73 | DailyChallengeInfo? newInfo = null; 74 | 75 | var activeRoom = activeRooms.FirstOrDefault(); 76 | 77 | if (activeRoom?.id != null) 78 | newInfo = new DailyChallengeInfo { RoomID = activeRoom.id }; 79 | 80 | if (!Current.Equals(newInfo)) 81 | { 82 | logger.LogInformation("Broadcasting 'beatmap of the day' room change from id {oldRoomID} to {newRoomId}", Current?.RoomID, newInfo?.RoomID); 83 | Current = newInfo; 84 | await hubContext.Clients.All.SendAsync(nameof(IMetadataClient.DailyChallengeUpdated), Current, cancellationToken); 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/Metadata/MetadataBroadcaster.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Linq; 6 | using Microsoft.AspNetCore.SignalR; 7 | using Microsoft.Extensions.Logging; 8 | using osu.Game.Online.Metadata; 9 | using osu.Server.QueueProcessor; 10 | using osu.Server.Spectator.Database; 11 | using ServerBeatmapUpdates = osu.Server.QueueProcessor.BeatmapUpdates; 12 | using ClientBeatmapUpdates = osu.Game.Online.Metadata.BeatmapUpdates; 13 | 14 | namespace osu.Server.Spectator.Hubs.Metadata 15 | { 16 | /// 17 | /// A service which broadcasts any new metadata changes to . 18 | /// 19 | public class MetadataBroadcaster : IDisposable 20 | { 21 | private readonly IDatabaseFactory databaseFactory; 22 | private readonly IHubContext metadataHubContext; 23 | 24 | private readonly ILogger logger; 25 | 26 | private readonly IDisposable poller; 27 | 28 | public MetadataBroadcaster( 29 | ILoggerFactory loggerFactory, 30 | IDatabaseFactory databaseFactory, 31 | IHubContext metadataHubContext) 32 | { 33 | this.databaseFactory = databaseFactory; 34 | this.metadataHubContext = metadataHubContext; 35 | 36 | logger = loggerFactory.CreateLogger(nameof(MetadataBroadcaster)); 37 | poller = BeatmapStatusWatcher.StartPollingAsync(handleUpdates, 5000).Result; 38 | } 39 | 40 | // ReSharper disable once AsyncVoidMethod 41 | private async void handleUpdates(ServerBeatmapUpdates updates) 42 | { 43 | logger.LogInformation("Polled beatmap changes up to last queue id {lastProcessedQueueID}", updates.LastProcessedQueueID); 44 | 45 | if (updates.BeatmapSetIDs.Any()) 46 | { 47 | logger.LogInformation("Broadcasting new beatmaps to client: {beatmapIds}", string.Join(',', updates.BeatmapSetIDs.Select(i => i.ToString()))); 48 | await metadataHubContext.Clients.All.SendAsync(nameof(IMetadataClient.BeatmapSetsUpdated), new ClientBeatmapUpdates(updates.BeatmapSetIDs, updates.LastProcessedQueueID)); 49 | } 50 | } 51 | 52 | public void Dispose() 53 | { 54 | poller.Dispose(); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/Metadata/MetadataClientState.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using osu.Game.Users; 5 | 6 | namespace osu.Server.Spectator.Hubs.Metadata 7 | { 8 | public class MetadataClientState : ClientState 9 | { 10 | public UserActivity? UserActivity { get; set; } 11 | 12 | public UserStatus? UserStatus { get; set; } 13 | 14 | public string? VersionHash { get; set; } 15 | 16 | public MetadataClientState(in string connectionId, in int userId, in string? versionHash) 17 | : base(in connectionId, in userId) 18 | { 19 | VersionHash = versionHash; 20 | } 21 | 22 | /// 23 | /// Creates a which represents this user's state as it should be broadcast to other users. 24 | /// 25 | /// The representative user presence, or null if the user should appear offline. 26 | public UserPresence? ToUserPresence() 27 | { 28 | switch (UserStatus) 29 | { 30 | case null: 31 | case Game.Users.UserStatus.Offline: 32 | return null; 33 | 34 | default: 35 | return new UserPresence 36 | { 37 | Activity = UserActivity, 38 | Status = UserStatus, 39 | }; 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/Metadata/MultiplayerRoomStats.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Collections.Concurrent; 5 | using osu.Game.Online.Metadata; 6 | 7 | namespace osu.Server.Spectator.Hubs.Metadata 8 | { 9 | public class MultiplayerRoomStats 10 | { 11 | public long RoomID { get; init; } 12 | 13 | public readonly ConcurrentDictionary PlaylistItemStats = new ConcurrentDictionary(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/Multiplayer/HeadToHead.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using osu.Game.Online.Multiplayer; 5 | using osu.Server.Spectator.Database.Models; 6 | 7 | namespace osu.Server.Spectator.Hubs.Multiplayer 8 | { 9 | public class HeadToHead : MatchTypeImplementation 10 | { 11 | public HeadToHead(ServerMultiplayerRoom room, IMultiplayerHubContext hub) 12 | : base(room, hub) 13 | { 14 | } 15 | 16 | public override void HandleUserJoined(MultiplayerRoomUser user) 17 | { 18 | base.HandleUserJoined(user); 19 | 20 | if (user.MatchState != null) 21 | { 22 | // we don't need a state, but keep things simple by completely nulling the state. 23 | // this allows the client to see a user state change and handle match type specifics based on that alone. 24 | user.MatchState = null; 25 | Hub.NotifyMatchUserStateChanged(Room, user); 26 | } 27 | } 28 | 29 | public override MatchStartedEventDetail GetMatchDetails() => new MatchStartedEventDetail 30 | { 31 | room_type = database_match_type.head_to_head 32 | }; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/Multiplayer/MatchTypeImplementation.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using osu.Game.Online.Multiplayer; 5 | using osu.Server.Spectator.Database.Models; 6 | 7 | namespace osu.Server.Spectator.Hubs.Multiplayer 8 | { 9 | public abstract class MatchTypeImplementation 10 | { 11 | protected readonly ServerMultiplayerRoom Room; 12 | protected readonly IMultiplayerHubContext Hub; 13 | 14 | protected MatchTypeImplementation(ServerMultiplayerRoom room, IMultiplayerHubContext hub) 15 | { 16 | Room = room; 17 | Hub = hub; 18 | } 19 | 20 | /// 21 | /// Called when a user has requested a match type specific action. 22 | /// 23 | /// The user requesting the action. 24 | /// The nature of the action. 25 | public virtual void HandleUserRequest(MultiplayerRoomUser user, MatchUserRequest request) 26 | { 27 | } 28 | 29 | /// 30 | /// Called once for each user which joins the room. Will be run once for each user after initial construction. 31 | /// 32 | /// The user which joined the room. 33 | public virtual void HandleUserJoined(MultiplayerRoomUser user) 34 | { 35 | } 36 | 37 | /// 38 | /// Called once for each user leaving the room. 39 | /// 40 | /// The user which left the room. 41 | public virtual void HandleUserLeft(MultiplayerRoomUser user) 42 | { 43 | } 44 | 45 | public abstract MatchStartedEventDetail GetMatchDetails(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/Multiplayer/MultiplayerClientState.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using Newtonsoft.Json; 6 | 7 | namespace osu.Server.Spectator.Hubs.Multiplayer 8 | { 9 | [Serializable] 10 | public class MultiplayerClientState : ClientState 11 | { 12 | public readonly long CurrentRoomID; 13 | 14 | [JsonConstructor] 15 | public MultiplayerClientState(in string connectionId, in int userId, in long currentRoomID) 16 | : base(connectionId, userId) 17 | { 18 | CurrentRoomID = currentRoomID; 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/Multiplayer/MultiplayerEventLogger.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Threading.Tasks; 6 | using Microsoft.Extensions.Logging; 7 | using Newtonsoft.Json; 8 | using osu.Server.Spectator.Database; 9 | using osu.Server.Spectator.Database.Models; 10 | 11 | namespace osu.Server.Spectator.Hubs.Multiplayer 12 | { 13 | public class MultiplayerEventLogger 14 | { 15 | private readonly IDatabaseFactory databaseFactory; 16 | private readonly ILogger logger; 17 | 18 | public MultiplayerEventLogger( 19 | ILoggerFactory loggerFactory, 20 | IDatabaseFactory databaseFactory) 21 | { 22 | logger = loggerFactory.CreateLogger(); 23 | this.databaseFactory = databaseFactory; 24 | } 25 | 26 | public Task LogRoomCreatedAsync(long roomId, int userId) => logEvent(new multiplayer_realtime_room_event 27 | { 28 | event_type = "room_created", 29 | room_id = roomId, 30 | user_id = userId, 31 | }); 32 | 33 | public Task LogRoomDisbandedAsync(long roomId, int userId) => logEvent(new multiplayer_realtime_room_event 34 | { 35 | event_type = "room_disbanded", 36 | room_id = roomId, 37 | user_id = userId, 38 | }); 39 | 40 | public Task LogPlayerJoinedAsync(long roomId, int userId) => logEvent(new multiplayer_realtime_room_event 41 | { 42 | event_type = "player_joined", 43 | room_id = roomId, 44 | user_id = userId, 45 | }); 46 | 47 | public Task LogPlayerLeftAsync(long roomId, int userId) => logEvent(new multiplayer_realtime_room_event 48 | { 49 | event_type = "player_left", 50 | room_id = roomId, 51 | user_id = userId, 52 | }); 53 | 54 | public Task LogPlayerKickedAsync(long roomId, int userId) => logEvent(new multiplayer_realtime_room_event 55 | { 56 | event_type = "player_kicked", 57 | room_id = roomId, 58 | user_id = userId, 59 | }); 60 | 61 | public Task LogHostChangedAsync(long roomId, int userId) => logEvent(new multiplayer_realtime_room_event 62 | { 63 | event_type = "host_changed", 64 | room_id = roomId, 65 | user_id = userId, 66 | }); 67 | 68 | public Task LogGameStartedAsync(long roomId, long playlistItemId, MatchStartedEventDetail details) => logEvent(new multiplayer_realtime_room_event 69 | { 70 | event_type = "game_started", 71 | room_id = roomId, 72 | playlist_item_id = playlistItemId, 73 | event_detail = JsonConvert.SerializeObject(details) 74 | }); 75 | 76 | public Task LogGameAbortedAsync(long roomId, long playlistItemId) => logEvent(new multiplayer_realtime_room_event 77 | { 78 | event_type = "game_aborted", 79 | room_id = roomId, 80 | playlist_item_id = playlistItemId, 81 | }); 82 | 83 | public Task LogGameCompletedAsync(long roomId, long playlistItemId) => logEvent(new multiplayer_realtime_room_event 84 | { 85 | event_type = "game_completed", 86 | room_id = roomId, 87 | playlist_item_id = playlistItemId, 88 | }); 89 | 90 | private async Task logEvent(multiplayer_realtime_room_event ev) 91 | { 92 | try 93 | { 94 | using var db = databaseFactory.GetInstance(); 95 | await db.LogRoomEventAsync(ev); 96 | } 97 | catch (Exception e) 98 | { 99 | logger.LogWarning(e, "Failed to log multiplayer room event to database"); 100 | } 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/Multiplayer/TeamVersus.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Collections.Generic; 5 | using System.Linq; 6 | using osu.Game.Online.Multiplayer; 7 | using osu.Game.Online.Multiplayer.MatchTypes.TeamVersus; 8 | using osu.Server.Spectator.Database.Models; 9 | 10 | namespace osu.Server.Spectator.Hubs.Multiplayer 11 | { 12 | public class TeamVersus : MatchTypeImplementation 13 | { 14 | private readonly TeamVersusRoomState state; 15 | 16 | public TeamVersus(ServerMultiplayerRoom room, IMultiplayerHubContext hub) 17 | : base(room, hub) 18 | { 19 | room.MatchState = state = TeamVersusRoomState.CreateDefault(); 20 | 21 | Hub.NotifyMatchRoomStateChanged(room); 22 | } 23 | 24 | public override void HandleUserJoined(MultiplayerRoomUser user) 25 | { 26 | base.HandleUserJoined(user); 27 | 28 | user.MatchState = new TeamVersusUserState { TeamID = getBestAvailableTeam() }; 29 | Hub.NotifyMatchUserStateChanged(Room, user); 30 | } 31 | 32 | public override void HandleUserRequest(MultiplayerRoomUser user, MatchUserRequest request) 33 | { 34 | switch (request) 35 | { 36 | case ChangeTeamRequest changeTeam: 37 | if (state.Teams.All(t => t.ID != changeTeam.TeamID)) 38 | throw new InvalidStateException("Attempted to set team out of valid range"); 39 | 40 | if (user.MatchState is TeamVersusUserState userState) 41 | userState.TeamID = changeTeam.TeamID; 42 | 43 | Hub.NotifyMatchUserStateChanged(Room, user); 44 | break; 45 | } 46 | } 47 | 48 | /// 49 | /// For a user joining the room, this will provide the most appropriate team for the new user to keep the room balanced. 50 | /// 51 | private int getBestAvailableTeam() 52 | { 53 | // initially check for any teams which don't yet have players, but are lower than TeamCount. 54 | foreach (var team in state.Teams) 55 | { 56 | if (Room.Users.All(u => (u.MatchState as TeamVersusUserState)?.TeamID != team.ID)) 57 | return team.ID; 58 | } 59 | 60 | var countsByTeams = Room.Users 61 | .GroupBy(u => (u.MatchState as TeamVersusUserState)?.TeamID) 62 | .Where(g => g.Key.HasValue) 63 | .OrderBy(g => g.Count()); 64 | 65 | return countsByTeams.First().Key ?? 0; 66 | } 67 | 68 | public override MatchStartedEventDetail GetMatchDetails() 69 | { 70 | var teams = new Dictionary(); 71 | 72 | foreach (var user in Room.Users) 73 | { 74 | if (user.MatchState is TeamVersusUserState userState) 75 | teams[user.UserID] = userState.TeamID == 0 ? room_team.red : room_team.blue; 76 | } 77 | 78 | return new MatchStartedEventDetail 79 | { 80 | room_type = database_match_type.team_versus, 81 | teams = teams 82 | }; 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/ScoreUploader.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Threading; 6 | using System.Threading.Channels; 7 | using System.Threading.Tasks; 8 | using Microsoft.Extensions.Logging; 9 | using osu.Game.Scoring; 10 | using osu.Server.Spectator.Database; 11 | using osu.Server.Spectator.Database.Models; 12 | using osu.Server.Spectator.Entities; 13 | using osu.Server.Spectator.Storage; 14 | using StatsdClient; 15 | 16 | namespace osu.Server.Spectator.Hubs 17 | { 18 | public class ScoreUploader : IEntityStore, IDisposable 19 | { 20 | /// 21 | /// Amount of time (in milliseconds) before any individual score times out if a score ID hasn't been set. 22 | /// This can happen if the user forcefully terminated the game before the API score submission request is sent, but after EndPlaySession() has been invoked. 23 | /// 24 | public double TimeoutInterval = 30000; 25 | 26 | public bool SaveReplays = AppSettings.SaveReplays; 27 | 28 | private const string statsd_prefix = "score_uploads"; 29 | 30 | private readonly Channel channel = Channel.CreateUnbounded(); 31 | 32 | private readonly IDatabaseFactory databaseFactory; 33 | private readonly IScoreStorage scoreStorage; 34 | private readonly CancellationTokenSource cancellationSource; 35 | private readonly CancellationToken cancellationToken; 36 | private readonly ILogger logger; 37 | 38 | public ScoreUploader( 39 | ILoggerFactory loggerFactory, 40 | IDatabaseFactory databaseFactory, 41 | IScoreStorage scoreStorage) 42 | { 43 | this.databaseFactory = databaseFactory; 44 | this.scoreStorage = scoreStorage; 45 | logger = loggerFactory.CreateLogger(nameof(ScoreUploader)); 46 | 47 | cancellationSource = new CancellationTokenSource(); 48 | cancellationToken = cancellationSource.Token; 49 | 50 | for (int i = 0; i < AppSettings.ReplayUploaderConcurrency; ++i) 51 | Task.Factory.StartNew(readLoop, TaskCreationOptions.LongRunning); 52 | 53 | Task.Factory.StartNew(monitorLoop, TaskCreationOptions.LongRunning); 54 | } 55 | 56 | /// 57 | /// Enqueues a new score to be uploaded. 58 | /// 59 | /// The score's token. 60 | /// The score. 61 | public async Task EnqueueAsync(long token, Score score) 62 | { 63 | if (!SaveReplays) 64 | return; 65 | 66 | Interlocked.Increment(ref remainingUsages); 67 | 68 | var cancellation = new CancellationTokenSource(); 69 | cancellation.CancelAfter(TimeSpan.FromMilliseconds(TimeoutInterval)); 70 | 71 | await channel.Writer.WriteAsync(new UploadItem(token, score, cancellation), cancellationToken); 72 | } 73 | 74 | private async Task readLoop() 75 | { 76 | while (!cancellationToken.IsCancellationRequested) 77 | { 78 | using var db = databaseFactory.GetInstance(); 79 | 80 | var item = await channel.Reader.ReadAsync(cancellationToken); 81 | 82 | try 83 | { 84 | SoloScore? dbScore = await db.GetScoreFromTokenAsync(item.Token); 85 | 86 | if (dbScore == null && !item.Cancellation.IsCancellationRequested) 87 | { 88 | // Score is not ready yet - enqueue for the next attempt. 89 | await channel.Writer.WriteAsync(item, cancellationToken); 90 | continue; 91 | } 92 | 93 | try 94 | { 95 | if (dbScore == null) 96 | { 97 | logger.LogError("Score upload timed out for token: {tokenId}", item.Token); 98 | DogStatsd.Increment($"{statsd_prefix}.timed_out"); 99 | continue; 100 | } 101 | 102 | if (!dbScore.passed) 103 | continue; 104 | 105 | item.Score.ScoreInfo.OnlineID = (long)dbScore.id; 106 | item.Score.ScoreInfo.Passed = dbScore.passed; 107 | 108 | await scoreStorage.WriteAsync(item.Score); 109 | await db.MarkScoreHasReplay(item.Score); 110 | DogStatsd.Increment($"{statsd_prefix}.uploaded"); 111 | } 112 | finally 113 | { 114 | item.Dispose(); 115 | Interlocked.Decrement(ref remainingUsages); 116 | } 117 | } 118 | catch (Exception e) 119 | { 120 | logger.LogError(e, "Error during score upload"); 121 | DogStatsd.Increment($"{statsd_prefix}.failed"); 122 | } 123 | } 124 | } 125 | 126 | private async Task monitorLoop() 127 | { 128 | while (!cancellationToken.IsCancellationRequested) 129 | { 130 | DogStatsd.Gauge($"{statsd_prefix}.total_in_queue", remainingUsages); 131 | await Task.Delay(1000, cancellationToken); 132 | } 133 | } 134 | 135 | public void Dispose() 136 | { 137 | cancellationSource.Cancel(); 138 | cancellationSource.Dispose(); 139 | } 140 | 141 | private record UploadItem(long Token, Score Score, CancellationTokenSource Cancellation) : IDisposable 142 | { 143 | public void Dispose() 144 | { 145 | Cancellation.Dispose(); 146 | } 147 | } 148 | 149 | private int remainingUsages; 150 | 151 | // Using the count of items in the queue isn't correct since items are dequeued for processing. 152 | public int RemainingUsages => remainingUsages; 153 | 154 | public string EntityName => "Score uploads"; 155 | 156 | public void StopAcceptingEntities() 157 | { 158 | // Handled by the spectator hub. 159 | } 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/Spectator/IScoreProcessedSubscriber.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Threading.Tasks; 5 | 6 | namespace osu.Server.Spectator.Hubs.Spectator 7 | { 8 | /// 9 | /// Allows hub clients to receive notifications about the completion of processing of a score. 10 | /// 11 | public interface IScoreProcessedSubscriber 12 | { 13 | /// 14 | /// Registers a hub client for future notifications about the completion of processing of a score. 15 | /// 16 | /// The ID of the connection that should receive the notifications. 17 | /// The ID of the user who set the score. 18 | /// The ID of the score which is being processed. 19 | Task RegisterForSingleScoreAsync(string receiverConnectionId, int userId, long scoreId); 20 | 21 | /// 22 | /// Registers a hub client for future notifications about incoming scores in a given . 23 | /// 24 | Task RegisterForMultiplayerRoomAsync(int userId, long roomId); 25 | 26 | /// 27 | /// Unregisters a hub client from future notifications about incoming scores in a given . 28 | /// 29 | Task UnregisterFromMultiplayerRoomAsync(int userId, long roomId); 30 | 31 | /// 32 | /// Unregisters a hub client from all multiplayer room subscriptions. 33 | /// 34 | Task UnregisterFromAllMultiplayerRoomsAsync(int userId); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/Spectator/SpectatorClientState.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using Newtonsoft.Json; 7 | using osu.Game.Online.Spectator; 8 | using osu.Game.Scoring; 9 | 10 | namespace osu.Server.Spectator.Hubs.Spectator 11 | { 12 | [Serializable] 13 | public class SpectatorClientState : ClientState 14 | { 15 | /// 16 | /// When a user is in gameplay, this is the state as conveyed at the start of the play session. 17 | /// 18 | public SpectatorState? State; 19 | 20 | /// 21 | /// When a user is in gameplay, this is the imminent score. It will be updated throughout a play session. 22 | /// 23 | public Score? Score; 24 | 25 | /// 26 | /// The score token as conveyed by the client at the beginning of a play session. 27 | /// 28 | public long? ScoreToken; 29 | 30 | /// 31 | /// The list of IDs of users that this client is currently watching. 32 | /// 33 | public HashSet WatchedUsers = new HashSet(); 34 | 35 | [JsonConstructor] 36 | public SpectatorClientState(in string connectionId, in int userId) 37 | : base(connectionId, userId) 38 | { 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Hubs/StatefulUserHub.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Collections.Generic; 6 | using System.Threading.Tasks; 7 | using JetBrains.Annotations; 8 | using Microsoft.AspNetCore.Authorization; 9 | using Microsoft.Extensions.Logging; 10 | using osu.Game.Online; 11 | using osu.Server.Spectator.Entities; 12 | using osu.Server.Spectator.Extensions; 13 | 14 | namespace osu.Server.Spectator.Hubs 15 | { 16 | [UsedImplicitly] 17 | [Authorize] 18 | public abstract class StatefulUserHub : LoggingHub, IStatefulUserHub 19 | where TUserState : ClientState 20 | where TClient : class, IStatefulUserHubClient 21 | { 22 | protected readonly EntityStore UserStates; 23 | 24 | protected StatefulUserHub( 25 | ILoggerFactory loggerFactory, 26 | EntityStore userStates) 27 | : base(loggerFactory) 28 | { 29 | UserStates = userStates; 30 | } 31 | 32 | protected KeyValuePair[] GetAllStates() => UserStates.GetAllEntities(); 33 | 34 | public override async Task OnConnectedAsync() 35 | { 36 | await base.OnConnectedAsync(); 37 | 38 | try 39 | { 40 | // if a previous connection is still present for the current user, we need to clean it up. 41 | await cleanUpState(false); 42 | } 43 | catch 44 | { 45 | Log("State cleanup failed"); 46 | 47 | // if any exception happened during clean-up, don't allow the user to reconnect. 48 | // this limits damage to the user in a bad state if their clean-up cannot occur (they will not be able to reconnect until the issue is resolved). 49 | Context.Abort(); 50 | throw; 51 | } 52 | } 53 | 54 | public sealed override async Task OnDisconnectedAsync(Exception? exception) 55 | { 56 | await base.OnDisconnectedAsync(exception); 57 | await cleanUpState(true); 58 | } 59 | 60 | private async Task cleanUpState(bool isDisconnect) 61 | { 62 | ItemUsage? usage; 63 | 64 | try 65 | { 66 | usage = await UserStates.GetForUse(Context.GetUserId()); 67 | } 68 | catch (KeyNotFoundException) 69 | { 70 | // no state to clean up. 71 | return; 72 | } 73 | 74 | Log($"Cleaning up state on {(isDisconnect ? "disconnect" : "connect")}"); 75 | 76 | try 77 | { 78 | if (usage.Item != null) 79 | { 80 | bool isOurState = usage.Item.ConnectionId == Context.ConnectionId; 81 | 82 | if (isDisconnect && !isOurState) 83 | { 84 | // not our state, owned by a different connection. 85 | Log("Disconnect state cleanup aborted due to newer connection owning state"); 86 | return; 87 | } 88 | 89 | try 90 | { 91 | await CleanUpState(usage.Item); 92 | } 93 | finally 94 | { 95 | usage.Destroy(); 96 | Log("State cleanup completed"); 97 | } 98 | } 99 | } 100 | finally 101 | { 102 | usage.Dispose(); 103 | } 104 | } 105 | 106 | /// 107 | /// Perform any cleanup required on the provided state. 108 | /// 109 | protected virtual Task CleanUpState(TUserState state) => Task.CompletedTask; 110 | 111 | protected async Task> GetOrCreateLocalUserState() 112 | { 113 | var usage = await UserStates.GetForUse(Context.GetUserId(), true); 114 | 115 | if (usage.Item != null && usage.Item.ConnectionId != Context.ConnectionId) 116 | { 117 | usage.Dispose(); 118 | throw new InvalidOperationException("State is not valid for this connection"); 119 | } 120 | 121 | return usage; 122 | } 123 | 124 | protected Task> GetStateFromUser(int userId) => UserStates.GetForUse(userId); 125 | 126 | protected Task?> TryGetStateFromUser(int userId) => UserStates.TryGetForUse(userId); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /osu.Server.Spectator/LegacyHelper.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using osu.Game.Rulesets; 6 | using osu.Game.Rulesets.Catch; 7 | using osu.Game.Rulesets.Mania; 8 | using osu.Game.Rulesets.Osu; 9 | using osu.Game.Rulesets.Taiko; 10 | 11 | namespace osu.Server.Spectator 12 | { 13 | public static class LegacyHelper 14 | { 15 | public static Ruleset GetRulesetFromLegacyID(int id) 16 | { 17 | switch (id) 18 | { 19 | default: 20 | throw new ArgumentException("Invalid ruleset ID provided."); 21 | 22 | case 0: 23 | return new OsuRuleset(); 24 | 25 | case 1: 26 | return new TaikoRuleset(); 27 | 28 | case 2: 29 | return new CatchRuleset(); 30 | 31 | case 3: 32 | return new ManiaRuleset(); 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /osu.Server.Spectator/LoggingHubFilter.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Collections; 6 | using System.Linq; 7 | using System.Threading.Tasks; 8 | using Microsoft.AspNetCore.SignalR; 9 | using Microsoft.Extensions.Logging; 10 | using osu.Framework.Development; 11 | using osu.Server.Spectator.Hubs; 12 | 13 | namespace osu.Server.Spectator 14 | { 15 | /// 16 | /// An logging method invoke and error to the . 17 | /// 18 | public class LoggingHubFilter : IHubFilter 19 | { 20 | public async ValueTask InvokeMethodAsync(HubInvocationContext invocationContext, Func> next) 21 | { 22 | if (!(invocationContext.Hub is ILogTarget logTarget)) 23 | throw new InvalidOperationException($"Hub implementation {invocationContext.Hub.GetType().Name} doesn't implement {nameof(ILogTarget)}."); 24 | 25 | try 26 | { 27 | if (DebugUtils.IsDebugBuild) 28 | logTarget.Log($"Invoking hub method: {GetMethodCallDisplayString(invocationContext)}", LogLevel.Debug); 29 | 30 | return await next(invocationContext); 31 | } 32 | catch (Exception e) 33 | { 34 | logTarget.Error($"Failed to invoke hub method: {GetMethodCallDisplayString(invocationContext)}", e); 35 | throw; 36 | } 37 | } 38 | 39 | public async Task OnConnectedAsync(HubLifetimeContext context, Func next) 40 | { 41 | if (!(context.Hub is ILogTarget logTarget)) 42 | throw new InvalidOperationException($"Hub implementation {context.Hub.GetType().Name} doesn't implement {nameof(ILogTarget)}."); 43 | 44 | try 45 | { 46 | await next(context); 47 | } 48 | catch (Exception e) 49 | { 50 | logTarget.Error("Failed to invoke OnConnectedAsync()", e); 51 | throw; 52 | } 53 | } 54 | 55 | public async Task OnDisconnectedAsync(HubLifetimeContext context, Exception? exception, Func next) 56 | { 57 | if (!(context.Hub is ILogTarget logTarget)) 58 | throw new InvalidOperationException($"Hub implementation {context.Hub.GetType().Name} doesn't implement {nameof(ILogTarget)}."); 59 | 60 | try 61 | { 62 | await next(context, exception); 63 | } 64 | catch (Exception e) 65 | { 66 | logTarget.Error($"Failed to invoke {nameof(OnDisconnectedAsync)}()", e); 67 | throw; 68 | } 69 | } 70 | 71 | public static string GetMethodCallDisplayString(HubInvocationContext invocationContext) 72 | { 73 | var methodCall = $"{invocationContext.HubMethodName}({string.Join(", ", invocationContext.HubMethodArguments.Select(getReadableString))})"; 74 | return methodCall; 75 | } 76 | 77 | private static string? getReadableString(object? value) 78 | { 79 | switch (value) 80 | { 81 | case null: 82 | return "null"; 83 | 84 | case string str: 85 | return $"\"{str}\""; 86 | 87 | case IEnumerable enumerable: 88 | return $"{{ {string.Join(", ", enumerable.Cast().Select(getReadableString))} }}"; 89 | 90 | default: 91 | return value.ToString(); 92 | } 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Net; 6 | using Microsoft.AspNetCore.Hosting; 7 | #if !DEBUG 8 | using Microsoft.AspNetCore.SignalR; 9 | #endif 10 | using Microsoft.Extensions.Hosting; 11 | using StatsdClient; 12 | 13 | namespace osu.Server.Spectator 14 | { 15 | public static class Program 16 | { 17 | public static void Main(string[] args) 18 | { 19 | DogStatsd.Configure(new StatsdConfig 20 | { 21 | StatsdServerName = AppSettings.DataDogAgentHost, 22 | Prefix = "osu.server.spectator", 23 | ConstantTags = new[] 24 | { 25 | $"hostname:{Dns.GetHostName()}", 26 | $"startup:{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}", 27 | } 28 | }); 29 | 30 | createHostBuilder(args).Build().Run(); 31 | } 32 | 33 | private static IHostBuilder createHostBuilder(string[] args) 34 | { 35 | return Host.CreateDefaultBuilder(args) 36 | .ConfigureWebHostDefaults(webBuilder => 37 | { 38 | #if !DEBUG 39 | webBuilder.UseSentry(o => 40 | { 41 | o.AddExceptionFilterForType(); 42 | o.TracesSampleRate = 0; 43 | o.Dsn = AppSettings.SentryDsn ?? throw new InvalidOperationException("SENTRY_DSN environment variable not set. " 44 | + "Please set the value of this variable to a valid Sentry DSN to use for logging events."); 45 | // TODO: set release name 46 | }); 47 | #endif 48 | 49 | #if DEBUG 50 | webBuilder.UseStartup(); 51 | #else 52 | webBuilder.UseStartup(); 53 | #endif 54 | 55 | webBuilder.UseUrls(urls: [$"http://*:{AppSettings.ServerPort}"]); 56 | }); 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Runtime.CompilerServices; 5 | 6 | [assembly: InternalsVisibleTo("osu.Server.Spectator.Tests")] 7 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "iisSettings": { 4 | "windowsAuthentication": false, 5 | "anonymousAuthentication": true, 6 | "iisExpress": { 7 | "applicationUrl": "http://localhost:22274", 8 | "sslPort": 44332 9 | } 10 | }, 11 | "profiles": { 12 | "osu.Server.Spectator": { 13 | "commandName": "Project", 14 | "launchBrowser": false, 15 | "launchUrl": "spectator", 16 | "applicationUrl": "http://localhost:5009", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | } 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /osu.Server.Spectator/S3.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.IO; 6 | using System.Threading.Tasks; 7 | using Amazon; 8 | using Amazon.Runtime; 9 | using Amazon.S3; 10 | using Amazon.S3.Model; 11 | 12 | namespace osu.Server.Spectator 13 | { 14 | public static class S3 15 | { 16 | private static AmazonS3Client getClient(RegionEndpoint? endpoint = null) 17 | { 18 | return new AmazonS3Client(new BasicAWSCredentials(AppSettings.S3Key, AppSettings.S3Secret), new AmazonS3Config 19 | { 20 | CacheHttpClient = true, 21 | HttpClientCacheSize = 32, 22 | RegionEndpoint = endpoint ?? RegionEndpoint.USWest1, 23 | UseHttp = true, 24 | ForcePathStyle = true, 25 | RetryMode = RequestRetryMode.Legacy, 26 | MaxErrorRetry = 5, 27 | Timeout = TimeSpan.FromSeconds(10), 28 | }); 29 | } 30 | 31 | public static async Task Upload(string bucket, string key, Stream stream, long contentLength, string? contentType = null) 32 | { 33 | using (var client = getClient()) 34 | { 35 | await client.PutObjectAsync(new PutObjectRequest 36 | { 37 | BucketName = bucket, 38 | Key = key, 39 | Headers = 40 | { 41 | ContentLength = contentLength, 42 | ContentType = contentType, 43 | }, 44 | InputStream = stream 45 | }); 46 | } 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /osu.Server.Spectator/ServerShuttingDownException.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using Microsoft.AspNetCore.SignalR; 5 | using osu.Game.Online; 6 | 7 | namespace osu.Server.Spectator 8 | { 9 | public class ServerShuttingDownException : HubException 10 | { 11 | public ServerShuttingDownException() 12 | : base(HubClientConnector.SERVER_SHUTDOWN_MESSAGE) 13 | { 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Services/ISharedInterop.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Threading.Tasks; 5 | using osu.Game.Online.Multiplayer; 6 | 7 | namespace osu.Server.Spectator.Services 8 | { 9 | public interface ISharedInterop 10 | { 11 | /// 12 | /// Creates an osu!web room. 13 | /// 14 | /// 15 | /// This does not join the creating user to the room. A subsequent call to should be made if required. 16 | /// 17 | /// The ID of the user that wants to create the room. 18 | /// The room. 19 | /// The room's ID. 20 | Task CreateRoomAsync(int hostUserId, MultiplayerRoom room); 21 | 22 | /// 23 | /// Adds a user to an osu!web room. 24 | /// 25 | /// 26 | /// This performs setup tasks like adding the user to the relevant chat channel. 27 | /// 28 | /// The ID of the user wanting to join the room. 29 | /// The ID of the room to join. 30 | /// The room's password. 31 | Task AddUserToRoomAsync(int userId, long roomId, string password); 32 | 33 | /// 34 | /// Parts an osu!web room. 35 | /// 36 | /// 37 | /// This performs setup tasks like removing the user from any relevant chat channels. 38 | /// 39 | /// The ID of the user wanting to part the room. 40 | /// The ID of the room to part. 41 | Task RemoveUserFromRoomAsync(int userId, long roomId); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /osu.Server.Spectator/StartupDevelopment.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System; 5 | using System.Security.Claims; 6 | using System.Security.Principal; 7 | using System.Text.Encodings.Web; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using JetBrains.Annotations; 11 | using Microsoft.AspNetCore.Authentication; 12 | using Microsoft.Extensions.DependencyInjection; 13 | using Microsoft.Extensions.Logging; 14 | using Microsoft.Extensions.Options; 15 | 16 | namespace osu.Server.Spectator 17 | { 18 | public class StartupDevelopment : Startup 19 | { 20 | protected override void ConfigureAuthentication(IServiceCollection services) 21 | { 22 | services.AddLocalAuthentication(); 23 | } 24 | } 25 | 26 | [UsedImplicitly] 27 | public class LocalAuthenticationHandler : AuthenticationHandler 28 | { 29 | private static int userIDCounter = 2; 30 | 31 | /// 32 | /// The name of the authorisation scheme that this handler will respond to. 33 | /// 34 | public const string AUTH_SCHEME = "LocalAuth"; 35 | 36 | public LocalAuthenticationHandler( 37 | IOptionsMonitor options, ILoggerFactory logger, 38 | UrlEncoder encoder) 39 | : base(options, logger, encoder) 40 | { 41 | } 42 | 43 | /// 44 | /// Marks all authentication requests as successful, and injects required user claims. 45 | /// 46 | protected override Task HandleAuthenticateAsync() 47 | { 48 | var nameIdentifierClaim = createNameIdentifierClaim(); 49 | var clientIdClaim = createClientIdClaim(); 50 | 51 | var authenticationTicket = new AuthenticationTicket( 52 | new ClaimsPrincipal(new[] { new ClaimsIdentity(new[] { nameIdentifierClaim, clientIdClaim }, AUTH_SCHEME) }), 53 | new AuthenticationProperties(), AUTH_SCHEME); 54 | 55 | return Task.FromResult(AuthenticateResult.Success(authenticationTicket)); 56 | } 57 | 58 | private Claim createNameIdentifierClaim() 59 | { 60 | string? userIdString = null; 61 | 62 | if (Context.Request.Headers.TryGetValue("user_id", out var userIdValue)) 63 | userIdString = userIdValue; 64 | 65 | userIdString ??= Interlocked.Increment(ref userIDCounter).ToString(); 66 | 67 | var nameIdentifierClaim = new Claim(ClaimTypes.NameIdentifier, userIdString); 68 | return nameIdentifierClaim; 69 | } 70 | 71 | private Claim createClientIdClaim() 72 | { 73 | string? clientIdString = null; 74 | 75 | if (Context.Request.Headers.TryGetValue("client_id", out var clientIdValue)) 76 | clientIdString = clientIdValue; 77 | 78 | clientIdString ??= Guid.NewGuid().ToString(); 79 | 80 | var clientIdClaim = new Claim("jti", clientIdString); 81 | return clientIdClaim; 82 | } 83 | } 84 | 85 | public class LocalIdentity : IIdentity 86 | { 87 | public string AuthenticationType => LocalAuthenticationHandler.AUTH_SCHEME; 88 | public bool IsAuthenticated => true; 89 | public string Name { get; } 90 | 91 | public LocalIdentity(string name) 92 | { 93 | Name = name; 94 | } 95 | } 96 | 97 | public static class LocalAuthenticationHandlerExtensions 98 | { 99 | public static AuthenticationBuilder AddLocalAuthentication(this IServiceCollection services) 100 | { 101 | return services.AddAuthentication(options => 102 | { 103 | options.DefaultAuthenticateScheme = LocalAuthenticationHandler.AUTH_SCHEME; 104 | options.DefaultChallengeScheme = LocalAuthenticationHandler.AUTH_SCHEME; 105 | }).AddScheme(LocalAuthenticationHandler.AUTH_SCHEME, opt => { }); 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Storage/FileScoreStorage.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.IO; 5 | using System.Threading.Tasks; 6 | using Microsoft.Extensions.Logging; 7 | using osu.Game.Scoring; 8 | using osu.Game.Scoring.Legacy; 9 | 10 | namespace osu.Server.Spectator.Storage 11 | { 12 | public class FileScoreStorage : IScoreStorage 13 | { 14 | private readonly ILogger logger; 15 | 16 | public FileScoreStorage(ILoggerFactory loggerFactory) 17 | { 18 | logger = loggerFactory.CreateLogger(nameof(FileScoreStorage)); 19 | } 20 | 21 | public Task WriteAsync(Score score) 22 | { 23 | var legacyEncoder = new LegacyScoreEncoder(score, null); 24 | 25 | string filename = score.ScoreInfo.OnlineID.ToString(); 26 | 27 | logger.LogInformation("Writing replay for score {scoreId} to {filename}", 28 | score.ScoreInfo.OnlineID, 29 | filename); 30 | 31 | using (var outStream = File.Create(Path.Combine(AppSettings.ReplaysPath, filename))) 32 | legacyEncoder.Encode(outStream); 33 | 34 | return Task.CompletedTask; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Storage/IScoreStorage.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Threading.Tasks; 5 | using osu.Game.Scoring; 6 | 7 | namespace osu.Server.Spectator.Storage 8 | { 9 | public interface IScoreStorage 10 | { 11 | Task WriteAsync(Score score); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /osu.Server.Spectator/Storage/S3ScoreStorage.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. 2 | // See the LICENCE file in the repository root for full licence text. 3 | 4 | using System.Globalization; 5 | using System.IO; 6 | using System.Threading.Tasks; 7 | using Microsoft.Extensions.Logging; 8 | using osu.Game.Scoring; 9 | using osu.Game.Scoring.Legacy; 10 | 11 | namespace osu.Server.Spectator.Storage 12 | { 13 | public class S3ScoreStorage : IScoreStorage 14 | { 15 | private readonly ILogger logger; 16 | 17 | public S3ScoreStorage(ILoggerFactory loggerFactory) 18 | { 19 | logger = loggerFactory.CreateLogger(nameof(S3ScoreStorage)); 20 | } 21 | 22 | public async Task WriteAsync(Score score) 23 | { 24 | using (var outStream = new MemoryStream()) 25 | { 26 | new LegacyScoreEncoder(score, null).Encode(outStream, true); 27 | 28 | outStream.Seek(0, SeekOrigin.Begin); 29 | 30 | logger.LogInformation($"Uploading replay for score {score.ScoreInfo.OnlineID}"); 31 | 32 | await S3.Upload(AppSettings.ReplaysBucket, score.ScoreInfo.OnlineID.ToString(CultureInfo.InvariantCulture), outStream, outStream.Length); 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /osu.Server.Spectator/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /osu.Server.Spectator/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "AllowedHosts": "*" 10 | } 11 | -------------------------------------------------------------------------------- /osu.Server.Spectator/oauth-public.key: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAocbbGH4bMuaWKQirl1oD 3 | /yuPry8auYdm55znx3KflMuEszD2AdbmM180OAC5VdZrERoEOpirQTj4SHXdLhpA 4 | pyEKkfpPmanLS2lUglAxZpByQ7As4Z1b9hz8dTRPofpzkrhbRpBWwfBYTuGJa4bg 5 | dev8XK1gKWKxw0A5/fo+tlfYTfd9biDu41pTqGp2R+Izh5wKDq/T649wNmgGj8Ia 6 | hrW0SCdxqKKoj9qG6SJ/TBD2a2UIKFZG/4H5DdPuRQn03DmGgHNjrc64nFXtjsl1 7 | W/wzMuiw9HZBdjCr5doORtLmA28axbCcti2+iNYA0bb+wbOLuD/s2L3ziF2ObYj6 8 | 1GN1ZY/0wEVKlkaPLAvOju+RC44R4BCykUWc/DcSwJBVk+Eaba7MbeO5zxb2/JpQ 9 | U9fOLykGfT1RGF0V/AR+73Q7gIPIu0mLsaAGs6Uxxzm+iHI4uw0tQq99XiFe+Qzs 10 | +du24vmMHsLmZNaTzTIe3cKQtmJNZyA/yGzh1mjj6lFMVUSDyoNr6uYQ6kQtVBrV 11 | oq616a4+MyshCJkCfMR6kFJ+JgLrzwrsmxv1ZghiRdMggi4ifruWZO8L1hK7yg9+ 12 | ytg/x9PNt1KiQLKcbhk0EkFcNzM1hOCz2SdKYIUi8gd/BbqGxcOt5XoB5Yv8LNbA 13 | nNQbmwnL/6xb7vkVwjmCwkECAwEAAQ== 14 | -----END PUBLIC KEY----- -------------------------------------------------------------------------------- /osu.Server.Spectator/osu.Server.Spectator.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | PreserveNewest 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | --------------------------------------------------------------------------------