Skip to content

Commit

Permalink
Merge pull request #237 from bdach/watch-multi-room
Browse files Browse the repository at this point in the history
Allow clients to receive realtime updates for a given playlist
  • Loading branch information
peppy committed Jun 28, 2024
2 parents 0ea82a2 + 6528c36 commit 8bf0287
Show file tree
Hide file tree
Showing 20 changed files with 395 additions and 60 deletions.
2 changes: 1 addition & 1 deletion SampleMultiplayerClient/SampleMultiplayerClient.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.2" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageReference Include="ppy.osu.Game" Version="2024.625.0" />
<PackageReference Include="ppy.osu.Game" Version="2024.628.0" />
</ItemGroup>

</Project>
2 changes: 1 addition & 1 deletion SampleSpectatorClient/SampleSpectatorClient.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.2" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="8.0.2" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageReference Include="ppy.osu.Game" Version="2024.625.0" />
<PackageReference Include="ppy.osu.Game" Version="2024.628.0" />
</ItemGroup>

</Project>
9 changes: 8 additions & 1 deletion osu.Server.Spectator.Tests/MetadataHubTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using osu.Server.Spectator.Database;
using osu.Server.Spectator.Entities;
using osu.Server.Spectator.Hubs.Metadata;
using osu.Server.Spectator.Hubs.Spectator;
using Xunit;

namespace osu.Server.Spectator.Tests
Expand All @@ -41,7 +42,13 @@ public MetadataHubTest()
loggerFactoryMock.Setup(factory => factory.CreateLogger(It.IsAny<string>()))
.Returns(new Mock<ILogger>().Object);

hub = new MetadataHub(loggerFactoryMock.Object, cache, userStates, databaseFactory.Object, new Mock<IDailyChallengeUpdater>().Object);
hub = new MetadataHub(
loggerFactoryMock.Object,
cache,
userStates,
databaseFactory.Object,
new Mock<IDailyChallengeUpdater>().Object,
new Mock<IScoreProcessedSubscriber>().Object);

