Skip to content

Commit

Permalink
Merge pull request #265 from smoogipoo/room-management-lio
Browse files Browse the repository at this point in the history
Add support for creating, joining, and parting osu!web rooms via interop
  • Loading branch information
peppy authored Feb 11, 2025
2 parents 094f3ad + 0a53433 commit 9716e12
Show file tree
Hide file tree
Showing 11 changed files with 353 additions and 32 deletions.
32 changes: 17 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,21 @@ To deploy this as part of a full osu! server stack deployment, [this wiki page](

For advanced testing purposes.

| Envvar name | Description | Default value |
| :- | :- | :- |
| `SAVE_REPLAYS` | Whether to save received replay frames from clients to replay files. `1` to enable, any other value to disable. | `""` (disabled) |
| `REPLAY_UPLOAD_THREADS` | Number of threads to use when uploading complete replays. Must be positive number. | `1` |
| `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/` |
| `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. | `""` |
| `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. | `""` |
| `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. | `""` |
| `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) |
| `SERVER_PORT` | Which port the server should listen on for incoming connections. | `80` |
| `REDIS_HOST` | Connection string to `osu-web` Redis instance. | `localhost` |
| `DD_AGENT_HOST` | Hostname under which the [Datadog](https://www.datadoghq.com/) agent host can be found. | `localhost` |
| `DB_HOST` | Hostname under which the `osu-web` MySQL instance can be found. | `localhost` |
| `DB_PORT` | Port under which the `osu-web` MySQL instance can be found. | `3306` |
| `DB_USER` | Username to use when logging into the `osu-web` MySQL instance. | `osuweb` |
| Envvar name | Description | Default value |
| :- | :- |:------------------|
| `SAVE_REPLAYS` | Whether to save received replay frames from clients to replay files. `1` to enable, any other value to disable. | `""` (disabled) |
| `REPLAY_UPLOAD_THREADS` | Number of threads to use when uploading complete replays. Must be positive number. | `1` |
| `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/` |
| `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. | `""` |
| `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. | `""` |
| `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. | `""` |
| `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) |
| `SERVER_PORT` | Which port the server should listen on for incoming connections. | `80` |
| `REDIS_HOST` | Connection string to `osu-web` Redis instance. | `localhost` |
| `DD_AGENT_HOST` | Hostname under which the [Datadog](https://www.datadoghq.com/) agent host can be found. | `localhost` |
| `DB_HOST` | Hostname under which the `osu-web` MySQL instance can be found. | `localhost` |
| `DB_PORT` | Port under which the `osu-web` MySQL instance can be found. | `3306` |
| `DB_USER` | Username to use when logging into the `osu-web` MySQL instance. | `osuweb` |
| `SENTRY_DSN` | A valid Sentry DSN to use for logging application events. | `null` (required in production) |
| `LEGACY_IO_DOMAIN` | The root URL of the osu-web instance to which legacy IO call should be submitted | `null` (required) |
| `SHARED_INTEROP_SECRET` | The value of the same environment variable that the target osu-web instance specifies in `.env`. | `null` (required) |
8 changes: 2 additions & 6 deletions SampleMultiplayerClient/MultiplayerClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,10 @@ public MultiplayerClient(HubConnection connection, int userId)
public MultiplayerRoom? Room { get; private set; }

public async Task<MultiplayerRoom> JoinRoom(long roomId)
{
return await JoinRoomWithPassword(roomId, string.Empty);
}
=> await JoinRoomWithPassword(roomId, string.Empty);

public async Task<MultiplayerRoom> JoinRoomWithPassword(long roomId, string? password = null)
{
return Room = await connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoomWithPassword), roomId, password ?? string.Empty);
}
=> Room = await connection.InvokeAsync<MultiplayerRoom>(nameof(IMultiplayerServer.JoinRoomWithPassword), roomId, password ?? string.Empty);

