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

Always broadcast user presence to users' friends #255

Merged
merged 13 commits into from
Jan 15, 2025
10 changes: 5 additions & 5 deletions SampleMultiplayerClient/SampleMultiplayerClient.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
<PackageReference Include="ppy.osu.Game" Version="2024.1208.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.0" />
<PackageReference Include="ppy.osu.Game" Version="2025.114.0" />
</ItemGroup>

</Project>
10 changes: 5 additions & 5 deletions SampleSpectatorClient/SampleSpectatorClient.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
<PackageReference Include="ppy.osu.Game" Version="2024.1208.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="9.0.0" />
<PackageReference Include="ppy.osu.Game" Version="2025.114.0" />
</ItemGroup>

</Project>
98 changes: 88 additions & 10 deletions osu.Server.Spectator.Tests/MetadataHubTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ public class MetadataHubTest

private readonly MetadataHub hub;
private readonly EntityStore<MetadataClientState> userStates;
private readonly Mock<HubCallerContext> mockUserContext;
private readonly Mock<IMetadataClient> mockCaller;
private readonly Mock<IMetadataClient> mockWatchersGroup;
private readonly Mock<IGroupManager> mockGroupManager;
private readonly Mock<IDatabaseAccess> mockDatabase;
private readonly Mock<IHubCallerClients<IMetadataClient>> mockClients;

public MetadataHubTest()
{
Expand All @@ -48,25 +50,21 @@ public MetadataHubTest()
new Mock<IDailyChallengeUpdater>().Object,
new Mock<IScoreProcessedSubscriber>().Object);

var mockContext = new Mock<HubCallerContext>();
mockContext.Setup(ctx => ctx.UserIdentifier).Returns(user_id.ToString());

mockWatchersGroup = new Mock<IMetadataClient>();
mockCaller = new Mock<IMetadataClient>();
mockGroupManager = new Mock<IGroupManager>();

var mockClients = new Mock<IHubCallerClients<IMetadataClient>>();
mockClients = new Mock<IHubCallerClients<IMetadataClient>>();
mockClients.Setup(clients => clients.Group(It.IsAny<string>()))
.Returns(new Mock<IMetadataClient>().Object);
mockClients.Setup(clients => clients.Group(MetadataHub.ONLINE_PRESENCE_WATCHERS_GROUP))
.Returns(mockWatchersGroup.Object);
mockClients.Setup(clients => clients.Caller)
.Returns(mockCaller.Object);

mockGroupManager = new Mock<IGroupManager>();

// this is to ensure that the `Context.GetHttpContext()` call in `MetadataHub.OnConnectedAsync()` doesn't nullref
// (the method in question is an extension, and it accesses `Features`; mocking further is not required).
mockContext.Setup(ctx => ctx.Features).Returns(new Mock<IFeatureCollection>().Object);
mockUserContext = createUserContext(user_id);

hub.Context = mockContext.Object;
hub.Context = mockUserContext.Object;
hub.Clients = mockClients.Object;
hub.Groups = mockGroupManager.Object;
}
Expand Down Expand Up @@ -227,5 +225,85 @@ public async Task UserWatchingHandling()
mgr => mgr.RemoveFromGroupAsync(It.IsAny<string>(), MetadataHub.ONLINE_PRESENCE_WATCHERS_GROUP, It.IsAny<CancellationToken>()),
Times.Once);
}

[Fact]
public async Task UserFriendsAlwaysNotified()
{
const int friend_id = 56;
const int non_friend_id = 57;

Mock<HubCallerContext> friendContext = createUserContext(friend_id);
Mock<HubCallerContext> nonFriendContext = createUserContext(non_friend_id);

mockDatabase.Setup(d => d.GetUserFriendsAsync(user_id)).ReturnsAsync([friend_id]);
mockClients.Setup(clients => clients.Group(MetadataHub.FRIEND_PRESENCE_WATCHERS_GROUP(friend_id)))
.Returns(() => mockCaller.Object);

await hub.OnConnectedAsync();

// Friend connects...
hub.Context = friendContext.Object;
await hub.OnConnectedAsync();
await hub.UpdateStatus(UserStatus.Online);
mockCaller.Verify(c => c.FriendPresenceUpdated(friend_id, It.Is<UserPresence>(p => p.Status == UserStatus.Online)), Times.Once);

// Non-friend connects...
hub.Context = nonFriendContext.Object;
await hub.OnConnectedAsync();
await hub.UpdateStatus(UserStatus.Online);
mockCaller.Verify(c => c.FriendPresenceUpdated(non_friend_id, It.IsAny<UserPresence>()), Times.Never);

// Friend disconnects...
hub.Context = friendContext.Object;
await hub.OnDisconnectedAsync(null);
mockCaller.Verify(c => c.FriendPresenceUpdated(friend_id, null), Times.Once);

// Non-friend disconnects...
hub.Context = nonFriendContext.Object;
await hub.OnDisconnectedAsync(null);
mockCaller.Verify(c => c.FriendPresenceUpdated(non_friend_id, It.IsAny<UserPresence>()), Times.Never);
}