var mockContext = new Mock<HubCallerContext>();
mockContext.Setup(ctx => ctx.UserIdentifier).Returns(user_id.ToString());
Expand Down
2 changes: 1 addition & 1 deletion osu.Server.Spectator.Tests/Multiplayer/MatchTypeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ public async Task ChangeMatchType()
[Fact]
public async Task JoinRoomWithTypeCreatesCorrectInstance()
{
Database.Setup(db => db.GetRoomAsync(ROOM_ID))
Database.Setup(db => db.GetRealtimeRoomAsync(ROOM_ID))
.Callback<long>(InitialiseRoom)
.ReturnsAsync(new multiplayer_room
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ public async Task UserCanInviteIntoRoomWithPassword()
{
const string password = "password";

Database.Setup(db => db.GetRoomAsync(It.IsAny<long>()))
Database.Setup(db => db.GetRealtimeRoomAsync(It.IsAny<long>()))
.Callback<long>(InitialiseRoom)
.ReturnsAsync(new multiplayer_room
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ public async Task RoomStartsWithCurrentPlaylistItem()
public async Task RoomStartsWithCorrectQueueingMode()
{
Database.Setup(d => d.GetBeatmapAsync(3333)).ReturnsAsync(new database_beatmap { checksum = "3333" });
Database.Setup(db => db.GetRoomAsync(ROOM_ID))
Database.Setup(db => db.GetRealtimeRoomAsync(ROOM_ID))
.Callback<long>(InitialiseRoom)
.ReturnsAsync(() => new multiplayer_room
{
Expand Down
4 changes: 2 additions & 2 deletions osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ private void setUpMockDatabase()
{
DatabaseFactory.Setup(factory => factory.GetInstance()).Returns(Database.Object);

Database.Setup(db => db.GetRoomAsync(ROOM_ID))
Database.Setup(db => db.GetRealtimeRoomAsync(ROOM_ID))
.Callback<long>(InitialiseRoom)
.ReturnsAsync(() => new multiplayer_room
{
Expand All @@ -223,7 +223,7 @@ private void setUpMockDatabase()
user_id = int.Parse(Hub.Context.UserIdentifier!),
});

Database.Setup(db => db.GetRoomAsync(ROOM_ID_2))
Database.Setup(db => db.GetRealtimeRoomAsync(ROOM_ID_2))
.Callback<long>(InitialiseRoom)
.ReturnsAsync(() => new multiplayer_room
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public async Task UserCanJoinWithPasswordEvenWhenNotRequired()
[Fact]
public async Task UserCanJoinWithCorrectPassword()
{
Database.Setup(db => db.GetRoomAsync(It.IsAny<long>()))
Database.Setup(db => db.GetRealtimeRoomAsync(It.IsAny<long>()))
.Callback<long>(InitialiseRoom)
.ReturnsAsync(new multiplayer_room
{
Expand All @@ -36,7 +36,7 @@ public async Task UserCanJoinWithCorrectPassword()
[Fact]
public async Task UserCantJoinWithIncorrectPassword()
{
Database.Setup(db => db.GetRoomAsync(It.IsAny<long>()))
Database.Setup(db => db.GetRealtimeRoomAsync(It.IsAny<long>()))
.Callback<long>(InitialiseRoom)
.ReturnsAsync(new multiplayer_room
{
Expand All @@ -61,7 +61,7 @@ public async Task UserCantJoinWhenRestricted()
[Fact]
public async Task UserCantJoinAlreadyEnded()
{
Database.Setup(db => db.GetRoomAsync(It.IsAny<long>()))
Database.Setup(db => db.GetRealtimeRoomAsync(It.IsAny<long>()))
.ReturnsAsync(new multiplayer_room
{
ends_at = DateTimeOffset.Now.AddMinutes(-5),
Expand Down Expand Up @@ -159,7 +159,7 @@ public async Task UserJoinLeaveNotifiesOtherUsers()
[Fact]
public async Task UserJoinPreRetrievalFailureCleansUpRoom()
{
Database.Setup(db => db.GetRoomAsync(ROOM_ID))
Database.Setup(db => db.GetRealtimeRoomAsync(ROOM_ID))
.Callback<long>(InitialiseRoom)
.ReturnsAsync(() => new multiplayer_room
{
Expand Down
8 changes: 4 additions & 4 deletions osu.Server.Spectator.Tests/ScoreUploaderTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public class ScoreUploaderTests
public ScoreUploaderTests()
{
mockDatabase = new Mock<IDatabaseAccess>();
mockDatabase.Setup(db => db.GetScoreFromToken(1)).Returns(Task.FromResult<SoloScore?>(new SoloScore
mockDatabase.Setup(db => db.GetScoreFromTokenAsync(1)).Returns(Task.FromResult<SoloScore?>(new SoloScore
{
id = 2,
passed = true
Expand Down Expand Up @@ -124,7 +124,7 @@ public async Task ScoreUploadsWithDelayedScoreToken()
mockStorage.Verify(s => s.WriteAsync(It.IsAny<Score>()), Times.Never);

// Give the score a token.
mockDatabase.Setup(db => db.GetScoreFromToken(2)).Returns(Task.FromResult<SoloScore?>(new SoloScore
mockDatabase.Setup(db => db.GetScoreFromTokenAsync(2)).Returns(Task.FromResult<SoloScore?>(new SoloScore
{
id = 3,
passed = true
Expand All @@ -150,15 +150,15 @@ public async Task TimedOutScoreDoesNotUpload()
mockStorage.Verify(s => s.WriteAsync(It.IsAny<Score>()), Times.Never);

// Give the score a token now. It should still not upload because it has timed out.
mockDatabase.Setup(db => db.GetScoreFromToken(2)).Returns(Task.FromResult<SoloScore?>(new SoloScore
mockDatabase.Setup(db => db.GetScoreFromTokenAsync(2)).Returns(Task.FromResult<SoloScore?>(new SoloScore
{
id = 3,
passed = true
}));
mockStorage.Verify(s => s.WriteAsync(It.IsAny<Score>()), Times.Never);

// New score that has a token (ensure the loop keeps running).
mockDatabase.Setup(db => db.GetScoreFromToken(3)).Returns(Task.FromResult<SoloScore?>(new SoloScore
mockDatabase.Setup(db => db.GetScoreFromTokenAsync(3)).Returns(Task.FromResult<SoloScore?>(new SoloScore
{
id = 4,
passed = true
Expand Down
12 changes: 6 additions & 6 deletions osu.Server.Spectator.Tests/SpectatorHubTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ public async Task ReplayDataIsSaved(bool savingEnabled)
hub.Context = mockContext.Object;
hub.Clients = mockClients.Object;

mockDatabase.Setup(db => db.GetScoreFromToken(1234)).Returns(Task.FromResult<SoloScore?>(new SoloScore
mockDatabase.Setup(db => db.GetScoreFromTokenAsync(1234)).Returns(Task.FromResult<SoloScore?>(new SoloScore
{
id = 456,
passed = true
Expand Down Expand Up @@ -184,7 +184,7 @@ public async Task ReplaysWithoutAnyHitsAreDiscarded()
hub.Context = mockContext.Object;
hub.Clients = mockClients.Object;

mockDatabase.Setup(db => db.GetScoreFromToken(1234)).Returns(Task.FromResult<SoloScore?>(new SoloScore
mockDatabase.Setup(db => db.GetScoreFromTokenAsync(1234)).Returns(Task.FromResult<SoloScore?>(new SoloScore
{
id = 456,
passed = true
Expand Down Expand Up @@ -348,7 +348,7 @@ public async Task ScoresAreOnlySavedOnRankedBeatmaps(BeatmapOnlineStatus status,
hub.Context = mockContext.Object;
hub.Clients = mockClients.Object;

mockDatabase.Setup(db => db.GetScoreFromToken(1234)).Returns(Task.FromResult<SoloScore?>(new SoloScore
mockDatabase.Setup(db => db.GetScoreFromTokenAsync(1234)).Returns(Task.FromResult<SoloScore?>(new SoloScore
{
id = 456,
passed = true
Expand Down Expand Up @@ -434,7 +434,7 @@ public async Task ScoresHaveAllUserRelatedMetadataFilledOutConsistently()
hub.Context = mockContext.Object;
hub.Clients = mockClients.Object;

mockDatabase.Setup(db => db.GetScoreFromToken(1234)).Returns(Task.FromResult<SoloScore?>(new SoloScore
mockDatabase.Setup(db => db.GetScoreFromTokenAsync(1234)).Returns(Task.FromResult<SoloScore?>(new SoloScore
{
id = 456,
passed = true
Expand Down Expand Up @@ -497,7 +497,7 @@ public async Task FailedScoresAreNotSaved()
hub.Context = mockContext.Object;
hub.Clients = mockClients.Object;

mockDatabase.Setup(db => db.GetScoreFromToken(1234)).Returns(Task.FromResult<SoloScore?>(new SoloScore
mockDatabase.Setup(db => db.GetScoreFromTokenAsync(1234)).Returns(Task.FromResult<SoloScore?>(new SoloScore
{
id = 456,
passed = false
Expand Down Expand Up @@ -543,7 +543,7 @@ public async Task ScoreRankPopulatedCorrectly()
hub.Context = mockContext.Object;
hub.Clients = mockClients.Object;

mockDatabase.Setup(db => db.GetScoreFromToken(1234)).Returns(Task.FromResult<SoloScore?>(new SoloScore
mockDatabase.Setup(db => db.GetScoreFromTokenAsync(1234)).Returns(Task.FromResult<SoloScore?>(new SoloScore
{
id = 456,
passed = true
Expand Down
94 changes: 93 additions & 1 deletion osu.Server.Spectator/Database/DatabaseAccess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ public async Task<bool> IsUserRestrictedAsync(int userId)
{
var connection = await getConnectionAsync();

return await connection.QueryFirstOrDefaultAsync<multiplayer_room>("SELECT * FROM multiplayer_rooms WHERE id = @RoomID", new
{
RoomID = roomId
});
}

public async Task<multiplayer_room?> GetRealtimeRoomAsync(long roomId)
{
var connection = await getConnectionAsync();

return await connection.QueryFirstOrDefaultAsync<multiplayer_room>("SELECT * FROM multiplayer_rooms WHERE type != 'playlists' AND id = @RoomID", new
{
RoomID = roomId
Expand Down Expand Up @@ -294,7 +304,7 @@ public async Task MarkScoreHasReplay(Score score)
});
}

public async Task<SoloScore?> GetScoreFromToken(long token)
public async Task<SoloScore?> GetScoreFromTokenAsync(long token)
{
var connection = await getConnectionAsync();

Expand All @@ -305,6 +315,16 @@ public async Task MarkScoreHasReplay(Score score)
});
}

public async Task<SoloScore?> GetScoreAsync(long id)
{
var connection = await getConnectionAsync();

return await connection.QuerySingleOrDefaultAsync<SoloScore?>("SELECT * FROM `scores` WHERE `id` = @Id", new
{
Id = id
});
}

public async Task<bool> IsScoreProcessedAsync(long scoreId)
{
var connection = await getConnectionAsync();
Expand Down Expand Up @@ -382,6 +402,78 @@ public async Task<IEnumerable<multiplayer_room>> GetActiveDailyChallengeRoomsAsy
+ "AND `ends_at` > NOW()");
}

public async Task<(long roomID, long playlistItemID)?> GetMultiplayerRoomIdForScoreAsync(long scoreId)
{
var connection = await getConnectionAsync();

return await connection.QuerySingleOrDefaultAsync<(long, long)?>(
"SELECT `multiplayer_playlist_items`.`room_id`, `multiplayer_playlist_items`.`id` "
+ "FROM `multiplayer_score_links` "
+ "JOIN `multiplayer_playlist_items` "
+ "ON `multiplayer_score_links`.`playlist_item_id` = `multiplayer_playlist_items`.`id` "
+ "WHERE `multiplayer_score_links`.`score_id` = @scoreId",
new { scoreId = scoreId });
}

public async Task<MultiplayerPlaylistItemStats[]> GetMultiplayerRoomStatsAsync(long roomId)
{
var connection = await getConnectionAsync();

long[] playlistItemIds = (await GetAllPlaylistItemsAsync(roomId)).Select(item => item.id).ToArray();
var result = new MultiplayerPlaylistItemStats[playlistItemIds.Length];

for (int i = 0; i < playlistItemIds.Length; ++i)
{
long[] totalScores = (await connection.QueryAsync<long>(
"SELECT `scores`.`total_score` FROM `scores` "
+ "JOIN `multiplayer_score_links` ON `multiplayer_score_links`.`score_id` = `scores`.`id` "
+ "WHERE `multiplayer_score_links`.`playlist_item_id` = @playlistItemId", new
{
playlistItemId = playlistItemIds[i]
})).ToArray();

var totals = totalScores.GroupBy(score => (int)Math.Clamp(Math.Floor((float)score / 100000), 0, MultiplayerPlaylistItemStats.TOTAL_SCORE_DISTRIBUTION_BINS - 1))
.OrderBy(grp => grp.Key)
.ToDictionary(grp => grp.Key, grp => grp.LongCount());

var stats = new MultiplayerPlaylistItemStats
{
PlaylistItemID = playlistItemIds[i],
TotalScoreDistribution = Enumerable.Range(0, MultiplayerPlaylistItemStats.TOTAL_SCORE_DISTRIBUTION_BINS).Select(i => totals.GetValueOrDefault(i)).ToArray(),
};

result[i] = stats;
}

return result;
}

public async Task<multiplayer_scores_high?> GetUserBestScoreAsync(long playlistItemId, int userId)
{
var connection = await getConnectionAsync();

return await connection.QuerySingleOrDefaultAsync<multiplayer_scores_high>(
"SELECT * FROM `multiplayer_scores_high` WHERE `playlist_item_id` = @playlistItemId AND `user_id` = @userId", new
{
playlistItemId = playlistItemId,
userId = userId
});
}

public async Task<int> GetUserRankInRoomAsync(long roomId, int userId)
{
var connection = await getConnectionAsync();

return await connection.QuerySingleAsync<int>(
"SELECT COUNT(1) + 1 FROM `multiplayer_rooms_high` WHERE `room_id` = @roomId AND `user_id` != @userId "
+ "AND `total_score` > (SELECT `total_score` FROM `multiplayer_rooms_high` WHERE `room_id` = @roomId AND `user_id` = @userId)",
new
{
roomId = roomId,
userId = userId,
});
}

public void Dispose()
{
openConnection?.Dispose();
Expand Down
34 changes: 33 additions & 1 deletion osu.Server.Spectator/Database/IDatabaseAccess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ public interface IDatabaseAccess : IDisposable
/// </summary>
Task<multiplayer_room?> GetRoomAsync(long roomId);

/// <summary>
/// Returns the <see cref="multiplayer_room"/> with the given <paramref name="roomId"/>.
/// Rooms of type <see cref="database_match_type.playlists"/> are not returned by this method.
/// </summary>
Task<multiplayer_room?> GetRealtimeRoomAsync(long roomId);

/// <summary>
/// Retrieves a beatmap corresponding to the given <paramref name="beatmapId"/>.
/// </summary>
Expand Down Expand Up @@ -126,7 +132,12 @@ public interface IDatabaseAccess : IDisposable
/// </summary>
/// <param name="token">The score token.</param>
/// <returns>The <see cref="SoloScore"/>.</returns>
Task<SoloScore?> GetScoreFromToken(long token);
Task<SoloScore?> GetScoreFromTokenAsync(long token);

/// <summary>
/// Returns the <see cref="SoloScore"/> for the given ID.
/// </summary>
Task<SoloScore?> GetScoreAsync(long scoreId);

/// <summary>
/// Returns <see langword="true"/> if the score with the supplied <paramref name="scoreId"/> has been successfully processed.
Expand Down Expand Up @@ -167,5 +178,26 @@ public interface IDatabaseAccess : IDisposable
/// Retrieves all active rooms from the <see cref="room_category.daily_challenge"/> category.
/// </summary>
Task<IEnumerable<multiplayer_room>> GetActiveDailyChallengeRoomsAsync();

/// <summary>
/// If <paramref name="scoreId"/> is associated with a multiplayer score, returns the room ID and playlist item ID which the score was set on.
/// Otherwise, returns <see langword="null"/>.
/// </summary>
Task<(long roomID, long playlistItemID)?> GetMultiplayerRoomIdForScoreAsync(long scoreId);

/// <summary>
/// Returns <see cref="MultiplayerPlaylistItemStats"/> for all playlist items in the room with the given <paramref name="roomId"/>.
/// </summary>
Task<MultiplayerPlaylistItemStats[]> GetMultiplayerRoomStatsAsync(long roomId);

/// <summary>
/// Returns the best score of user with <paramref name="userId"/> on the playlist item with <paramref name="playlistItemId"/>.
/// </summary>
Task<multiplayer_scores_high?> GetUserBestScoreAsync(long playlistItemId, int userId);

/// <summary>
/// Gets the overall rank of user <paramref name="userId"/> in the room with <paramref name="roomId"/>.
/// </summary>
Task<int> GetUserRankInRoomAsync(long roomId, int userId);
}
}
Loading

0 comments on commit 8bf0287

Please sign in to comment.