diff --git a/osu.Game/Online/Multiplayer/RoomCategory.cs b/osu.Game/Online/Multiplayer/RoomCategory.cs index 636a73a3e9..d6786a72fe 100644 --- a/osu.Game/Online/Multiplayer/RoomCategory.cs +++ b/osu.Game/Online/Multiplayer/RoomCategory.cs @@ -6,6 +6,7 @@ namespace osu.Game.Online.Multiplayer public enum RoomCategory { Normal, - Spotlight + Spotlight, + Realtime, } } diff --git a/osu.Game/Online/RealtimeMultiplayer/IMultiplayerClient.cs b/osu.Game/Online/RealtimeMultiplayer/IMultiplayerClient.cs new file mode 100644 index 0000000000..c162f066d4 --- /dev/null +++ b/osu.Game/Online/RealtimeMultiplayer/IMultiplayerClient.cs @@ -0,0 +1,65 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; + +namespace osu.Game.Online.RealtimeMultiplayer +{ + /// + /// An interface defining a multiplayer client instance. + /// + public interface IMultiplayerClient + { + /// + /// Signals that the room has changed state. + /// + /// The state of the room. + Task RoomStateChanged(MultiplayerRoomState state); + + /// + /// Signals that a user has joined the room. + /// + /// The user. + Task UserJoined(MultiplayerRoomUser user); + + /// + /// Signals that a user has left the room. + /// + /// The user. + Task UserLeft(MultiplayerRoomUser user); + + /// + /// Signal that the host of the room has changed. + /// + /// The user ID of the new host. + Task HostChanged(long userId); + + /// + /// Signals that the settings for this room have changed. + /// + /// The updated room settings. + Task SettingsChanged(MultiplayerRoomSettings newSettings); + + /// + /// Signals that a user in this room changed their state. + /// + /// The ID of the user performing a state change. + /// The new state of the user. + Task UserStateChanged(long userId, MultiplayerUserState state); + + /// + /// Signals that a match is to be started. This will *only* be sent to clients which are to begin loading at this point. + /// + Task LoadRequested(); + + /// + /// Signals that a match has started. All users in the state should begin gameplay as soon as possible. + /// + Task MatchStarted(); + + /// + /// Signals that the match has ended, all players have finished and results are ready to be displayed. + /// + Task ResultsReady(); + } +} diff --git a/osu.Game/Online/RealtimeMultiplayer/IMultiplayerLoungeServer.cs b/osu.Game/Online/RealtimeMultiplayer/IMultiplayerLoungeServer.cs new file mode 100644 index 0000000000..eecb61bcb0 --- /dev/null +++ b/osu.Game/Online/RealtimeMultiplayer/IMultiplayerLoungeServer.cs @@ -0,0 +1,20 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; + +namespace osu.Game.Online.RealtimeMultiplayer +{ + /// + /// Interface for an out-of-room multiplayer server. + /// + public interface IMultiplayerLoungeServer + { + /// + /// Request to join a multiplayer room. + /// + /// The databased room ID. + /// If the user is already in the requested (or another) room. + Task JoinRoom(long roomId); + } +} diff --git a/osu.Game/Online/RealtimeMultiplayer/IMultiplayerRoomServer.cs b/osu.Game/Online/RealtimeMultiplayer/IMultiplayerRoomServer.cs new file mode 100644 index 0000000000..f1b3daf7d3 --- /dev/null +++ b/osu.Game/Online/RealtimeMultiplayer/IMultiplayerRoomServer.cs @@ -0,0 +1,51 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using System.Threading.Tasks; + +namespace osu.Game.Online.RealtimeMultiplayer +{ + /// + /// Interface for an in-room multiplayer server. + /// + public interface IMultiplayerRoomServer + { + /// + /// Request to leave the currently joined room. + /// + /// If the user is not in a room. + Task LeaveRoom(); + + /// + /// Transfer the host of the currently joined room to another user in the room. + /// + /// The new user which is to become host. + /// A user other than the current host is attempting to transfer host. + /// If the user is not in a room. + Task TransferHost(long userId); + + /// + /// As the host, update the settings of the currently joined room. + /// + /// The new settings to apply. + /// A user other than the current host is attempting to transfer host. + /// If the user is not in a room. + Task ChangeSettings(MultiplayerRoomSettings settings); + + /// + /// Change the local user state in the currently joined room. + /// + /// The proposed new state. + /// If the state change requested is not valid, given the previous state or room state. + /// If the user is not in a room. + Task ChangeState(MultiplayerUserState newState); + + /// + /// As the host of a room, start the match. + /// + /// A user other than the current host is attempting to start the game. + /// If the user is not in a room. + /// If an attempt to start the game occurs when the game's (or users') state disallows it. + Task StartMatch(); + } +} diff --git a/osu.Game/Online/RealtimeMultiplayer/IMultiplayerServer.cs b/osu.Game/Online/RealtimeMultiplayer/IMultiplayerServer.cs new file mode 100644 index 0000000000..1d093af743 --- /dev/null +++ b/osu.Game/Online/RealtimeMultiplayer/IMultiplayerServer.cs @@ -0,0 +1,12 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Online.RealtimeMultiplayer +{ + /// + /// An interface defining the multiplayer server instance. + /// + public interface IMultiplayerServer : IMultiplayerRoomServer, IMultiplayerLoungeServer + { + } +} diff --git a/osu.Game/Online/RealtimeMultiplayer/InvalidStateChangeException.cs b/osu.Game/Online/RealtimeMultiplayer/InvalidStateChangeException.cs new file mode 100644 index 0000000000..d9a276fc19 --- /dev/null +++ b/osu.Game/Online/RealtimeMultiplayer/InvalidStateChangeException.cs @@ -0,0 +1,23 @@ +// 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 System.Runtime.Serialization; +using Microsoft.AspNetCore.SignalR; + +namespace osu.Game.Online.RealtimeMultiplayer +{ + [Serializable] + public class InvalidStateChangeException : HubException + { + public InvalidStateChangeException(MultiplayerUserState oldState, MultiplayerUserState newState) + : base($"Cannot change from {oldState} to {newState}") + { + } + + protected InvalidStateChangeException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} diff --git a/osu.Game/Online/RealtimeMultiplayer/InvalidStateException.cs b/osu.Game/Online/RealtimeMultiplayer/InvalidStateException.cs new file mode 100644 index 0000000000..7791bfc69f --- /dev/null +++ b/osu.Game/Online/RealtimeMultiplayer/InvalidStateException.cs @@ -0,0 +1,23 @@ +// 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 System.Runtime.Serialization; +using Microsoft.AspNetCore.SignalR; + +namespace osu.Game.Online.RealtimeMultiplayer +{ + [Serializable] + public class InvalidStateException : HubException + { + public InvalidStateException(string message) + : base(message) + { + } + + protected InvalidStateException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} diff --git a/osu.Game/Online/RealtimeMultiplayer/MultiplayerRoom.cs b/osu.Game/Online/RealtimeMultiplayer/MultiplayerRoom.cs new file mode 100644 index 0000000000..e009a34707 --- /dev/null +++ b/osu.Game/Online/RealtimeMultiplayer/MultiplayerRoom.cs @@ -0,0 +1,76 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Threading; +using Newtonsoft.Json; +using osu.Framework.Allocation; + +namespace osu.Game.Online.RealtimeMultiplayer +{ + /// + /// A multiplayer room. + /// + [Serializable] + public class MultiplayerRoom + { + /// + /// The ID of the room, used for database persistence. + /// + public readonly long RoomID; + + /// + /// The current state of the room (ie. whether it is in progress or otherwise). + /// + public MultiplayerRoomState State { get; set; } + + /// + /// All currently enforced game settings for this room. + /// + public MultiplayerRoomSettings Settings { get; set; } = new MultiplayerRoomSettings(); + + /// + /// All users currently in this room. + /// + public List Users { get; set; } = new List(); + + /// + /// The host of this room, in control of changing room settings. + /// + public MultiplayerRoomUser? Host { get; set; } + + private object writeLock = new object(); + + [JsonConstructor] + public MultiplayerRoom(in long roomId) + { + RoomID = roomId; + } + + private object updateLock = new object(); + + private ManualResetEventSlim freeForWrite = new ManualResetEventSlim(true); + + /// + /// Request a lock on this room to perform a thread-safe update. + /// + public IDisposable LockForUpdate() + { + // ReSharper disable once InconsistentlySynchronizedField + freeForWrite.Wait(); + + lock (updateLock) + { + freeForWrite.Wait(); + freeForWrite.Reset(); + + return new ValueInvokeOnDisposal(this, r => freeForWrite.Set()); + } + } + + public override string ToString() => $"RoomID:{RoomID} Host:{Host?.UserID} Users:{Users.Count} State:{State} Settings: [{Settings}]"; + } +} diff --git a/osu.Game/Online/RealtimeMultiplayer/MultiplayerRoomSettings.cs b/osu.Game/Online/RealtimeMultiplayer/MultiplayerRoomSettings.cs new file mode 100644 index 0000000000..d2f64235c3 --- /dev/null +++ b/osu.Game/Online/RealtimeMultiplayer/MultiplayerRoomSettings.cs @@ -0,0 +1,30 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using JetBrains.Annotations; +using osu.Game.Online.API; + +namespace osu.Game.Online.RealtimeMultiplayer +{ + [Serializable] + public class MultiplayerRoomSettings : IEquatable + { + public int BeatmapID { get; set; } + + public int RulesetID { get; set; } + + public string Name { get; set; } = "Unnamed room"; + + [NotNull] + public IEnumerable Mods { get; set; } = Enumerable.Empty(); + + public bool Equals(MultiplayerRoomSettings other) => BeatmapID == other.BeatmapID && Mods.SequenceEqual(other.Mods) && RulesetID == other.RulesetID && Name.Equals(other.Name, StringComparison.Ordinal); + + public override string ToString() => $"Name:{Name} Beatmap:{BeatmapID} Mods:{string.Join(',', Mods)} Ruleset:{RulesetID}"; + } +} diff --git a/osu.Game/Online/RealtimeMultiplayer/MultiplayerRoomState.cs b/osu.Game/Online/RealtimeMultiplayer/MultiplayerRoomState.cs new file mode 100644 index 0000000000..69c04b09a8 --- /dev/null +++ b/osu.Game/Online/RealtimeMultiplayer/MultiplayerRoomState.cs @@ -0,0 +1,33 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#nullable enable + +namespace osu.Game.Online.RealtimeMultiplayer +{ + /// + /// The current overall state of a realtime multiplayer room. + /// + public enum MultiplayerRoomState + { + /// + /// The room is open and accepting new players. + /// + Open, + + /// + /// A game start has been triggered but players have not finished loading. + /// + WaitingForLoad, + + /// + /// A game is currently ongoing. + /// + Playing, + + /// + /// The room has been disbanded and closed. + /// + Closed + } +} diff --git a/osu.Game/Online/RealtimeMultiplayer/MultiplayerRoomUser.cs b/osu.Game/Online/RealtimeMultiplayer/MultiplayerRoomUser.cs new file mode 100644 index 0000000000..caf1a70197 --- /dev/null +++ b/osu.Game/Online/RealtimeMultiplayer/MultiplayerRoomUser.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. + +#nullable enable + +using System; +using Newtonsoft.Json; +using osu.Game.Users; + +namespace osu.Game.Online.RealtimeMultiplayer +{ + [Serializable] + public class MultiplayerRoomUser : IEquatable + { + public readonly int UserID; + + public MultiplayerUserState State { get; set; } = MultiplayerUserState.Idle; + + public User? User { get; set; } + + [JsonConstructor] + public MultiplayerRoomUser(in int userId) + { + UserID = userId; + } + + public bool Equals(MultiplayerRoomUser other) + { + if (ReferenceEquals(this, other)) return true; + + return UserID == other.UserID; + } + + public override bool Equals(object obj) + { + if (ReferenceEquals(this, obj)) return true; + if (obj.GetType() != GetType()) return false; + + return Equals((MultiplayerRoomUser)obj); + } + + public override int GetHashCode() => UserID.GetHashCode(); + } +} diff --git a/osu.Game/Online/RealtimeMultiplayer/MultiplayerUserState.cs b/osu.Game/Online/RealtimeMultiplayer/MultiplayerUserState.cs new file mode 100644 index 0000000000..ed9acd146e --- /dev/null +++ b/osu.Game/Online/RealtimeMultiplayer/MultiplayerUserState.cs @@ -0,0 +1,59 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +namespace osu.Game.Online.RealtimeMultiplayer +{ + public enum MultiplayerUserState + { + /// + /// The user is idle and waiting for something to happen (or watching the match but not participating). + /// + Idle, + + /// + /// The user has marked themselves as ready to participate and should be considered for the next game start. + /// + /// + /// Clients in this state will receive gameplay channel messages. + /// As a client the only thing to look for in this state is a call. + /// + Ready, + + /// + /// The server is waiting for this user to finish loading. This is a reserved state, and is set by the server. + /// + /// + /// All users in state when the game start will be transitioned to this state. + /// All users in this state need to transition to before the game can start. + /// + WaitingForLoad, + + /// + /// The user's client has marked itself as loaded and ready to begin gameplay. + /// + Loaded, + + /// + /// The user is currently playing in a game. This is a reserved state, and is set by the server. + /// + /// + /// Once there are no remaining users, all users in state will be transitioned to this state. + /// At this point the game will start for all users. + /// + Playing, + + /// + /// The user has finished playing and is ready to view results. + /// + /// + /// Once all users transition from to this state, the game will end and results will be distributed. + /// All users will be transitioned to the state. + /// + FinishedPlay, + + /// + /// The user is currently viewing results. This is a reserved state, and is set by the server. + /// + Results, + } +} diff --git a/osu.Game/Online/RealtimeMultiplayer/NotHostException.cs b/osu.Game/Online/RealtimeMultiplayer/NotHostException.cs new file mode 100644 index 0000000000..56095043f0 --- /dev/null +++ b/osu.Game/Online/RealtimeMultiplayer/NotHostException.cs @@ -0,0 +1,23 @@ +// 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 System.Runtime.Serialization; +using Microsoft.AspNetCore.SignalR; + +namespace osu.Game.Online.RealtimeMultiplayer +{ + [Serializable] + public class NotHostException : HubException + { + public NotHostException() + : base("User is attempting to perform a host level operation while not the host") + { + } + + protected NotHostException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +} diff --git a/osu.Game/Online/RealtimeMultiplayer/NotJoinedRoomException.cs b/osu.Game/Online/RealtimeMultiplayer/NotJoinedRoomException.cs new file mode 100644 index 0000000000..7a6e089d0b --- /dev/null +++ b/osu.Game/Online/RealtimeMultiplayer/NotJoinedRoomException.cs @@ -0,0 +1,23 @@ +// 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 System.Runtime.Serialization; +using Microsoft.AspNetCore.SignalR; + +namespace osu.Game.Online.RealtimeMultiplayer +{ + [Serializable] + public class NotJoinedRoomException : HubException + { + public NotJoinedRoomException() + : base("This user has not yet joined a multiplayer room.") + { + } + + protected NotJoinedRoomException(SerializationInfo info, StreamingContext context) + : base(info, context) + { + } + } +}