[Fact]
public async Task FriendPresenceBroadcastWhenConnected()
{
const int friend_id = 56;
const int non_friend_id = 57;

Mock<HubCallerContext> friendContext = createUserContext(friend_id);
Mock<HubCallerContext> nonFriendContext = createUserContext(non_friend_id);

mockDatabase.Setup(d => d.GetUserFriendsAsync(user_id)).ReturnsAsync([friend_id]);
mockClients.Setup(clients => clients.Group(MetadataHub.FRIEND_PRESENCE_WATCHERS_GROUP(friend_id)))
.Returns(() => mockCaller.Object);

// Friend connects...
hub.Context = friendContext.Object;
await hub.OnConnectedAsync();
await hub.UpdateStatus(UserStatus.Online);

// Non-friend connects...
hub.Context = nonFriendContext.Object;
await hub.OnConnectedAsync();
await hub.UpdateStatus(UserStatus.Online);

// We connect...
mockCaller.Invocations.Clear();
hub.Context = mockUserContext.Object;
await hub.OnConnectedAsync();
mockCaller.Verify(c => c.FriendPresenceUpdated(friend_id, It.IsAny<UserPresence>()), Times.Once);
mockCaller.Verify(c => c.FriendPresenceUpdated(non_friend_id, It.IsAny<UserPresence>()), Times.Never);
}

private Mock<HubCallerContext> createUserContext(int userId)
{
var mockContext = new Mock<HubCallerContext>();
mockContext.Setup(ctx => ctx.ConnectionId).Returns(userId.ToString());
mockContext.Setup(ctx => ctx.UserIdentifier).Returns(userId.ToString());
// this is to ensure that the `Context.GetHttpContext()` call in `MetadataHub.OnConnectedAsync()` doesn't nullref
// (the method in question is an extension, and it accesses `Features`; mocking further is not required).
mockContext.Setup(ctx => ctx.Features).Returns(new Mock<IFeatureCollection>().Object);
return mockContext;
}
}
}
8 changes: 4 additions & 4 deletions osu.Server.Spectator.Tests/osu.Server.Spectator.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Moq" Version="4.18.3" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PackageReference Include="coverlet.collector" Version="6.0.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
Expand Down
16 changes: 16 additions & 0 deletions osu.Server.Spectator/Database/DatabaseAccess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,22 @@ public async Task<bool> IsScoreProcessedAsync(long scoreId)
});
}

public async Task<IEnumerable<int>> GetUserFriendsAsync(int userId)
{
var connection = await getConnectionAsync();

// Query pulled from osu!bancho.
return await connection.QueryAsync<int>(
"SELECT zebra_id FROM phpbb_zebra z "
+ "JOIN phpbb_users u ON z.zebra_id = u.user_id "
+ "WHERE z.user_id = @UserId "
+ "AND friend = 1 "
+ "AND (`user_warnings` = '0' and `user_type` = '0')", new
{
UserId = userId
});
}

public async Task<bool> GetUserAllowsPMs(int userId)
{
var connection = await getConnectionAsync();
Expand Down
5 changes: 5 additions & 0 deletions osu.Server.Spectator/Database/IDatabaseAccess.cs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,11 @@ public interface IDatabaseAccess : IDisposable
/// </summary>
Task<phpbb_zebra?> GetUserRelation(int userId, int zebraId);

/// <summary>
/// Lists the specified user's friends.
/// </summary>
Task<IEnumerable<int>> GetUserFriendsAsync(int userId);

/// <summary>
/// Returns <see langword="true"/> if the user with the supplied <paramref name="userId"/> allows private messages from people not on their friends list.
/// </summary>
Expand Down
33 changes: 33 additions & 0 deletions osu.Server.Spectator/Entities/EntityStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,39 @@ public async Task<ItemUsage<T>> GetForUse(long id, bool createOnMissing = false)
throw new TimeoutException("Could not allocate new entity after multiple retries. Something very bad has happened");
}

