├── .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 |
5 |
6 |
7 |
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 [](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