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

Add room database retrieval and validation #4

Merged
merged 15 commits into from
Dec 16, 2020
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
13 changes: 13 additions & 0 deletions osu.Server.Spectator.Tests/MultiplayerFlowTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MultiplayerRoom> RetrieveRoom(long roomId)
peppy marked this conversation as resolved.
Show resolved Hide resolved
{
// 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);
}
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 @@ -128,6 +128,7 @@
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=PossibleMultipleEnumeration/@EntryIndexedValue">HINT</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=PrivateVariableCanBeMadeReadonly/@EntryIndexedValue">WARNING</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=PublicConstructorInAbstractClass/@EntryIndexedValue">WARNING</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=RedundantAnonymousTypePropertyName/@EntryIndexedValue">HINT</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=RedundantArgumentDefaultValue/@EntryIndexedValue">HINT</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=RedundantArrayCreationExpression/@EntryIndexedValue">WARNING</s:String>
<s:String x:Key="/Default/CodeInspection/Highlighting/InspectionSeverities/=RedundantAttributeParentheses/@EntryIndexedValue">WARNING</s:String>
Expand Down
21 changes: 21 additions & 0 deletions osu.Server.Spectator/Database.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// 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;
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;
}
}
}
44 changes: 44 additions & 0 deletions osu.Server.Spectator/DatabaseModels/multiplayer_playlist_item.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;
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()
{
}

/// <summary>
/// Create a playlist item model from the latest settings in a room.
/// </summary>
/// <param name="room">The room to retrieve settings from.</param>
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;
}
}
}
27 changes: 27 additions & 0 deletions osu.Server.Spectator/DatabaseModels/multiplayer_room.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// 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;
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; }
}
}
141 changes: 124 additions & 17 deletions osu.Server.Spectator/Hubs/MultiplayerHub.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -52,45 +56,82 @@ public async Task<MultiplayerRoom> 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));

return room;
}

/// <summary>
/// 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.
/// </summary>
/// <param name="roomId">The proposed room ID.</param>
/// <exception cref="InvalidStateException">If anything is wrong with this request.</exception>
protected virtual async Task<MultiplayerRoom> RetrieveRoom(long roomId)
{
using (var conn = Database.GetConnection())
{
var databaseRoom = await conn.QueryFirstOrDefaultAsync<multiplayer_room>("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<multiplayer_playlist_item>("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<IEnumerable<APIMod>>(playlistItem.allowed_mods) : Array.Empty<APIMod>()
}
};
}
}

public async Task LeaveRoom()
{
var room = await getLocalUserRoom();
Expand All @@ -109,6 +150,8 @@ public async Task LeaveRoom()

room.Users.Remove(user);

await UpdateDatabaseParticipants(room);

if (room.Users.Count == 0)
{
lock (active_rooms)
Expand All @@ -117,6 +160,8 @@ public async Task LeaveRoom()
active_rooms.Remove(room.RoomID);
}

await EndDatabaseMatch(room);

return;
}

Expand Down Expand Up @@ -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);
}
}
Expand All @@ -250,6 +298,65 @@ protected override Task OnDisconnectedAsync(Exception exception, MultiplayerClie
/// <param name="gameplay">Whether the group ID should be for active gameplay, or room control messages.</param>
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
});
}
}

/// <summary>
/// Should be called when user states change, to check whether the new overall room state can trigger a room-level state change.
/// </summary>
Expand Down
12 changes: 0 additions & 12 deletions osu.Server.Spectator/Program.cs
Original file line number Diff line number Diff line change
@@ -1,25 +1,13 @@
// 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;
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();
Expand Down