/// <summary>
/// Attempts to retrieve an existing entity with a lock for use.
/// </summary>
/// <param name="id">The ID of the requested entity.</param>
/// <returns>An existing <see cref="ItemUsage{T}"/> which allows reading or writing the item, or <c>null</c> if no entity exists. This should be disposed after usage.</returns>
public async Task<ItemUsage<T>?> TryGetForUse(long id)
{
TrackedEntity? item;

lock (entityMapping)
{
if (!entityMapping.TryGetValue(id, out item))
{
DogStatsd.Increment($"{statsDPrefix}.get-notfound");
return null;
}
}

try
{
await item.ObtainLockAsync();
Copy link
Member

Choose a reason for hiding this comment

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

For this particular case, do we actually need a lock? Wondering if it can be avoided..

Copy link
Contributor Author

@smoogipoo smoogipoo Jan 14, 2025

Choose a reason for hiding this comment

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

Yes, because ItemUsage.Item enforces that the item must be locked:

public T? Item
{
get
{
checkValidForUse();
return item;
}

A deadlock concern is valid here, saved by the fact that EntityStore will continuously retry taking the lock over 5 seconds, before throwing a timeout exception. So I think it'll be fine after all.

I've tried to resolve this in a more "proper" way, and have come up with the following. I'm not sure if it's over-engineered (the hub part), and likely not over-engineered enough:

diff --git a/osu.Server.Spectator/Entities/EntityStore.cs b/osu.Server.Spectator/Entities/EntityStore.cs
index ebeb4dc..42b7af2 100644
--- a/osu.Server.Spectator/Entities/EntityStore.cs
+++ b/osu.Server.Spectator/Entities/EntityStore.cs
@@ -6,6 +6,7 @@ using System.Collections.Generic;
 using System.Linq;
 using System.Threading;
 using System.Threading.Tasks;
+using JetBrains.Annotations;
 using osu.Framework.Extensions.ObjectExtensions;
 using StatsdClient;
 
@@ -32,13 +33,19 @@ namespace osu.Server.Spectator.Entities
         {
             get
             {
-                lock (entityMapping)
+                using (GetRetrievalLock())
                     return entityMapping.Count;
             }
         }
 
         public string EntityName => typeof(T).Name;
 
+        /// <summary>
+        /// Locks this <see cref="EntityStore{T}"/> for retrieval of an entity.
+        /// </summary>
+        [MustDisposeResource]
+        public EntityStoreLock GetRetrievalLock() => new EntityStoreLock(entityMapping);
+
         /// <summary>
         /// Retrieves an entity.
         /// </summary>
@@ -49,7 +56,7 @@ namespace osu.Server.Spectator.Entities
         /// <returns>The entity.</returns>
         public T? GetEntityUnsafe(long id)
         {
-            lock (entityMapping)
+            using (GetRetrievalLock())
                 return !entityMapping.TryGetValue(id, out var entity) ? null : entity.GetItemUnsafe();
         }
 
@@ -68,7 +75,7 @@ namespace osu.Server.Spectator.Entities
             {
                 TrackedEntity? item;
 
-                lock (entityMapping)
+                using (GetRetrievalLock())
                 {
                     if (!entityMapping.TryGetValue(id, out item))
                     {
@@ -119,7 +126,7 @@ namespace osu.Server.Spectator.Entities
         {
             TrackedEntity? item;
 
-            lock (entityMapping)
+            using (GetRetrievalLock())
             {
                 if (!entityMapping.TryGetValue(id, out item))
                 {
@@ -147,7 +154,7 @@ namespace osu.Server.Spectator.Entities
         {
             TrackedEntity? item;
 
-            lock (entityMapping)
+            using (GetRetrievalLock())
             {
                 if (!entityMapping.TryGetValue(id, out item))
                     // was not tracking.
@@ -173,7 +180,7 @@ namespace osu.Server.Spectator.Entities
         /// </summary>
         public KeyValuePair<long, T>[] GetAllEntities()
         {
-            lock (entityMapping)
+            using (GetRetrievalLock())
             {
                 return entityMapping
                        .Where(kvp => kvp.Value.GetItemUnsafe() != null)
@@ -187,7 +194,7 @@ namespace osu.Server.Spectator.Entities
         /// </summary>
         public void Clear()
         {
-            lock (entityMapping)
+            using (GetRetrievalLock())
             {
                 entityMapping.Clear();
             }
@@ -195,7 +202,7 @@ namespace osu.Server.Spectator.Entities
 
         private void remove(long id)
         {
-            lock (entityMapping)
+            using (GetRetrievalLock())
             {
                 entityMapping.Remove(id);
 
@@ -290,5 +297,28 @@ namespace osu.Server.Spectator.Entities
                 if (shouldBeLocked && !isLocked) throw new InvalidOperationException("Attempted to access a tracked entity without holding a lock");
             }
         }
+
+        [MustDisposeResource]
+        public class EntityStoreLock : IDisposable
+        {
+            private readonly object lockObject;
+
+            public EntityStoreLock(object lockObject)
+            {
+                this.lockObject = lockObject;
+                Monitor.Enter(lockObject);
+            }
+
+            public void Dispose()
+            {
+                try
+                {
+                    Monitor.Exit(lockObject);
+                }
+                catch
+                {
+                }
+            }
+        }
     }
 }
diff --git a/osu.Server.Spectator/Hubs/Metadata/MetadataHub.cs b/osu.Server.Spectator/Hubs/Metadata/MetadataHub.cs
index 9a3e12c..65ddd0b 100644
--- a/osu.Server.Spectator/Hubs/Metadata/MetadataHub.cs
+++ b/osu.Server.Spectator/Hubs/Metadata/MetadataHub.cs
@@ -52,6 +52,8 @@ namespace osu.Server.Spectator.Hubs.Metadata
         {
             await base.OnConnectedAsync();
 
+            var friendUserIds = new List<int>();
+
             using (var usage = await GetOrCreateLocalUserState())
             {
                 string? versionHash = null;
@@ -76,16 +78,31 @@ namespace osu.Server.Spectator.Hubs.Metadata
                     foreach (int friendId in await db.GetUserFriendsAsync(usage.Item.UserId))
                     {
                         await Groups.AddToGroupAsync(Context.ConnectionId, FRIEND_PRESENCE_WATCHERS_GROUP(friendId));
-
-                        // Check if the friend is online, and if they are, broadcast to the connected user.
-                        using (var friendUsage = await TryGetStateFromUser(friendId))
-                        {
-                            if (friendUsage?.Item != null && shouldBroadcastPresenceToOtherUsers(friendUsage.Item))
-                                await Clients.Caller.FriendPresenceUpdated(friendId, friendUsage.Item.ToUserPresence());
-                        }
+                        friendUserIds.Add(friendId);
                     }
                 }
             }
+
+            // Broadcast online friend states to the newly connected user.
+
+            // We're using multiple user states here...
+            using (UserStates.GetRetrievalLock())
+            {
+                using var userUsage = await TryGetStateFromUser(Context.GetUserId());
+
+                if (userUsage?.Item == null)
+                    return;
+
+                foreach (int friendId in friendUserIds)
+                {
+                    using var friendUsage = await TryGetStateFromUser(friendId);
+
+                    if (friendUsage?.Item == null || !shouldBroadcastPresenceToOtherUsers(friendUsage.Item))
+                        continue;
+
+                    await Clients.Caller.FriendPresenceUpdated(friendId, friendUsage.Item.ToUserPresence());
+                }
+            }
         }
 
         private async Task logLogin(ItemUsage<MetadataClientState> usage)

Copy link
Member

Choose a reason for hiding this comment

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

Aha, I see.

Let's... save this for another time 😅 .

}
// this may be thrown if the item was destroyed between when we retrieved the item usage and took the lock.
catch (InvalidOperationException)
{
DogStatsd.Increment($"{statsDPrefix}.get-notfound");
return null;
}

DogStatsd.Increment($"{statsDPrefix}.get");
return new ItemUsage<T>(item);
}

public async Task Destroy(long id)
{
TrackedEntity? item;
Expand Down
22 changes: 21 additions & 1 deletion osu.Server.Spectator/Hubs/Metadata/MetadataHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public class MetadataHub : StatefulUserHub<IMetadataClient, MetadataClientState>
private readonly IScoreProcessedSubscriber scoreProcessedSubscriber;

internal const string ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers";
internal static string FRIEND_PRESENCE_WATCHERS_GROUP(int userId) => $"metadata:online-presence-watchers:{userId}";

internal static string MultiplayerRoomWatchersGroup(long roomId) => $"metadata:multiplayer-room-watchers:{roomId}";

Expand Down Expand Up @@ -69,6 +70,21 @@ public override async Task OnConnectedAsync()

await logLogin(usage);
await Clients.Caller.DailyChallengeUpdated(dailyChallengeUpdater.Current);

using (var db = databaseFactory.GetInstance())
{
foreach (int friendId in await db.GetUserFriendsAsync(usage.Item.UserId))
{
await Groups.AddToGroupAsync(Context.ConnectionId, FRIEND_PRESENCE_WATCHERS_GROUP(friendId));
peppy marked this conversation as resolved.
Show resolved Hide resolved

// Check if the friend is online, and if they are, broadcast to the connected user.
using (var friendUsage = await TryGetStateFromUser(friendId))
{
if (friendUsage?.Item != null && shouldBroadcastPresenceToOtherUsers(friendUsage.Item))
await Clients.Caller.FriendPresenceUpdated(friendId, friendUsage.Item.ToUserPresence());
}
}
}
}
}

Expand Down Expand Up @@ -222,7 +238,11 @@ private Task broadcastUserPresenceUpdate(int userId, UserPresence? userPresence)
// we never want appearing offline users to have their status broadcast to other clients.
Debug.Assert(userPresence?.Status != UserStatus.Offline);

return Clients.Group(ONLINE_PRESENCE_WATCHERS_GROUP).UserPresenceUpdated(userId, userPresence);
return Task.WhenAll
(
Clients.Group(ONLINE_PRESENCE_WATCHERS_GROUP).UserPresenceUpdated(userId, userPresence),
Clients.Group(FRIEND_PRESENCE_WATCHERS_GROUP(userId)).FriendPresenceUpdated(userId, userPresence)
Comment on lines +243 to +244
Copy link
Member

Choose a reason for hiding this comment

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

I guess clients are going to get double status updates for friends, but probably fine for now.

);
}

private bool shouldBroadcastPresenceToOtherUsers(MetadataClientState state)
Expand Down
2 changes: 2 additions & 0 deletions osu.Server.Spectator/Hubs/StatefulUserHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,5 +122,7 @@ protected async Task<ItemUsage<TUserState>> GetOrCreateLocalUserState()
}

protected Task<ItemUsage<TUserState>> GetStateFromUser(int userId) => UserStates.GetForUse(userId);

protected Task<ItemUsage<TUserState>?> TryGetStateFromUser(int userId) => UserStates.TryGetForUse(userId);
}
}
24 changes: 12 additions & 12 deletions osu.Server.Spectator/osu.Server.Spectator.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,22 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="AWSSDK.S3" Version="3.7.405" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
<PackageReference Include="AWSSDK.S3" Version="3.7.411.6" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.5.0" />
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="DogStatsD-CSharp-Client" Version="8.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="9.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="8.0.1" />
<PackageReference Include="ppy.osu.Game" Version="2024.1208.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Catch" Version="2024.1208.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Mania" Version="2024.1208.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Osu" Version="2024.1208.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Taiko" Version="2024.1208.0" />
<PackageReference Include="ppy.osu.Server.OsuQueueProcessor" Version="2024.507.0" />
<PackageReference Include="Sentry.AspNetCore" Version="4.12.1" />
<PackageReference Include="ppy.osu.Game" Version="2025.114.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Catch" Version="2025.114.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Mania" Version="2025.114.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Osu" Version="2025.114.0" />
<PackageReference Include="ppy.osu.Game.Rulesets.Taiko" Version="2025.114.0" />
<PackageReference Include="ppy.osu.Server.OsuQueueProcessor" Version="2024.1111.0" />
<PackageReference Include="Sentry.AspNetCore" Version="5.0.1" />
</ItemGroup>

<ItemGroup>
Expand Down