diff --git a/osu.Server.Spectator.Tests/MultiplayerFlowTests.cs b/osu.Server.Spectator.Tests/MultiplayerFlowTests.cs index 0065c1c2..caa7214c 100644 --- a/osu.Server.Spectator.Tests/MultiplayerFlowTests.cs +++ b/osu.Server.Spectator.Tests/MultiplayerFlowTests.cs @@ -510,6 +510,19 @@ public TestMultiplayerHub(MemoryDistributedCache cache) { } + protected override Task UpdateDatabaseParticipants(MultiplayerRoom room) => Task.CompletedTask; + protected override Task UpdateDatabaseSettings(MultiplayerRoom room) => Task.CompletedTask; + protected override Task EndDatabaseMatch(MultiplayerRoom room) => Task.CompletedTask; + + protected override Task RetrieveRoom(long roomId) + { + // bypass database for testing. + return Task.FromResult(new MultiplayerRoom(roomId) + { + Host = new MultiplayerRoomUser(CurrentContextUserId) + }); + } + public new bool TryGetRoom(long roomId, [MaybeNullWhen(false)] out MultiplayerRoom room) => base.TryGetRoom(roomId, out room); } diff --git a/osu.Server.Spectator.sln.DotSettings b/osu.Server.Spectator.sln.DotSettings index 3ef419c5..5eb8e6b7 100644 --- a/osu.Server.Spectator.sln.DotSettings +++ b/osu.Server.Spectator.sln.DotSettings @@ -128,6 +128,7 @@ HINT WARNING WARNING + HINT HINT WARNING WARNING diff --git a/osu.Server.Spectator/Database.cs b/osu.Server.Spectator/Database.cs new file mode 100644 index 00000000..aabe1ba4 --- /dev/null +++ b/osu.Server.Spectator/Database.cs @@ -0,0 +1,21 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using MySqlConnector; + +namespace osu.Server.Spectator +{ + public class Database + { + public static MySqlConnection GetConnection() + { + string host = (Environment.GetEnvironmentVariable("DB_HOST") ?? "localhost"); + string user = (Environment.GetEnvironmentVariable("DB_USER") ?? "root"); + + var connection = new MySqlConnection($"Server={host};Database=osu;User ID={user};ConnectionTimeout=5;ConnectionReset=false;Pooling=true;"); + connection.Open(); + return connection; + } + } +} diff --git a/osu.Server.Spectator/DatabaseModels/multiplayer_playlist_item.cs b/osu.Server.Spectator/DatabaseModels/multiplayer_playlist_item.cs new file mode 100644 index 00000000..1fdbb5c9 --- /dev/null +++ b/osu.Server.Spectator/DatabaseModels/multiplayer_playlist_item.cs @@ -0,0 +1,44 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using Newtonsoft.Json; +using osu.Game.Online.RealtimeMultiplayer; + +// ReSharper disable InconsistentNaming (matches database table) + +namespace osu.Server.Spectator.DatabaseModels +{ + [Serializable] + public class multiplayer_playlist_item + { + public long id { get; set; } + public long room_id { get; set; } + public int beatmap_id { get; set; } + public short ruleset_id { get; set; } + public short? playlist_order { get; set; } + public string? allowed_mods { get; set; } + public string? required_mods { get; set; } + public DateTimeOffset? created_at { get; set; } + public DateTimeOffset? updated_at { get; set; } + + // for deserialization + public multiplayer_playlist_item() + { + } + + /// + /// Create a playlist item model from the latest settings in a room. + /// + /// The room to retrieve settings from. + public multiplayer_playlist_item(MultiplayerRoom room) + { + room_id = room.RoomID; + + beatmap_id = room.Settings.BeatmapID; + ruleset_id = (short)room.Settings.RulesetID; + required_mods = JsonConvert.SerializeObject(room.Settings.Mods); + updated_at = DateTimeOffset.Now; + } + } +} diff --git a/osu.Server.Spectator/DatabaseModels/multiplayer_room.cs b/osu.Server.Spectator/DatabaseModels/multiplayer_room.cs new file mode 100644 index 00000000..bee1c113 --- /dev/null +++ b/osu.Server.Spectator/DatabaseModels/multiplayer_room.cs @@ -0,0 +1,27 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System; +using osu.Game.Online.Multiplayer; + +// ReSharper disable InconsistentNaming (matches database table) + +namespace osu.Server.Spectator.DatabaseModels +{ + [Serializable] + public class multiplayer_room + { + public long id { get; set; } + public int user_id { get; set; } + public string name { get; set; } = string.Empty; + public int channel_id { get; set; } + public DateTimeOffset starts_at { get; set; } + public DateTimeOffset? ends_at { get; set; } + public byte max_attempts { get; set; } + public int participant_count { get; set; } + public DateTimeOffset? created_at { get; set; } + public DateTimeOffset? updated_at { get; set; } + public DateTimeOffset? deleted_at { get; set; } + public RoomCategory category { get; set; } + } +} diff --git a/osu.Server.Spectator/Hubs/MultiplayerHub.cs b/osu.Server.Spectator/Hubs/MultiplayerHub.cs index 34646fdd..1e1b2422 100644 --- a/osu.Server.Spectator/Hubs/MultiplayerHub.cs +++ b/osu.Server.Spectator/Hubs/MultiplayerHub.cs @@ -8,8 +8,12 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading.Tasks; +using Dapper; using Microsoft.Extensions.Caching.Distributed; +using Newtonsoft.Json; +using osu.Game.Online.API; using osu.Game.Online.RealtimeMultiplayer; +using osu.Server.Spectator.DatabaseModels; namespace osu.Server.Spectator.Hubs { @@ -52,38 +56,33 @@ public async Task JoinRoom(long roomId) if (state != null) { // if the user already has a state, it means they are already in a room and can't join another without first leaving. - throw new InvalidStateException("Can't join a room when already in another room"); + throw new InvalidStateException("Can't join a room when already in another room."); } - MultiplayerRoom? room; - - bool shouldBecomeHost = false; + // add the user to the room. + var roomUser = new MultiplayerRoomUser(CurrentContextUserId); - lock (active_rooms) + // check whether we are already aware of this match. + if (!TryGetRoom(roomId, out var room)) { - // check whether we are already aware of this match. + room = await RetrieveRoom(roomId); + + room.Host = roomUser; - if (!TryGetRoom(roomId, out room)) + lock (active_rooms) { - // TODO: get details of the room from the database. hard abort if non existent. Console.WriteLine($"Tracking new room {roomId}."); - active_rooms.Add(roomId, room = new MultiplayerRoom(roomId)); - shouldBecomeHost = true; + active_rooms.Add(roomId, room); } } - // add the user to the room. - var roomUser = new MultiplayerRoomUser(CurrentContextUserId); - using (room.LockForUpdate()) { room.Users.Add(roomUser); - - if (shouldBecomeHost) - room.Host = roomUser; - await Clients.Group(GetGroupId(roomId)).UserJoined(roomUser); await Groups.AddToGroupAsync(Context.ConnectionId, GetGroupId(roomId)); + + await UpdateDatabaseParticipants(room); } await UpdateLocalUserState(new MultiplayerClientState(roomId)); @@ -91,6 +90,48 @@ public async Task JoinRoom(long roomId) return room; } + /// + /// Attempt to construct and a room based on a room ID specification. + /// This will check the database backing to ensure things are in a consistent state. + /// + /// The proposed room ID. + /// If anything is wrong with this request. + protected virtual async Task RetrieveRoom(long roomId) + { + using (var conn = Database.GetConnection()) + { + var databaseRoom = await conn.QueryFirstOrDefaultAsync("SELECT * FROM multiplayer_rooms WHERE category = 'realtime' AND id = @RoomID", new + { + RoomID = roomId + }); + + if (databaseRoom == null) + throw new InvalidStateException("Specified match does not exist."); + + if (databaseRoom.ends_at != null) + throw new InvalidStateException("Match has already ended."); + + if (databaseRoom.user_id != CurrentContextUserId) + throw new InvalidStateException("Non-host is attempting to join match before host"); + + var playlistItem = await conn.QuerySingleAsync("SELECT * FROM multiplayer_playlist_items WHERE room_id = @RoomID", new + { + RoomID = roomId + }); + + return new MultiplayerRoom(roomId) + { + Settings = new MultiplayerRoomSettings + { + BeatmapID = playlistItem.beatmap_id, + RulesetID = playlistItem.ruleset_id, + Name = databaseRoom.name, + Mods = playlistItem.allowed_mods != null ? JsonConvert.DeserializeObject>(playlistItem.allowed_mods) : Array.Empty() + } + }; + } + } + public async Task LeaveRoom() { var room = await getLocalUserRoom(); @@ -109,6 +150,8 @@ public async Task LeaveRoom() room.Users.Remove(user); + await UpdateDatabaseParticipants(room); + if (room.Users.Count == 0) { lock (active_rooms) @@ -117,6 +160,8 @@ public async Task LeaveRoom() active_rooms.Remove(room.RoomID); } + await EndDatabaseMatch(room); + return; } @@ -231,6 +276,9 @@ public async Task ChangeSettings(MultiplayerRoomSettings settings) ensureIsHost(room); room.Settings = settings; + + await UpdateDatabaseSettings(room); + await Clients.Group(GetGroupId(room.RoomID)).SettingsChanged(settings); } } @@ -250,6 +298,65 @@ protected override Task OnDisconnectedAsync(Exception exception, MultiplayerClie /// Whether the group ID should be for active gameplay, or room control messages. public static string GetGroupId(long roomId, bool gameplay = false) => $"room:{roomId}:{gameplay}"; + protected virtual async Task UpdateDatabaseSettings(MultiplayerRoom room) + { + using (var conn = Database.GetConnection()) + { + var dbPlaylistItem = new multiplayer_playlist_item(room); + + await conn.ExecuteAsync("UPDATE multiplayer_rooms SET name = @Name WHERE id = @RoomID", new + { + RoomID = room.RoomID, + Name = room.Settings.Name + }); + + await conn.ExecuteAsync("UPDATE multiplayer_playlist_items SET beatmap_id = @beatmap_id, ruleset_id = @ruleset_id, required_mods = @required_mods, updated_at = NOW() WHERE room_id = @room_id", dbPlaylistItem); + } + } + + protected virtual async Task EndDatabaseMatch(MultiplayerRoom room) + { + using (var conn = Database.GetConnection()) + { + await conn.ExecuteAsync("UPDATE multiplayer_rooms SET ends_at = NOW() WHERE id = @RoomID", new + { + RoomID = room.RoomID + }); + } + } + + protected virtual async Task UpdateDatabaseParticipants(MultiplayerRoom room) + { + using (var conn = Database.GetConnection()) + { + using (var transaction = await conn.BeginTransactionAsync()) + { + // This should be considered *very* temporary, and for display purposes only! + await conn.ExecuteAsync("DELETE FROM multiplayer_rooms_high WHERE room_id = @RoomID", new + { + RoomID = room.RoomID + }, transaction); + + foreach (var u in room.Users) + { + await conn.ExecuteAsync("INSERT INTO multiplayer_rooms_high (room_id, user_id) VALUES (@RoomID, @UserID)", new + { + RoomID = room.RoomID, + UserID = u.UserID + }, transaction); + } + + await transaction.CommitAsync(); + } + + await conn.ExecuteAsync("UPDATE multiplayer_rooms SET participant_count = @Count WHERE id = @RoomID", new + { + RoomID = room.RoomID, + Count = room.Users.Count + }); + } + } + /// /// Should be called when user states change, to check whether the new overall room state can trigger a room-level state change. /// diff --git a/osu.Server.Spectator/Program.cs b/osu.Server.Spectator/Program.cs index c82830c5..5b15a7c2 100644 --- a/osu.Server.Spectator/Program.cs +++ b/osu.Server.Spectator/Program.cs @@ -1,25 +1,13 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. -using System; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; -using MySqlConnector; namespace osu.Server.Spectator { public static class Program { - public static MySqlConnection GetDatabaseConnection() - { - string host = (Environment.GetEnvironmentVariable("DB_HOST") ?? "localhost"); - string user = (Environment.GetEnvironmentVariable("DB_USER") ?? "root"); - - var connection = new MySqlConnection($"Server={host};Database=osu;User ID={user};ConnectionTimeout=1;ConnectionReset=false;Pooling=true;"); - connection.Open(); - return connection; - } - public static void Main(string[] args) { createHostBuilder(args).Build().Run();