Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow clients to receive realtime updates for a given playlist #237

Merged
merged 3 commits into from
Jun 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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>(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how well this will scale, but I think we can go life with it and deal with that later.

Non-representative tests as we'll likely see a lot more usage than most existing playlists:

select playlist_item_id, count(score_id) from multiplayer_score_links group by playlist_item_id order by
                 -> count(score_id) desc limit 10;
+------------------+-----------------+
| playlist_item_id | count(score_id) |
+------------------+-----------------+
| 5415805          | 1090            |
| 7091206          | 353             |
| 5415807          | 321             |
| 5827890          | 291             |
| 4350065          | 280             |
| 4296355          | 279             |
| 5415798          | 253             |
| 5344648          | 250             |
| 7091692          | 249             |
| 4296362          | 242             |
+------------------+-----------------+

-- cold query (includes japan-us roundtrip)
select sum(total_score) from multiplayer_score_links join scores on (score_id = scores.id) where playlist_item_id = 5415805;
+------------------+
| sum(total_score) |
+------------------+
| 32986707         |
+------------------+

1 row in set
Time: 0.504s

-- warm query (includes japan-us roundtrip)
select sum(total_score) from multiplayer_score_links join scores on (score_id = scores.id) where playlist_item_id = 5415805;
+------------------+
| sum(total_score) |
+------------------+
| 32986707         |
+------------------+

1 row in set
Time: 0.130s

It shouldn't be a huge reach to cache these values if we need to, as well.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it's any solace the intention is that this will only be requested whenever someone enters the daily challenge screen.

Caching can 100% be done even now. It's non-critical data, nobody is going to care about perfect accuracy of this.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, aware of that. I'm also against caching until we need to.

"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