public async Task LeaveRoom()
{
Expand Down
8 changes: 7 additions & 1 deletion osu.Server.Spectator.Tests/Multiplayer/MultiplayerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using osu.Server.Spectator.Database.Models;
using osu.Server.Spectator.Entities;
using osu.Server.Spectator.Hubs.Multiplayer;
using osu.Server.Spectator.Services;

namespace osu.Server.Spectator.Tests.Multiplayer
{
Expand All @@ -37,6 +38,8 @@ public abstract class MultiplayerTest
protected readonly Mock<IDatabaseFactory> DatabaseFactory;
protected readonly Mock<IDatabaseAccess> Database;

protected readonly Mock<ILegacyIO> LegacyIO;

/// <summary>
/// A general non-gameplay receiver for the room with ID <see cref="ROOM_ID"/>.
/// </summary>
Expand Down Expand Up @@ -130,13 +133,16 @@ protected MultiplayerTest()
loggerFactoryMock.Setup(factory => factory.CreateLogger(It.IsAny<string>()))
.Returns(new Mock<ILogger>().Object);

LegacyIO = new Mock<ILegacyIO>();

Hub = new TestMultiplayerHub(
loggerFactoryMock.Object,
Rooms,
UserStates,
DatabaseFactory.Object,
new ChatFilters(DatabaseFactory.Object),
hubContext.Object);
hubContext.Object,
LegacyIO.Object);
Hub.Groups = Groups.Object;
Hub.Clients = Clients.Object;

Expand Down
44 changes: 44 additions & 0 deletions osu.Server.Spectator.Tests/Multiplayer/RoomInteropTest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Moq;
using osu.Game.Online.Multiplayer;
using Xunit;

namespace osu.Server.Spectator.Tests.Multiplayer
{
public class RoomInteropTest : MultiplayerTest
{
[Fact]
public async Task CreateRoom()
{
LegacyIO.Setup(io => io.CreateRoomAsync(It.IsAny<int>(), It.IsAny<MultiplayerRoom>()))
.ReturnsAsync(() => ROOM_ID);

await Hub.CreateRoom(new MultiplayerRoom(0));
LegacyIO.Verify(io => io.CreateRoomAsync(USER_ID, It.IsAny<MultiplayerRoom>()), Times.Once);
LegacyIO.Verify(io => io.AddUserToRoomAsync(ROOM_ID, USER_ID), Times.Once);

using (var usage = await Hub.GetRoom(ROOM_ID))
{
Assert.NotNull(usage.Item);
Assert.Equal(USER_ID, usage.Item.Users.Single().UserID);
}
}

[Fact]
public async Task LeaveRoom()
{
await Hub.JoinRoom(ROOM_ID);
LegacyIO.Verify(io => io.RemoveUserFromRoomAsync(ROOM_ID, USER_ID), Times.Never);

await Hub.LeaveRoom();
LegacyIO.Verify(io => io.RemoveUserFromRoomAsync(ROOM_ID, USER_ID), Times.Once);

await Assert.ThrowsAsync<KeyNotFoundException>(() => Hub.GetRoom(ROOM_ID));
}
}
}
6 changes: 4 additions & 2 deletions osu.Server.Spectator.Tests/Multiplayer/TestMultiplayerHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using osu.Server.Spectator.Database;
using osu.Server.Spectator.Entities;
using osu.Server.Spectator.Hubs.Multiplayer;
using osu.Server.Spectator.Services;

namespace osu.Server.Spectator.Tests.Multiplayer
{
Expand All @@ -19,8 +20,9 @@ public TestMultiplayerHub(
EntityStore<MultiplayerClientState> users,
IDatabaseFactory databaseFactory,
ChatFilters chatFilters,
IHubContext<MultiplayerHub> hubContext)
: base(loggerFactory, rooms, users, databaseFactory, chatFilters, hubContext)
IHubContext<MultiplayerHub> hubContext,
ILegacyIO legacyIO)
: base(loggerFactory, rooms, users, databaseFactory, chatFilters, hubContext, legacyIO)
{
}

Expand Down
1 change: 1 addition & 0 deletions osu.Server.Spectator.sln.DotSettings
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,7 @@
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=HUD/@EntryIndexedValue">HUD</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=ID/@EntryIndexedValue">ID</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IL/@EntryIndexedValue">IL</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IO/@EntryIndexedValue">IO</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IOS/@EntryIndexedValue">IOS</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IP/@EntryIndexedValue">IP</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=IPC/@EntryIndexedValue">IPC</s:String>
Expand Down
6 changes: 6 additions & 0 deletions osu.Server.Spectator/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ public static class AppSettings
public static string DatabaseUser { get; }
public static string DatabasePort { get; }

public static string LegacyIODomain { get; }
public static string SharedInteropSecret { get; }

public static string? SentryDsn { get; }

static AppSettings()
Expand All @@ -56,6 +59,9 @@ static AppSettings()
DatabaseUser = Environment.GetEnvironmentVariable("DB_USER") ?? "osuweb";
DatabasePort = Environment.GetEnvironmentVariable("DB_PORT") ?? "3306";

LegacyIODomain = Environment.GetEnvironmentVariable("LEGACY_IO_DOMAIN") ?? string.Empty;
SharedInteropSecret = Environment.GetEnvironmentVariable("SHARED_INTEROP_SECRET") ?? string.Empty;

SentryDsn = Environment.GetEnvironmentVariable("SENTRY_DSN");
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using osu.Server.Spectator.Hubs.Metadata;
using osu.Server.Spectator.Hubs.Multiplayer;
using osu.Server.Spectator.Hubs.Spectator;
using osu.Server.Spectator.Services;
using osu.Server.Spectator.Storage;
using StackExchange.Redis;

Expand All @@ -17,7 +18,9 @@ public static class ServiceCollectionExtensions
{
public static IServiceCollection AddHubEntities(this IServiceCollection serviceCollection)
{
return serviceCollection.AddSingleton<EntityStore<SpectatorClientState>>()
return serviceCollection.AddHttpClient()
.AddTransient<ILegacyIO, LegacyIO>()
.AddSingleton<EntityStore<SpectatorClientState>>()
.AddSingleton<EntityStore<MultiplayerClientState>>()
.AddSingleton<EntityStore<ServerMultiplayerRoom>>()
.AddSingleton<EntityStore<ConnectionState>>()
Expand Down
31 changes: 24 additions & 7 deletions osu.Server.Spectator/Hubs/Multiplayer/MultiplayerHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
using osu.Server.Spectator.Database.Models;
using osu.Server.Spectator.Entities;
using osu.Server.Spectator.Extensions;
using osu.Server.Spectator.Services;

namespace osu.Server.Spectator.Hubs.Multiplayer
{
Expand All @@ -25,34 +26,48 @@ public class MultiplayerHub : StatefulUserHub<IMultiplayerClient, MultiplayerCli
protected readonly MultiplayerHubContext HubContext;
private readonly IDatabaseFactory databaseFactory;
private readonly ChatFilters chatFilters;
private readonly ILegacyIO legacyIO;

public MultiplayerHub(
ILoggerFactory loggerFactory,
EntityStore<ServerMultiplayerRoom> rooms,
EntityStore<MultiplayerClientState> users,
IDatabaseFactory databaseFactory,
ChatFilters chatFilters,
IHubContext<MultiplayerHub> hubContext)
IHubContext<MultiplayerHub> hubContext,
ILegacyIO legacyIO)
: base(loggerFactory, users)
{
Rooms = rooms;
this.databaseFactory = databaseFactory;
this.chatFilters = chatFilters;
this.legacyIO = legacyIO;

Rooms = rooms;
HubContext = new MultiplayerHubContext(hubContext, rooms, users, loggerFactory, databaseFactory);
}

public async Task<MultiplayerRoom> CreateRoom(MultiplayerRoom room)
{
Log($"{Context.GetUserId()} creating room");

long roomId = await legacyIO.CreateRoomAsync(Context.GetUserId(), room);

return await JoinRoomWithPassword(roomId, room.Settings.Password);
}

public Task<MultiplayerRoom> JoinRoom(long roomId) => JoinRoomWithPassword(roomId, string.Empty);

public async Task<MultiplayerRoom> JoinRoomWithPassword(long roomId, string password)
{
Log($"Attempting to join room {roomId}");

bool isRestricted;
using (var db = databaseFactory.GetInstance())
isRestricted = await db.IsUserRestrictedAsync(Context.GetUserId());
{
if (await db.IsUserRestrictedAsync(Context.GetUserId()))
throw new InvalidStateException("Can't join a room when restricted.");
}

if (isRestricted)
throw new InvalidStateException("Can't join a room when restricted.");
await legacyIO.AddUserToRoomAsync(roomId, Context.GetUserId());

using (var userUsage = await GetOrCreateLocalUserState())
{
Expand Down Expand Up @@ -739,8 +754,8 @@ private async Task removeDatabaseUser(MultiplayerRoom room, MultiplayerRoomUser

protected override async Task CleanUpState(MultiplayerClientState state)
{
await leaveRoom(state, true);
await base.CleanUpState(state);
await leaveRoom(state, true);
}

private async Task setNewHost(MultiplayerRoom room, MultiplayerRoomUser newHost)
Expand Down Expand Up @@ -902,6 +917,8 @@ private async Task<ItemUsage<ServerMultiplayerRoom>> getLocalUserRoom(Multiplaye

private async Task leaveRoom(MultiplayerClientState state, bool wasKick)
{
await legacyIO.RemoveUserFromRoomAsync(state.CurrentRoomID, state.UserId);

using (var roomUsage = await getLocalUserRoom(state))
await leaveRoom(state, roomUsage, wasKick);
}
Expand Down
42 changes: 42 additions & 0 deletions osu.Server.Spectator/Services/ILegacyIO.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.

using System.Threading.Tasks;
using osu.Game.Online.Multiplayer;

namespace osu.Server.Spectator.Services
{
public interface ILegacyIO
{
/// <summary>
/// Creates an osu!web room.
/// </summary>
/// <remarks>
/// This does not join the creating user to the room. A subsequent call to <see cref="AddUserToRoomAsync"/> should be made if required.
/// </remarks>
/// <param name="hostUserId">The ID of the user that wants to create the room.</param>
/// <param name="room">The room.</param>
/// <returns>The room's ID.</returns>
Task<long> CreateRoomAsync(int hostUserId, MultiplayerRoom room);

/// <summary>
/// Adds a user to an osu!web room.
/// </summary>
/// <remarks>
/// This performs setup tasks like adding the user to the relevant chat channel.
/// </remarks>
/// <param name="roomId">The ID of the room to join.</param>
/// <param name="userId">The ID of the user wanting to join the room.</param>
Task AddUserToRoomAsync(long roomId, int userId);

/// <summary>
/// Parts an osu!web room.
/// </summary>
/// <remarks>
/// This performs setup tasks like removing the user from any relevant chat channels.
/// </remarks>
/// <param name="roomId">The ID of the room to part.</param>
/// <param name="userId">The ID of the user wanting to part the room.</param>
Task RemoveUserFromRoomAsync(long roomId, int userId);
}
}
Loading

0 comments on commit 9716e12

Please sign in to comment.