diff --git a/src/Behide.OnlineServices.Tests/Behide.OnlineServices.Tests.fsproj b/src/Behide.OnlineServices.Tests/Behide.OnlineServices.Tests.fsproj index e570f95..27cf5f8 100644 --- a/src/Behide.OnlineServices.Tests/Behide.OnlineServices.Tests.fsproj +++ b/src/Behide.OnlineServices.Tests/Behide.OnlineServices.Tests.fsproj @@ -9,7 +9,13 @@ - + + + + + + + diff --git a/src/Behide.OnlineServices.Tests/Common.fs b/src/Behide.OnlineServices.Tests/Common.fs index 3731d50..1d4a7eb 100644 --- a/src/Behide.OnlineServices.Tests/Common.fs +++ b/src/Behide.OnlineServices.Tests/Common.fs @@ -10,22 +10,17 @@ open Behide.OnlineServices open Behide.OnlineServices.Signaling let createTestServer () = - let offerStore = Hubs.Signaling.ConnAttemptStore() + let connectionAttemptStore = Hubs.Signaling.ConnectionAttemptStore() let roomStore = Hubs.Signaling.RoomStore() - let playerConnStore = Hubs.Signaling.PlayerConnStore() + let playerConnStore = Hubs.Signaling.PlayerStore() let hostBuilder = WebHostBuilder() .ConfigureServices(fun services -> services |> Program.configureServices |> ignore - - services.Remove(ServiceDescriptor.Singleton()) |> ignore - services.Remove(ServiceDescriptor.Singleton()) |> ignore - services.Remove(ServiceDescriptor.Singleton()) |> ignore - - services.AddSingleton(offerStore) |> ignore + services.AddSingleton(connectionAttemptStore) |> ignore services.AddSingleton(roomStore) |> ignore - services.AddSingleton(playerConnStore) |> ignore + services.AddSingleton(playerConnStore) |> ignore ) .Configure(fun app -> app.UseRouting() @@ -36,7 +31,7 @@ let createTestServer () = ) new TestServer(hostBuilder), - offerStore :> Store.IStore<_, _>, + connectionAttemptStore :> Store.IStore<_, _>, roomStore :> Store.IStore<_, _>, playerConnStore :> Store.IStore<_, _> diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling.fs deleted file mode 100644 index 0df42d5..0000000 --- a/src/Behide.OnlineServices.Tests/Tests/Signaling.fs +++ /dev/null @@ -1,1071 +0,0 @@ -module Behide.OnlineServices.Tests.SignalingHub - -open Microsoft.AspNetCore.TestHost -open Microsoft.AspNetCore.SignalR -open Microsoft.AspNetCore.SignalR.Client -open Microsoft.AspNetCore.Http.Connections.Client -open Microsoft.Extensions.DependencyInjection -open System.Threading -open System.Threading.Tasks -open System.Text.Json.Serialization - -open Expecto -open FsToolkit.ErrorHandling - -open Behide.OnlineServices.Signaling - - -type SignalingHub(connection: HubConnection) = - interface ISignalingHub with - member _.StartConnectionAttempt sdpDescription = connection.InvokeAsync<_>("StartConnectionAttempt", sdpDescription) - member _.JoinConnectionAttempt offerId = connection.InvokeAsync<_>("JoinConnectionAttempt", offerId) - member _.SendAnswer offerId iceCandidate = connection.InvokeAsync<_>("SendAnswer", offerId, iceCandidate) - member _.SendIceCandidate offerId iceCandidate = connection.InvokeAsync<_>("SendIceCandidate", offerId, iceCandidate) - member _.EndConnectionAttempt offerId = connection.InvokeAsync<_>("EndConnectionAttempt", offerId) - - member _.CreateRoom () = connection.InvokeAsync<_>("CreateRoom") - member _.JoinRoom roomId = connection.InvokeAsync<_>("JoinRoom", roomId) - member _.LeaveRoom () = connection.InvokeAsync<_>("LeaveRoom") - member _.ConnectToRoomPlayers() = connection.InvokeAsync<_>("ConnectToRoomPlayers") - - -let connectHub (testServer: TestServer) : Task = - let httpConnectionOptions (options: HttpConnectionOptions) = - options.HttpMessageHandlerFactory <- fun _ -> testServer.CreateHandler() - - let setupJsonProtocol (options: JsonHubProtocolOptions) = - JsonFSharpOptions - .Default() - .AddToJsonSerializerOptions(options.PayloadSerializerOptions) - - let url = testServer.BaseAddress.ToString() + "webrtc-signaling" - - let connection = - HubConnectionBuilder() - .WithUrl(url, httpConnectionOptions) - .AddJsonProtocol(setupJsonProtocol) - .Build() - - task { - do! connection.StartAsync() - return connection, SignalingHub(connection) :> ISignalingHub - } - - -[] -let signalingTests = - let testServer, offerStore, roomStore, playerConnStore = Common.createTestServer() - - testList "Signaling tests" [ - testTask "Signaling hub connection should success" { - let! (connection: HubConnection, _) = testServer |> connectHub - - Expect.equal connection.State HubConnectionState.Connected "Should be connected to the hub" - - let connId = connection.ConnectionId |> ConnId.parse - let playerConn = - playerConnStore.Get connId - |> Flip.Expect.wantSome "Client should be registered in the player connections store" - - Expect.equal playerConn.ConnectionId connId "Connection ID should be the same" - } - - testList "CreateRoom" [ - testTask "Create room should success" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - let! roomId = - signalingHub.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - let room = - roomStore.Get roomId - |> Flip.Expect.wantSome "Room should be created" - - Expect.equal room.Id roomId "Room ID should be the same" - } - - testTask "Create room while already in a room" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - do! signalingHub.CreateRoom() - |> Task.map (Flip.Expect.isOk "Room creation should success") - - let! (error: Errors.CreateRoomError) = - signalingHub.CreateRoom() - |> Task.map (Flip.Expect.wantError "Second room creation should return an error") - - Expect.equal - error - Errors.CreateRoomError.PlayerAlreadyInARoom - "Room creation should fail" - } - ] - - testList "JoinRoom" [ - testTask "Join room should success" { - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (conn2: HubConnection, signalingHub2: ISignalingHub) = testServer |> connectHub - - let mutable offerId = None - - // Add handler for creating offer - conn1.On("ConnectionRequested", fun _playerId -> - Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt - |> Task.map (function - | Ok o -> offerId <- Some o; o - | Error e -> failwithf "Failed to create offer: %A" e) - ) - |> ignore - - - // Create a room and check if it was created - let! roomId = - signalingHub1.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - roomStore.Get roomId - |> Flip.Expect.isSome "Room should be created" - - - // Join the room - let! playerId = - signalingHub2.JoinRoom roomId - |> Task.map (Flip.Expect.wantOk "Room joining should success") - - // Retrieve the updated room - let room = - roomStore.Get roomId - |> Flip.Expect.wantSome "Room should still exist" - - - // Check if the room connection info is correct - Expect.equal playerId 2 "Peer ID should be 2 in that case" - - // Check if the room contains both players - Expect.equal - conn1.ConnectionId - (room.Initiator |> ConnId.raw) - "Room initiator should be the first player connection id" - - Expect.containsAll - room.Players - [ 1, conn1.ConnectionId |> ConnId.parse - 2, conn2.ConnectionId |> ConnId.parse ] - "Room should contain both player connection ids" - } - - testTask "Join room while already in a room" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - let! roomId = - signalingHub.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - let! (error: Errors.JoinRoomError) = - roomId - |> signalingHub.JoinRoom - |> Task.map (Flip.Expect.wantError "Joining room should return an error") - - Expect.equal - error - Errors.JoinRoomError.PlayerAlreadyInARoom - "Room joining should fail" - } - - testTask "Join nonexisting room" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - let fakeRoomId = RoomId.create() - - let! (error: Errors.JoinRoomError) = - fakeRoomId - |> signalingHub.JoinRoom - |> Task.map (Flip.Expect.wantError "Joining nonexisting room should return an error") - - Expect.equal - error - Errors.JoinRoomError.RoomNotFound - "Nonexisting room joining should fail" - } - - testTask "Joining room should give a unique playerId" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub3: ISignalingHub) = testServer |> connectHub - - // Initialization - let! roomId = - signalingHub1.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - let playerId1 = 1 // The first player should always have the playerId 1 - - // Join room - let! playerId2 = - signalingHub2.JoinRoom roomId - |> Task.map (Flip.Expect.wantOk "Room joining should success") - - Expect.equal playerId2 2 "The second player should have the playerId to 2" - Expect.notEqual playerId1 playerId2 "The two players should have different playerId" - - // Leave and rejoin room - do! signalingHub2.LeaveRoom() - |> Task.map (Flip.Expect.wantOk "Leaving room should success") - - let! playerId3 = - signalingHub2.JoinRoom roomId - |> Task.map (Flip.Expect.wantOk "Room joining should success") - - Expect.equal playerId3 2 "The second player should have the playerId to 2" - Expect.notEqual playerId1 playerId3 "The two players should have different playerId" - - // Join room with a third player - let! playerId4 = - signalingHub3.JoinRoom roomId - |> Task.map (Flip.Expect.wantOk "Room joining should success") - - Expect.equal playerId4 3 "The third player should have the playerId to 3" - Expect.notEqual playerId1 playerId4 "The two players should have different playerId" - } - ] - - testList "ConnectToRoomPlayers" [ - testTask "Connect to room players" { - // Create room - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub - - let! roomId = - signalingHub1.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Failed to create a room") - - // Join room - let! (conn2: HubConnection, signalingHub2: ISignalingHub) = testServer |> connectHub - - let! secondPlayerId = - signalingHub2.JoinRoom roomId - |> Task.map (Flip.Expect.wantOk "Failed to join room") - - Expect.equal secondPlayerId 2 "The second player should have a playerId/peerId to 2" - - // Register "ConnectionRequested" handler on first player - let mutable connAttemptId = None - - conn1.On("ConnectionRequested", fun _playerId -> - Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt - |> Task.map (function - | Ok c -> connAttemptId <- Some c; c - | Error e -> failwithf "Failed to create connection attempt: %A" e) - ) - |> ignore - - // Connect players - let! (res: RoomConnectionInfo) = - signalingHub2.ConnectToRoomPlayers() - |> Task.map (Flip.Expect.wantOk "Failed to create connection information for players") - - Expect.isEmpty res.FailedCreations "No connection attempt creation should be failed" - - let connAttemptId = Expect.wantSome connAttemptId "An connection attempt id should have been generated" - Expect.sequenceEqual - (res.PlayersConnInfo |> List.ofArray) - [ { PeerId = 1; ConnAttemptId = connAttemptId } ] - "Players connection info should contain the first player connection info and only that" - - // Check connections - let room = - roomStore.Get roomId - |> Flip.Expect.wantSome "Room should still exist" - - let connId1 = conn1.ConnectionId |> ConnId.parse - let connId2 = conn2.ConnectionId |> ConnId.parse - - Expect.containsAll - room.Connections - [ connId2, connId1 ] - "Room should contain the connection between the two players" - } - - testTheoryTask - "Connect to room players with multiple players" - [ 3, 0 - 10, 4 - 100, 99 ] - <| fun (nbOfPeers: int, peerThatConnects: int) -> task { - let! players = - List.init nbOfPeers (fun _ -> testServer |> connectHub) - |> Task.WhenAll - |> Task.map List.ofArray - - // Register handlers - let cts = new CancellationTokenSource() - let connAttemptIdsTask = - players - |> List.indexed - |> List.choose (fun (idx, (conn, hub)) -> - let connAttemptIdTcs = TaskCompletionSource>() - cts.Token.Register(fun _ -> connAttemptIdTcs.TrySetCanceled() |> ignore) |> ignore - - conn.On("ConnectionRequested", fun _ -> - Common.fakeSdpDescription - |> hub.StartConnectionAttempt - |> Task.map (fun res -> - connAttemptIdTcs.SetResult(res) - - match res with - | Ok c -> c - | Error _ -> failwith "Failed to create connection attempt" - ) - ) - |> ignore - - match idx = peerThatConnects with // Don't await the connection attempt creation for the peer that will connect - | true -> None - | false -> Some connAttemptIdTcs.Task - ) - |> List.sequenceTaskResultA - |> Task.map (Flip.Expect.wantOk "Failed to create some connection attempt") - - // Create room - let! roomId = - (players |> List.head |> snd).CreateRoom() - |> Task.map (Flip.Expect.wantOk "Failed to create room") - - // Join room - do! players - |> List.tail - |> List.map (fun (_, hub) -> hub.JoinRoom roomId) - |> List.sequenceTaskResultA - |> Task.map (Flip.Expect.isOk "All players should be able to join the room") - - // Connect players - let hub = players |> List.item peerThatConnects |> snd - let! res = - hub.ConnectToRoomPlayers() - |> Task.map (Flip.Expect.wantOk "Failed to create connection information for players") - - cts.CancelAfter 1000 - let! connAttemptIds = connAttemptIdsTask - - Expect.isEmpty res.FailedCreations "All connection attempt creations should be successful" - Expect.hasLength res.PlayersConnInfo (players.Length - 1) "Their should be one player connection info per player to connect to" - Expect.hasLength connAttemptIds (players.Length - 1) "Their should be one connection attempt id per players to connect to" - Expect.hasLength connAttemptIds res.PlayersConnInfo.Length "Their should be one connection attempt id per player connection info" - - Expect.containsAll - connAttemptIds - (res.PlayersConnInfo |> Array.map _.ConnAttemptId) - "Created connection attempt ids should be the same that the received connection attempt ids" - - // Check connections - let room = - roomStore.Get roomId - |> Flip.Expect.wantSome "Room should still exist" - - let connIdThatConnects = - players - |> List.item peerThatConnects - |> fst - |> _.ConnectionId - |> ConnId.parse - - let expectedConnections = - players - |> List.choose (fun (conn, _) -> - let connId = conn.ConnectionId |> ConnId.parse - - match connId <> connIdThatConnects with - | false -> None // The player that connects should not be connected to himself - | true -> Some (connIdThatConnects, connId) - ) - - Expect.containsAll - room.Connections - expectedConnections - "Room should contain the all the new connections" - } - - testTask "Connect to players without a ConnectionRequested handler" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - - let! roomId = - signalingHub1.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - do! signalingHub2.JoinRoom roomId - |> Task.map (Flip.Expect.wantOk "Room joining should success") - - let! (res: RoomConnectionInfo) = - signalingHub2.ConnectToRoomPlayers() - |> Task.map (Flip.Expect.wantOk "Connecting to room players should return an \"Ok\" result") - - Expect.sequenceEqual - res.FailedCreations - [ 1 ] // 1 is the peerId of the first player, the one who created the room - "Joining room without add handler for ConnectionRequested should fail" - - Expect.isEmpty res.PlayersConnInfo "No connection info should be returned" - } - - testTask "Connect to players while not in a room" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - let! (error: Errors.ConnectToRoomPlayersError) = - signalingHub.ConnectToRoomPlayers() - |> Task.map (Flip.Expect.wantError "Connecting to room players while not in a room should return an error") - - Expect.equal - error - Errors.ConnectToRoomPlayersError.NotInARoom - "Connecting to room players while not in a room should fail" - } - ] - - testList "LeaveRoom" [ - testTask "Leave room" { - // Create room - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub - - let! roomId = - signalingHub1.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - // Join room - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - - do! roomId - |> signalingHub2.JoinRoom - |> Task.map (Flip.Expect.isOk "Room joining should success") - - // Leave room - do! signalingHub2.LeaveRoom() - |> Task.map (Flip.Expect.wantOk "Leaving room should success") - - // Check if the player is not in the room anymore - let room = - roomStore.Get roomId - |> Flip.Expect.wantSome "Room should still exist" - - Expect.sequenceEqual - room.Players - [ 1, conn1.ConnectionId |> ConnId.parse ] - "Room should not contain the second player" - - Expect.isEmpty room.Connections "Room should not contain any connection" - } - - testTask "Leave room where we are connected to players" { - // Create room - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub - - let! roomId = - signalingHub1.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - // Join room - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - - do! roomId - |> signalingHub2.JoinRoom - |> Task.map (Flip.Expect.isOk "Room joining should success") - - // Connect to room players - conn1.On("ConnectionRequested", fun _playerId -> - Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt - |> Task.map (function - | Ok c -> c - | Error e -> failwithf "Failed to create connection attempt: %A" e) - ) - |> ignore - - do! signalingHub2.ConnectToRoomPlayers() - |> Task.map (Flip.Expect.isOk "Connecting to room players should success") - - // Leave room - do! signalingHub2.LeaveRoom() - |> Task.map (Flip.Expect.isOk "Leaving room should success") - - // Check if the player is not in the room anymore - let room = - roomStore.Get roomId - |> Flip.Expect.wantSome "Room should still exist" - - Expect.sequenceEqual - room.Players - [ 1, conn1.ConnectionId |> ConnId.parse ] - "Room should not contain the second player" - - Expect.isEmpty room.Connections "Room should not contain any connection" - } - - testTask "Leave room while not in a room" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - let! (error: Errors.LeaveRoomError) = - signalingHub.LeaveRoom() - |> Task.map (Flip.Expect.wantError "Leaving room while not in a room should return an error") - - Expect.equal - error - Errors.LeaveRoomError.NotInARoom - "Leaving room while not in a room should fail" - } - - testTask "Leave room while being the last player delete the room" { - let! (_, hub1: ISignalingHub) = testServer |> connectHub - - // Create room - let! roomId = - hub1.CreateRoom() - |> Task.map (Flip.Expect.wantOk "Room creation should success") - - // Leave room - do! hub1.LeaveRoom() - |> Task.map (Flip.Expect.wantOk "Leaving room should success") - - // Check if the room is removed - roomStore.Get roomId - |> Flip.Expect.isNone "Room should be removed" - } - ] - - testList "StartConnectionAttempt" [ - testTask "Create connection attempt" { - let! (conn: HubConnection, signalingHub: ISignalingHub) = testServer |> connectHub - - let! offerId = - Common.fakeSdpDescription - |> signalingHub.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // Check if the offer was created - let offer = - offerStore.Get offerId - |> Flip.Expect.wantSome "Offer should be created" - - Expect.equal - offer.InitiatorConnectionId - (conn.ConnectionId |> ConnId.parse) - "Offer initiator should be the player connection id" - - Expect.equal - offer.SdpDescription - Common.fakeSdpDescription - "Offer SDP description should be the same" - } - ] - - testList "JoinConnectionAttempt" [ - testTask "Join connection attempt" { - // Create a connection attempt - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub - - let originalSdpDesc = Common.fakeSdpDescription - - let! offerId = - originalSdpDesc - |> signalingHub1.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // Check if the offer has answerer (it should not) - offerStore.Get offerId - |> Flip.Expect.wantSome "Offer should exist" - |> _.Answerer - |> Flip.Expect.isNone "Offer should be marked as not answered" - - // Join connection attempt - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - - let! sdpDescription = - offerId - |> signalingHub2.JoinConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt joining should success") - - Expect.equal - sdpDescription - originalSdpDesc - "SDP description should be the same" - - // Check if the offer is marked as answered - let offer = - offerStore.Get offerId - |> Flip.Expect.wantSome "Offer should exist" - - Expect.isSome offer.Answerer "Offer should has an answerer" - - Expect.equal - offer.InitiatorConnectionId - (conn1.ConnectionId |> ConnId.parse) - "Offer initiator should be the first player connection id" - } - - testTask "Join nonexisting connection attempt" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - let fakeOfferId = ConnAttemptId.create() - - let! (error: Errors.JoinConnectionAttemptError) = - fakeOfferId - |> signalingHub.JoinConnectionAttempt - |> Task.map (Flip.Expect.wantError "Joining nonexisting connection attempt should return an error") - - Expect.equal - error - Errors.JoinConnectionAttemptError.OfferNotFound - "Nonexisting connection attempt joining should fail" - } - - testTask "Join already answered connection attempt" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub3: ISignalingHub) = testServer |> connectHub - - // Create a connection attempt - let! offerId = - Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // Join connection attempt - do! offerId - |> signalingHub2.JoinConnectionAttempt - |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") - - // Try to join the same connection attempt again - let! (error: Errors.JoinConnectionAttemptError) = - offerId - |> signalingHub3.JoinConnectionAttempt - |> Task.map (Flip.Expect.wantError "Joining already answered connection attempt should return an error") - - Expect.equal - error - Errors.JoinConnectionAttemptError.OfferAlreadyAnswered - "Already answered connection attempt joining should fail" - } - - testTask "Join connection attempt as the initiator" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - // Create connection attempt - let! offerId = - Common.fakeSdpDescription - |> signalingHub.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // Join connection attempt - let! (error: Errors.JoinConnectionAttemptError) = - offerId - |> signalingHub.JoinConnectionAttempt - |> Task.map (Flip.Expect.wantError "Joining connection attempt as the initiator should return an error") - - Expect.equal - error - Errors.JoinConnectionAttemptError.InitiatorCannotJoin - "Joining connection attempt as the initiator should fail" - } - ] - - testList "EndConnectionAttempt" [ - testTask "Initiator end connection attempt" { - let! (_, hub1: ISignalingHub) = testServer |> connectHub - let! (_, hub2: ISignalingHub) = testServer |> connectHub - - // Create connection attempt - let! offerId = - Common.fakeSdpDescription - |> hub1.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // Join connection attempt - do! offerId - |> hub2.JoinConnectionAttempt - |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") - - // End connection attempt - do! offerId - |> hub1.EndConnectionAttempt - |> Task.map (Flip.Expect.isOk "Connection attempt ending should success") - - // Check if the offer is removed - offerStore.Get offerId - |> Flip.Expect.isNone "Offer should be removed" - } - - testTask "Answerer end connection attempt" { - let! (_, hub1: ISignalingHub) = testServer |> connectHub - let! (_, hub2: ISignalingHub) = testServer |> connectHub - - // Create connection attempt - let! offerId = - Common.fakeSdpDescription - |> hub1.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // Join connection attempt - do! offerId - |> hub2.JoinConnectionAttempt - |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") - - // End connection attempt - do! offerId - |> hub2.EndConnectionAttempt - |> Task.map (Flip.Expect.isOk "Connection attempt ending should success") - - // Check if the offer is removed - offerStore.Get offerId - |> Flip.Expect.isNone "Offer should be removed" - } - - testTask "End nonexisting connection attempt" { - let! (_, hub: ISignalingHub) = testServer |> connectHub - - let fakeOfferId = ConnAttemptId.create() - - let! (error: Errors.EndConnectionAttemptError) = - hub.EndConnectionAttempt fakeOfferId - |> Task.map (Flip.Expect.wantError "Ending nonexisting connection attempt should return an error") - - Expect.equal - error - Errors.EndConnectionAttemptError.OfferNotFound - "Ending nonexisting connection attempt should fail" - } - - testTask "End ended connection attempt" { - let! (_, hub1: ISignalingHub) = testServer |> connectHub - let! (_, hub2: ISignalingHub) = testServer |> connectHub - - // Create connection attempt - let! offerId = - Common.fakeSdpDescription - |> hub1.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // Join connection attempt - do! offerId - |> hub2.JoinConnectionAttempt - |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") - - // End connection attempt - do! offerId - |> hub1.EndConnectionAttempt - |> Task.map (Flip.Expect.isOk "Connection attempt ending should success") - - // End connection attempt again - let! (error: Errors.EndConnectionAttemptError) = - offerId - |> hub1.EndConnectionAttempt - |> Task.map (Flip.Expect.wantError "Ending already ended connection attempt should return an error") - - Expect.equal - error - Errors.EndConnectionAttemptError.OfferNotFound - "Ending already ended connection attempt should fail" - } - - testTask "End connection attempt as not participant" { - let! (_, hub1: ISignalingHub) = testServer |> connectHub - let! (_, hub2: ISignalingHub) = testServer |> connectHub - - // Create connection attempt - let! offerId = - Common.fakeSdpDescription - |> hub1.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // End connection attempt - let! (error: Errors.EndConnectionAttemptError) = - offerId - |> hub2.EndConnectionAttempt - |> Task.map (Flip.Expect.wantError "Ending connection attempt as not participant should return an error") - - Expect.equal - error - Errors.EndConnectionAttemptError.NotParticipant - "Ending connection attempt as not participant should fail" - } - - testTask "End answered connection attempt as not participant" { - let! (_, hub1: ISignalingHub) = testServer |> connectHub // Initiator - let! (_, hub2: ISignalingHub) = testServer |> connectHub // Answerer - let! (_, hub3: ISignalingHub) = testServer |> connectHub // Other - - // Create connection attempt - let! offerId = - Common.fakeSdpDescription - |> hub1.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // Join connection attempt - do! offerId - |> hub2.JoinConnectionAttempt - |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") - - // End connection attempt as not participant - let! (error: Errors.EndConnectionAttemptError) = - offerId - |> hub3.EndConnectionAttempt - |> Task.map (Flip.Expect.wantError "Ending connection attempt as not participant should return an error") - - Expect.equal - error - Errors.EndConnectionAttemptError.NotParticipant - "Ending connection attempt as not participant should fail" - } - ] - - testList "SendAnswer" [ - testTask "Send answer" { - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - - // Subscribe to SdpAnswerReceived event - let sdpAnswerReceivedTcs = Common.TimedTaskCompletionSource(1000) - - conn1.On("SdpAnswerReceived", fun (offerId: ConnAttemptId) (sdpDescription: SdpDescription) -> - sdpAnswerReceivedTcs.SetResult(offerId, sdpDescription) - ) - |> ignore - - // Create connection attempt - let! offerId = - Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // Join connection attempt - do! offerId - |> signalingHub2.JoinConnectionAttempt - |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") - - // Send answer - let answerSdpDesc = Common.fakeSdpDescription - do! answerSdpDesc - |> signalingHub2.SendAnswer offerId - |> Task.map (Flip.Expect.isOk "Answer sending should success") - - // Test received answer - let! (receivedOfferId: ConnAttemptId, receivedSdpDesc: SdpDescription) = sdpAnswerReceivedTcs.Task - - Expect.equal - receivedOfferId - offerId - "Received offer ID should be the same" - - Expect.equal - receivedSdpDesc - answerSdpDesc - "Received SDP description should be the same" - } - - testTask "Send answer to nonexisting connection attempt" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - let fakeOfferId = ConnAttemptId.create() - - let! (error: Errors.SendAnswerError) = - signalingHub.SendAnswer - fakeOfferId - Common.fakeSdpDescription - |> Task.map (Flip.Expect.wantError "Sending answer to nonexisting connection attempt should return an error") - - Expect.equal - error - Errors.SendAnswerError.OfferNotFound - "Sending answer to nonexisting connection attempt should fail" - } - - testTask "Send answer to not joined connection attempt" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - - // Create connection attempt - let! offerId = - Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // Sending answer - let! (error: Errors.SendAnswerError) = - signalingHub2.SendAnswer - offerId - Common.fakeSdpDescription - |> Task.map (Flip.Expect.wantError "Sending answer to not joined connection attempt should return an error") - - Expect.equal - error - Errors.SendAnswerError.NotAnswerer - "Sending answer to not joined connection attempt should fail" - } - - testTask "Send answer to joined connection attempt by another player" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub3: ISignalingHub) = testServer |> connectHub - - // Create connection attempt - let! offerId = - Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // Join connection attempt - do! offerId - |> signalingHub2.JoinConnectionAttempt - |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") - - // Sending answer - let! (error: Errors.SendAnswerError) = - signalingHub3.SendAnswer - offerId - Common.fakeSdpDescription - |> Task.map (Flip.Expect.wantError "Sending answer should return an error") - - Expect.equal - error - Errors.SendAnswerError.NotAnswerer - "Sending answer connection attempt should fail" - } - ] - - testList "SendIceCandidate" [ - testTask "Send ice candidate" { - let! (conn1: HubConnection, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - - // Subscribe to IceCandidateReceived event - let iceCandidateReceivedTcs = Common.TimedTaskCompletionSource(1000) - - conn1.On("IceCandidateReceived", fun (offerId: ConnAttemptId) (iceCandidate: IceCandidate) -> - iceCandidateReceivedTcs.SetResult(offerId, iceCandidate) - ) - |> ignore - - // Create connection attempt - let! offerId = - Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // Join connection attempt - do! offerId - |> signalingHub2.JoinConnectionAttempt - |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") - - // Send ice candidate - let iceCandidate = Common.fakeIceCandidate - do! iceCandidate - |> signalingHub2.SendIceCandidate offerId - |> Task.map (Flip.Expect.isOk "Ice candidate sending should success") - - // Test received ice candidate - let! (receivedOfferId: ConnAttemptId, receivedIceCandidate: IceCandidate) = iceCandidateReceivedTcs.Task - - Expect.equal - receivedOfferId - offerId - "Received offer ID should be the same" - - Expect.equal - receivedIceCandidate - iceCandidate - "Received ice candidate should be the same" - } - - testTask "Send ice candidate to nonexisting connection attempt" { - let! (_, signalingHub: ISignalingHub) = testServer |> connectHub - - let fakeOfferId = ConnAttemptId.create() - - let! (error: Errors.SendIceCandidateError) = - signalingHub.SendIceCandidate - fakeOfferId - Common.fakeIceCandidate - |> Task.map (Flip.Expect.wantError "Sending ice candidate to nonexisting connection attempt should return an error") - - Expect.equal - error - Errors.SendIceCandidateError.OfferNotFound - "Sending ice candidate to nonexisting connection attempt should fail" - } - - testTask "Send ice candidate to not joined connection attempt" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - - // Create connection attempt - let! offerId = - Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // Sending ice candidate - let! (error: Errors.SendIceCandidateError) = - signalingHub2.SendIceCandidate - offerId - Common.fakeIceCandidate - |> Task.map (Flip.Expect.wantError "Sending ice candidate to not joined connection attempt should return an error") - - Expect.equal - error - Errors.SendIceCandidateError.NotAnswerer - "Sending ice candidate to not joined connection attempt should fail" - } - - testTask "Send ice candidate to joined connection attempt by another player" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub3: ISignalingHub) = testServer |> connectHub - - // Create connection attempt - let! offerId = - Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // Join connection attempt - do! offerId - |> signalingHub2.JoinConnectionAttempt - |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") - - // Sending ice candidate - let! (error: Errors.SendIceCandidateError) = - signalingHub3.SendIceCandidate - offerId - Common.fakeIceCandidate - |> Task.map (Flip.Expect.wantError "Sending ice candidate should return an error") - - Expect.equal - error - Errors.SendIceCandidateError.NotParticipant - "Sending ice candidate connection attempt should fail" - } - - testTask "Send ice candidate to connection attempt without answerer" { - let! (_, signalingHub1: ISignalingHub) = testServer |> connectHub - let! (_, signalingHub2: ISignalingHub) = testServer |> connectHub - - // Create connection attempt - let! offerId = - Common.fakeSdpDescription - |> signalingHub1.StartConnectionAttempt - |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") - - // Sending ice candidate - let! (error: Errors.SendIceCandidateError) = - signalingHub2.SendIceCandidate - offerId - Common.fakeIceCandidate - |> Task.map (Flip.Expect.wantError "Sending ice candidate should return an error") - - Expect.equal - error - Errors.SendIceCandidateError.NotAnswerer - "Sending ice candidate connection attempt should fail" - } - ] - ] diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/Common.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/Common.fs new file mode 100644 index 0000000..680a75d --- /dev/null +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/Common.fs @@ -0,0 +1,74 @@ +module Behide.OnlineServices.Tests.Signaling.Common + +open Behide.OnlineServices.Signaling +open Expecto +open FsToolkit.ErrorHandling + +open System.Text.Json.Serialization +open System.Threading.Tasks + +open Microsoft.Extensions.DependencyInjection +open Microsoft.AspNetCore.Http.Connections.Client +open Microsoft.AspNetCore.SignalR +open Microsoft.AspNetCore.SignalR.Client +open Microsoft.AspNetCore.TestHost + +type HandlerSetter(connection: HubConnection) = + member _.ConnectionRequested(handler: int -> Task) = connection.On("ConnectionRequested", handler) + member _.ConnectionRequested(handler: int -> ConnectionAttemptId | null) = connection.On("ConnectionRequested", handler) + member _.SdpAnswerReceived(handler: ConnectionAttemptId -> SdpDescription -> unit) = connection.On("SdpAnswerReceived", handler) + member _.IceCandidateReceived(handler: ConnectionAttemptId -> IceCandidate -> unit) = connection.On("IceCandidateReceived", handler) + +type TestHubClient(connection: HubConnection) = + let playerId = connection.ConnectionId |> PlayerId.fromHubConnectionId + let handlerSetter = HandlerSetter(connection) + + interface ISignalingHub with + member _.StartConnectionAttempt sdpDescription = connection.InvokeAsync<_>("StartConnectionAttempt", sdpDescription) + member _.JoinConnectionAttempt offerId = connection.InvokeAsync<_>("JoinConnectionAttempt", offerId) + member _.SendAnswer offerId iceCandidate = connection.InvokeAsync<_>("SendAnswer", offerId, iceCandidate) + member _.SendIceCandidate offerId iceCandidate = connection.InvokeAsync<_>("SendIceCandidate", offerId, iceCandidate) + member _.EndConnectionAttempt offerId = connection.InvokeAsync<_>("EndConnectionAttempt", offerId) + + member _.CreateRoom() = connection.InvokeAsync<_>("CreateRoom") + member _.JoinRoom roomId = connection.InvokeAsync<_>("JoinRoom", roomId) + member _.LeaveRoom() = connection.InvokeAsync<_>("LeaveRoom") + member _.ConnectToRoomPlayers() = connection.InvokeAsync<_>("ConnectToRoomPlayers") + + member this.StartConnectionAttempt sdpDescription = (this :> ISignalingHub).StartConnectionAttempt sdpDescription + member this.JoinConnectionAttempt offerId = (this :> ISignalingHub).JoinConnectionAttempt offerId + member this.SendAnswer offerId iceCandidate = (this :> ISignalingHub).SendAnswer offerId iceCandidate + member this.SendIceCandidate offerId iceCandidate = (this :> ISignalingHub).SendIceCandidate offerId iceCandidate + member this.EndConnectionAttempt offerId = (this :> ISignalingHub).EndConnectionAttempt offerId + + member this.CreateRoom() = (this :> ISignalingHub).CreateRoom() + member this.JoinRoom roomId = (this :> ISignalingHub).JoinRoom roomId + member this.LeaveRoom() = (this :> ISignalingHub).LeaveRoom() + member this.ConnectToRoomPlayers() = (this :> ISignalingHub).ConnectToRoomPlayers() + + member _.PlayerId = playerId + member _.SetHandlerFor = handlerSetter + member _.Connection = connection + + +let connectHub (testServer: TestServer) : Task = + let httpConnectionOptions (options: HttpConnectionOptions) = + options.HttpMessageHandlerFactory <- fun _ -> testServer.CreateHandler() + + let setupJsonProtocol (options: JsonHubProtocolOptions) = + JsonFSharpOptions + .Default() + .AddToJsonSerializerOptions(options.PayloadSerializerOptions) + + let url = testServer.BaseAddress.ToString() + "webrtc-signaling" + + let connection = + HubConnectionBuilder() + .WithUrl(url, httpConnectionOptions) + .AddJsonProtocol(setupJsonProtocol) + .Build() + + task { + do! connection.StartAsync() + return TestHubClient(connection) + } diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs new file mode 100644 index 0000000..267cd5e --- /dev/null +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/RoomManagement.fs @@ -0,0 +1,731 @@ +module Behide.OnlineServices.Tests.Signaling.RoomManagement + +open Expecto +open FsToolkit.ErrorHandling + +open System +open System.Collections.Concurrent +open System.Collections.Generic +open System.Threading +open System.Threading.Tasks + +open Behide.OnlineServices +open Behide.OnlineServices.Signaling +open Behide.OnlineServices.Tests +open Behide.OnlineServices.Tests.Signaling.Common + +let tests testServer (roomStore: Hubs.Signaling.IRoomStore) = + testList "RoomManagement" [ + testList "CreateRoom" [ + testTask "Create room should success" { + let! (hub: TestHubClient) = testServer |> connectHub + + let! roomId = + hub.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + let room = + roomStore.Get roomId + |> Flip.Expect.wantSome "Room should be created" + + Expect.equal room.Id roomId "Room ID should be the same" + } + + testTask "Create room while already in a room" { + let! (hub: TestHubClient) = testServer |> connectHub + + do! hub.CreateRoom() + |> Task.map (Flip.Expect.isOk "Room creation should success") + + let! (error: Errors.CreateRoomError) = + hub.CreateRoom() + |> Task.map (Flip.Expect.wantError "Second room creation should return an error") + + Expect.equal + error + Errors.CreateRoomError.PlayerAlreadyInARoom + "Room creation should fail" + } + ] + + testList "JoinRoom" [ + testTask "Join room should success" { + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub + + let mutable offerId = None + + // Add handler for creating offer + hub1.SetHandlerFor.ConnectionRequested(fun _playerId -> + Common.fakeSdpDescription + |> hub1.StartConnectionAttempt + |> Task.map (function + | Ok o -> offerId <- Some o; o + | Error e -> failwithf "Failed to create offer: %A" e) + ) + |> ignore + + + // Create a room and check if it was created + let! roomId = + hub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + roomStore.Get roomId + |> Flip.Expect.isSome "Room should be created" + + + // Join the room + let! peerId = + hub2.JoinRoom roomId + |> Task.map (Flip.Expect.wantOk "Room joining should success") + + // Retrieve the updated room + let room = + roomStore.Get roomId + |> Flip.Expect.wantSome "Room should still exist" + + + // Check if the room connection info is correct + Expect.equal peerId 2 "Peer ID should be 2 in that case" + + // Check if the room contains both players + Expect.containsAll + room.Players + [ KeyValuePair(hub1.PlayerId, 1) + KeyValuePair(hub2.PlayerId, 2) ] + "Room should contain both player connection ids" + } + + testTask "Join room while already in a room" { + let! (hub: TestHubClient) = testServer |> connectHub + + let! roomId = + hub.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + let! (error: Errors.JoinRoomError) = + roomId + |> hub.JoinRoom + |> Task.map (Flip.Expect.wantError "Joining room should return an error") + + Expect.equal + error + Errors.JoinRoomError.PlayerAlreadyInARoom + "Room joining should fail" + } + + testTask "Join nonexisting room" { + let! (hub: TestHubClient) = testServer |> connectHub + + let fakeRoomId = RoomId.create() + + let! (error: Errors.JoinRoomError) = + fakeRoomId + |> hub.JoinRoom + |> Task.map (Flip.Expect.wantError "Joining nonexisting room should return an error") + + Expect.equal + error + Errors.JoinRoomError.RoomNotFound + "Nonexisting room joining should fail" + } + + testTask "Joining room should give a unique peerId" { + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub + let! (hub3: TestHubClient) = testServer |> connectHub + + // Initialization + let! roomId = + hub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + let peerId1 = 1 // The first player should always have the peerId 1 + + // Join room + let! peerId2 = + hub2.JoinRoom roomId + |> Task.map (Flip.Expect.wantOk "Room joining should success") + + Expect.equal peerId2 2 "The second player should have the peerId to 2" + Expect.notEqual peerId1 peerId2 "The two players should have different peerId" + + // Leave and rejoin room + do! hub2.LeaveRoom() + |> Task.map (Flip.Expect.wantOk "Leaving room should success") + + let! peerId3 = + hub2.JoinRoom roomId + |> Task.map (Flip.Expect.wantOk "Room joining should success") + + Expect.equal peerId3 2 "The second player should have the peerId to 2" + Expect.notEqual peerId1 peerId3 "The two players should have different peerId" + + // Join room with a third player + let! peerId4 = + hub3.JoinRoom roomId + |> Task.map (Flip.Expect.wantOk "Room joining should success") + + Expect.equal peerId4 3 "The third player should have the peerId to 3" + Expect.notEqual peerId1 peerId4 "The two players should have different peerId" + } + + testTheoryTask + "Simultaneously joining room should give unique peerIds" + [ 3; 5; 10; 100 ] + (fun nbOfPlayers -> task { + let! roomCreator = testServer |> connectHub + let! joiningPlayers = + List.init nbOfPlayers (fun _ -> testServer |> connectHub) + |> Task.WhenAll + + // Create room + let! roomId = roomCreator.CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") + + // Join room + use cts = new CancellationTokenSource(TimeSpan.FromSeconds 20.) + let peerIds = ConcurrentBag([ roomCreator.PlayerId, 1 ]) + + do! Parallel.ForEachAsync( + joiningPlayers, + ParallelOptions( + MaxDegreeOfParallelism = nbOfPlayers, + CancellationToken = cts.Token + ), + Func(fun player ct -> + player.JoinRoom(roomId) + |> Task.map (Flip.Expect.wantOk "Should be able to join the room") + |> Task.map (fun peerId -> peerIds.Add (player.PlayerId, peerId)) + |> ValueTask + ) + ) + + // Check + Expect.hasLength peerIds (nbOfPlayers + 1) "All players should have a peer id" + + let distinctPeerIds = peerIds |> Array.ofSeq |> Array.distinct + Expect.hasLength distinctPeerIds peerIds.Count "Peer ids should all be unique" + + let room = roomStore.Get roomId |> Flip.Expect.wantSome "Room should still exist" + let roomPeerIds = + room.Players + |> Array.ofSeq + |> Array.map _.Deconstruct() + + Expect.containsAll roomPeerIds distinctPeerIds "Room should have assigned the peer ids to the correct players" + }) + ] + + testList "ConnectToRoomPlayers" [ + testTask "Connect to room players" { + // Create room + let! (hub1: TestHubClient) = testServer |> connectHub + + let! roomId = + hub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Failed to create a room") + + // Join room + let! (hub2: TestHubClient) = testServer |> connectHub + + let! secondPeerId = + hub2.JoinRoom roomId + |> Task.map (Flip.Expect.wantOk "Failed to join room") + + Expect.equal secondPeerId 2 "The second player should have a peerId to 2" + + // Register "ConnectionRequested" handler on first player + let mutable connAttemptId = None + + hub1.SetHandlerFor.ConnectionRequested(fun _peerId -> + Common.fakeSdpDescription + |> hub1.StartConnectionAttempt + |> Task.map (function + | Ok c -> connAttemptId <- Some c; c + | Error e -> failtestf "Failed to create connection attempt: %A" e) + ) + |> ignore + + // Connect players + let! (res: RoomConnectionInfo) = + hub2.ConnectToRoomPlayers() + |> Task.map (Flip.Expect.wantOk "Failed to create connection information for players") + + Expect.isEmpty res.FailedCreations "No connection attempt creation should be failed" + + let connAttemptId = Expect.wantSome connAttemptId "An connection attempt id should have been generated" + Expect.sequenceEqual + (res.PlayersConnectionInfo |> List.ofArray) + [ { PeerId = 1; ConnAttemptId = connAttemptId } ] + "Players connection info should contain the first player connection info and only that" + + // Check connections + let room = + roomStore.Get roomId + |> Flip.Expect.wantSome "Room should still exist" + + Expect.containsAll + room.Connections + [ Pair.create hub1.PlayerId hub2.PlayerId ] + "Room should contain the connection between the two players" + } + + testTheoryTask + "Connect to room players with multiple players" + [ 3, 0 + 10, 4 + 100, 99 ] + <| fun (nbOfPeers: int, peerThatConnects: int) -> task { + let! players = + List.init nbOfPeers (fun _ -> testServer |> connectHub) + |> Task.WhenAll + |> Task.map List.ofArray + + // Register handlers + use cts = new CancellationTokenSource() + let connAttemptIdsTask = + players + |> List.indexed + |> List.choose (fun (idx, hub) -> + let connAttemptIdTcs = TaskCompletionSource>() + cts.Token.Register(fun _ -> connAttemptIdTcs.TrySetCanceled() |> ignore) |> ignore + + hub.SetHandlerFor.ConnectionRequested(fun _ -> + Common.fakeSdpDescription + |> hub.StartConnectionAttempt + |> Task.map (fun res -> + connAttemptIdTcs.SetResult(res) + + match res with + | Ok c -> c + | Error _ -> failwith "Failed to create connection attempt" + ) + ) + |> ignore + + match idx = peerThatConnects with // Don't await the connection attempt creation for the peer that will connect + | true -> None + | false -> Some connAttemptIdTcs.Task + ) + |> List.sequenceTaskResultA + |> Task.map (Flip.Expect.wantOk "Failed to create some connection attempt") + + // Create room + let! roomId = + (players |> List.head).CreateRoom() + |> Task.map (Flip.Expect.wantOk "Failed to create room") + + // Join room + do! players + |> List.tail + |> List.map (fun hub -> hub.JoinRoom roomId) + |> List.sequenceTaskResultA + |> Task.map (Flip.Expect.isOk "All players should be able to join the room") + + // Connect players + let! res = + players[peerThatConnects].ConnectToRoomPlayers() + |> Task.map (Flip.Expect.wantOk "Failed to create connection information for players") + + cts.CancelAfter 1000 + let! connAttemptIds = connAttemptIdsTask + + Expect.isEmpty res.FailedCreations "All connection attempt creations should be successful" + Expect.hasLength res.PlayersConnectionInfo (players.Length - 1) "Their should be one player connection info per player to connect to" + Expect.hasLength connAttemptIds (players.Length - 1) "Their should be one connection attempt id per players to connect to" + Expect.hasLength connAttemptIds res.PlayersConnectionInfo.Length "Their should be one connection attempt id per player connection info" + + Expect.containsAll + connAttemptIds + (res.PlayersConnectionInfo |> Array.map _.ConnAttemptId) + "Created connection attempt ids should be the same that the received connection attempt ids" + + // Check connections + let room = + roomStore.Get roomId + |> Flip.Expect.wantSome "Room should still exist" + + let playerIdThatConnects = + players + |> List.item peerThatConnects + |> _.PlayerId + + let expectedConnections = + players + |> List.choose (fun hub -> + match hub.PlayerId <> playerIdThatConnects with + | false -> None // The player that connects should not be connected to himself + | true -> Some <| Pair.create playerIdThatConnects hub.PlayerId + ) + + Expect.containsAll + room.Connections + expectedConnections + "Room should contain the all the new connections" + } + + testTheoryTask + "Simultaneously connect players" + [ 3, [1; 2] + 10, [5; 6; 7; 8; 9] + 100, (List.init 50 ((+) 50)) + 100, (List.init 99 ((+) 1)) ] + <| fun (nbOfPlayers: int, playersThatConnect: int list) -> task { + let! players = + List.init nbOfPlayers (fun _ -> testServer |> connectHub) + |> Task.WhenAll + |> Task.map List.ofArray + + // Register handlers + let connectionAttemptIds = ConcurrentBag() + players |> List.iter (fun hub -> + hub.SetHandlerFor.ConnectionRequested(fun _ -> + Task.Run(fun () -> + Common.fakeSdpDescription + |> hub.StartConnectionAttempt + |> TaskResult.tee connectionAttemptIds.Add + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should succeed") + ) + ) + |> ignore + ) + + // Create room + let! roomId = + players + |> List.head + |> _.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Failed to create room") + + // Join room + let! playerIndexByPlayerPeerId = + players + |> List.tail + |> List.mapi (fun idx hub -> + let playerIdx = idx + 1 + hub.JoinRoom roomId + |> TaskResult.map (fun peerId -> peerId, playerIdx) + ) + |> List.sequenceTaskResultA + |> Task.map ( + Flip.Expect.wantOk "All players should be able to join the room" + >> List.append [ 1, 0 ] // Room creator + >> dict + ) + + // Connect players + use cts = new CancellationTokenSource(TimeSpan.FromSeconds 10.) + let connectionsMade = Array.init nbOfPlayers (fun _ -> Array.create nbOfPlayers false) + + do! Parallel.ForEachAsync( + playersThatConnect, + ParallelOptions( + MaxDegreeOfParallelism = playersThatConnect.Length, + CancellationToken = cts.Token + ), + Func(fun playerIdx _ -> + task { + let hub = players[playerIdx] + + let! res = hub.ConnectToRoomPlayers() + let connectionInfo = Expect.wantOk res "ConnectToRoomPlayers method should succeed" + Expect.isEmpty connectionInfo.FailedCreations $"All connection attempt creations should be successful: PlayerIdx {playerIdx}" + + for connectionInfo in connectionInfo.PlayersConnectionInfo do + let targetPlayerIdx = playerIndexByPlayerPeerId[connectionInfo.PeerId] + connectionsMade[playerIdx][targetPlayerIdx] <- true + connectionsMade[targetPlayerIdx][playerIdx] <- true + } + |> ValueTask + ) + ) + + // Check that players that should connect are connected to every other players + playersThatConnect |> List.iter (fun connectingPlayerIdx -> + let playerConnections = connectionsMade[connectingPlayerIdx] + + playerConnections |> Array.iteri (fun targetPlayerIdx isConnected -> + match connectingPlayerIdx = targetPlayerIdx with + | true -> + Expect.isFalse + isConnected + $"Connection {connectingPlayerIdx} <-> {targetPlayerIdx} should not be established. We cannot connect to ourself" + | false -> + Expect.isTrue + isConnected + $"Connection {connectingPlayerIdx} <-> {targetPlayerIdx} should be established" + ) + ) + + // Check connections are well represented on the room store + let room = roomStore.Get roomId |> Flip.Expect.wantSome "Room should still exist" + + Expect.isEmpty room.ConnectionsInProgress "No connection should still be in progress" + + let expectedConnections = + playersThatConnect |> List.collect (fun playerIdx -> + let requestingPlayerId = players[playerIdx].PlayerId + + List.init nbOfPlayers (fun otherPlayerIdx -> players[otherPlayerIdx].PlayerId) + |> List.filter ((<>) requestingPlayerId) + |> List.map (Pair.create requestingPlayerId) + ) + + Expect.containsAll room.Connections expectedConnections "All connections should be present" + } + + testTask "Connect to players without a ConnectionRequested handler" { + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub + + let! roomId = + hub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + do! hub2.JoinRoom roomId + |> Task.map (Flip.Expect.wantOk "Room joining should success") + + let! (res: RoomConnectionInfo) = + hub2.ConnectToRoomPlayers() + |> Task.map (Flip.Expect.wantOk "Connecting to room players should return an \"Ok\" result") + + Expect.sequenceEqual + res.FailedCreations + [ 1 ] // 1 is the peerId of the first player, the one who created the room + "Joining room without add handler for ConnectionRequested should fail" + + Expect.isEmpty res.PlayersConnectionInfo "No connection info should be returned" + } + + testTask "Connect to players with a blocking ConnectionRequested handler" { + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub + + // Create room + let! roomId = + hub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + // Join room + do! hub2.JoinRoom roomId + |> Task.map (Flip.Expect.wantOk "Room joining should success") + + // Register blocking "ConnectionRequested" handler on first player + hub1.SetHandlerFor.ConnectionRequested(fun _playerId -> + while true do () // Never returns + ConnectionAttemptId.create() + ) + |> ignore + + let! (res: RoomConnectionInfo) = + hub2.ConnectToRoomPlayers() + |> Task.map (Flip.Expect.wantOk "Connecting to room players should return an \"Ok\" result") + + Expect.sequenceEqual + res.FailedCreations + [ 1 ] // 1 is the peerId of the first player, the one who created the room + "Joining room with a blocking ConnectionRequested handler should fail" + + Expect.isEmpty res.PlayersConnectionInfo "No connection info should be returned" + } + + testTask "Connect to players while not in a room" { + let! (hub: TestHubClient) = testServer |> connectHub + + let! (error: Errors.ConnectToRoomPlayersError) = + hub.ConnectToRoomPlayers() + |> Task.map (Flip.Expect.wantError "Connecting to room players while not in a room should return an error") + + Expect.equal + error + Errors.ConnectToRoomPlayersError.NotInARoom + "Connecting to room players while not in a room should fail" + } + ] + + testList "LeaveRoom" [ + testTask "Leave room" { + // Create room + let! (hub1: TestHubClient) = testServer |> connectHub + + let! roomId = + hub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + // Join room + let! (hub2: TestHubClient) = testServer |> connectHub + + do! roomId + |> hub2.JoinRoom + |> Task.map (Flip.Expect.isOk "Room joining should success") + + // Leave room + do! hub2.LeaveRoom() + |> Task.map (Flip.Expect.wantOk "Leaving room should success") + + // Check if the player is not in the room anymore + let room = + roomStore.Get roomId + |> Flip.Expect.wantSome "Room should still exist" + + Expect.sequenceEqual + room.Players + [ KeyValuePair(hub1.PlayerId, 1) ] + "Room should not contain the second player" + + Expect.isEmpty room.Connections "Room should not contain any connection" + } + + testTask "Leave room where we are connected to players" { + // Create room + let! (hub1: TestHubClient) = testServer |> connectHub + + let! roomId = + hub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + // Join room + let! (hub2: TestHubClient) = testServer |> connectHub + + do! roomId + |> hub2.JoinRoom + |> Task.map (Flip.Expect.isOk "Room joining should success") + + // Connect to room players + hub1.SetHandlerFor.ConnectionRequested(fun _playerId -> + Common.fakeSdpDescription + |> hub1.StartConnectionAttempt + |> Task.map (function + | Ok c -> c + | Error e -> failwithf "Failed to create connection attempt: %A" e) + ) + |> ignore + + do! hub2.ConnectToRoomPlayers() + |> Task.map (Flip.Expect.isOk "Connecting to room players should success") + + // Leave room + do! hub2.LeaveRoom() + |> Task.map (Flip.Expect.isOk "Leaving room should success") + + // Check if the player is not in the room anymore + let room = + roomStore.Get roomId + |> Flip.Expect.wantSome "Room should still exist" + + Expect.sequenceEqual + room.Players + [ KeyValuePair(hub1.PlayerId, 1) ] + "Room should not contain the second player" + + Expect.isEmpty room.Connections "Room should not contain any connection" + } + + testTask "Leave room while not in a room" { + let! (hub: TestHubClient) = testServer |> connectHub + + let! (error: Errors.LeaveRoomError) = + hub.LeaveRoom() + |> Task.map (Flip.Expect.wantError "Leaving room while not in a room should return an error") + + Expect.equal + error + Errors.LeaveRoomError.NotInARoom + "Leaving room while not in a room should fail" + } + + testTask "Leave room while being the last player delete the room" { + let! (hub1: TestHubClient) = testServer |> connectHub + + // Create room + let! roomId = + hub1.CreateRoom() + |> Task.map (Flip.Expect.wantOk "Room creation should success") + + // Leave room + do! hub1.LeaveRoom() + |> Task.map (Flip.Expect.wantOk "Leaving room should success") + + // Check if the room is removed + roomStore.Get roomId + |> Flip.Expect.isNone "Room should be removed" + } + + testTheoryTask + "Leave room while other players are joining" + [ (3, 1, 1) + (3, 3, 2) + (3, 2, 3) + (100, 50, 50) + (100, 100, 42) ] + (fun (initialPlayerCount, leavingPlayerCount, joiningPlayerCount) -> task { + let! inRoomPlayers = List.init initialPlayerCount (fun _ -> testServer |> connectHub) |> Task.WhenAll + let! joiningPlayers = List.init joiningPlayerCount (fun _ -> testServer |> connectHub) |> Task.WhenAll + let leavingPlayers = ArraySegment(inRoomPlayers, initialPlayerCount - leavingPlayerCount, leavingPlayerCount) + + let! roomId = inRoomPlayers[0].CreateRoom() |> Task.map (Flip.Expect.wantOk "Room creation should success") + do! inRoomPlayers + |> Array.tail + |> Array.map _.JoinRoom(roomId) + |> Task.WhenAll + |> Task.map (Array.iter (Flip.Expect.isOk "Should be able to join room")) + + use cts = new CancellationTokenSource() + let joinTcs = TaskCompletionSource() + let leaveTcs = TaskCompletionSource() + + let join () = + Parallel.ForEachAsync( + joiningPlayers, + ParallelOptions( + MaxDegreeOfParallelism = joiningPlayers.Length, + CancellationToken = cts.Token + ), + Func(fun player ct -> + player.JoinRoom(roomId) + |> Task.map (Flip.Expect.isOk "Should be able to join the room") + |> ValueTask + ) + ).ContinueWith(fun _ -> joinTcs.SetResult()) + |> ignore + let leave () = + Parallel.ForEachAsync( + leavingPlayers, + ParallelOptions( + MaxDegreeOfParallelism = joiningPlayers.Length, + CancellationToken = cts.Token + ), + Func(fun player ct -> + player.LeaveRoom() + |> Task.map (Flip.Expect.isOk "Should be able to leave the room") + |> ValueTask + ) + ).ContinueWith(fun _ -> leaveTcs.SetResult()) + |> ignore + + cts.CancelAfter(TimeSpan.FromSeconds 20.) + Parallel.Invoke(join, leave) + do! Task.WhenAll(joinTcs.Task, leaveTcs.Task) + + let room = roomStore.Get roomId |> Flip.Expect.wantSome "Room should still exist" + + // Check if players correctly leaved + Expect.all + leavingPlayers + (fun player -> room.Players.ContainsKey player.PlayerId |> not) + "All leaving players id should be absent from the room dict" + + // Check if players correctly joined + Expect.all + joiningPlayers + (fun player -> room.Players.ContainsKey player.PlayerId) + "All joining players id should be present in the room dict" + + Expect.equal + room.Players.Count + (initialPlayerCount - leavingPlayerCount + joiningPlayerCount) + "The room should have the correct number of players" + }) + ] + ] diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs new file mode 100644 index 0000000..cebf8e4 --- /dev/null +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/Signaling.fs @@ -0,0 +1,31 @@ +module Behide.OnlineServices.Tests.Signaling.All + +open Expecto +open Microsoft.AspNetCore.SignalR.Client + +open Behide.OnlineServices.Signaling +open Behide.OnlineServices.Tests +open Behide.OnlineServices.Tests.Signaling +open Behide.OnlineServices.Tests.Signaling.Common + +[] +let signalingTests = + let testServer, connectionAttemptStore, roomStore, playerConnStore = Common.createTestServer() + + testList "Signaling" [ + testTask "Signaling hub connection should success" { + let! (hub: TestHubClient) = testServer |> connectHub + + Expect.equal hub.Connection.State HubConnectionState.Connected "Should be connected to the hub" + + let playerId = hub.Connection.ConnectionId |> PlayerId.fromHubConnectionId + let player = + playerConnStore.Get playerId + |> Flip.Expect.wantSome "Client should be registered in the player connections store" + + Expect.equal player.Id playerId "Player ids should be the same" + } + + WebRTCSignaling.tests testServer connectionAttemptStore + RoomManagement.tests testServer roomStore + ] diff --git a/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs b/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs new file mode 100644 index 0000000..a74a2cc --- /dev/null +++ b/src/Behide.OnlineServices.Tests/Tests/Signaling/WebRTCSignaling.fs @@ -0,0 +1,548 @@ +module Behide.OnlineServices.Tests.Signaling.WebRTCSignaling + +open Expecto +open FsToolkit.ErrorHandling + +open Behide.OnlineServices +open Behide.OnlineServices.Signaling +open Behide.OnlineServices.Tests +open Behide.OnlineServices.Tests.Signaling.Common + +let tests testServer (connectionAttemptStore: Hubs.Signaling.IConnectionAttemptStore) = + testList "WebRTC Signaling" [ + testList "StartConnectionAttempt" [ + testTask "Create connection attempt" { + let! (signalingHub: TestHubClient) = testServer |> connectHub + + let! offerId = + Common.fakeSdpDescription + |> signalingHub.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // Check if the offer was created + let offer = + connectionAttemptStore.Get offerId + |> Flip.Expect.wantSome "Offer should be created" + + Expect.equal + offer.InitiatorConnectionId + signalingHub.PlayerId + "Offer initiator should be the player connection id" + + Expect.equal + offer.Offer + Common.fakeSdpDescription + "Offer SDP description should be the same" + } + ] + + testList "JoinConnectionAttempt" [ + testTask "Join connection attempt" { + // Create a connection attempt + let! (signalingHub1: TestHubClient) = testServer |> connectHub + + let originalSdpDesc = Common.fakeSdpDescription + + let! offerId = + originalSdpDesc + |> signalingHub1.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // Check if the offer has answerer (it should not) + connectionAttemptStore.Get offerId + |> Flip.Expect.wantSome "Offer should exist" + |> _.Answerer + |> Flip.Expect.isNone "Offer should be marked as not answered" + + // Join connection attempt + let! (signalingHub2: TestHubClient) = testServer |> connectHub + + let! sdpDescription = + offerId + |> signalingHub2.JoinConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt joining should success") + + Expect.equal + sdpDescription + originalSdpDesc + "SDP description should be the same" + + // Check if the offer is marked as answered + let offer = + connectionAttemptStore.Get offerId + |> Flip.Expect.wantSome "Offer should exist" + + Expect.isSome offer.Answerer "Offer should has an answerer" + + Expect.equal + offer.InitiatorConnectionId + signalingHub1.PlayerId + "Offer initiator should be the first player connection id" + } + + testTask "Join nonexisting connection attempt" { + let! (signalingHub: TestHubClient) = testServer |> connectHub + + let fakeOfferId = ConnectionAttemptId.create() + + let! (error: Errors.JoinConnectionAttemptError) = + fakeOfferId + |> signalingHub.JoinConnectionAttempt + |> Task.map (Flip.Expect.wantError "Joining nonexisting connection attempt should return an error") + + Expect.equal + error + Errors.JoinConnectionAttemptError.ConnectionAttemptNotFound + "Nonexisting connection attempt joining should fail" + } + + testTask "Join already answered connection attempt" { + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub + let! (signalingHub3: TestHubClient) = testServer |> connectHub + + // Create a connection attempt + let! offerId = + Common.fakeSdpDescription + |> signalingHub1.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // Join connection attempt + do! offerId + |> signalingHub2.JoinConnectionAttempt + |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") + + // Try to join the same connection attempt again + let! (error: Errors.JoinConnectionAttemptError) = + offerId + |> signalingHub3.JoinConnectionAttempt + |> Task.map (Flip.Expect.wantError "Joining already answered connection attempt should return an error") + + Expect.equal + error + Errors.JoinConnectionAttemptError.ConnectionAttemptAlreadyAnswered + "Already answered connection attempt joining should fail" + } + + testTask "Join connection attempt as the initiator" { + let! (signalingHub: TestHubClient) = testServer |> connectHub + + // Create connection attempt + let! offerId = + Common.fakeSdpDescription + |> signalingHub.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // Join connection attempt + let! (error: Errors.JoinConnectionAttemptError) = + offerId + |> signalingHub.JoinConnectionAttempt + |> Task.map (Flip.Expect.wantError "Joining connection attempt as the initiator should return an error") + + Expect.equal + error + Errors.JoinConnectionAttemptError.InitiatorCannotJoin + "Joining connection attempt as the initiator should fail" + } + ] + + testList "EndConnectionAttempt" [ + testTask "Initiator end connection attempt" { + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub + + // Create connection attempt + let! offerId = + Common.fakeSdpDescription + |> hub1.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // Join connection attempt + do! offerId + |> hub2.JoinConnectionAttempt + |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") + + // End connection attempt + do! offerId + |> hub1.EndConnectionAttempt + |> Task.map (Flip.Expect.isOk "Connection attempt ending should success") + + // Check if the offer is removed + connectionAttemptStore.Get offerId + |> Flip.Expect.isNone "Offer should be removed" + } + + testTask "Answerer end connection attempt" { + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub + + // Create connection attempt + let! offerId = + Common.fakeSdpDescription + |> hub1.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // Join connection attempt + do! offerId + |> hub2.JoinConnectionAttempt + |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") + + // End connection attempt + do! offerId + |> hub2.EndConnectionAttempt + |> Task.map (Flip.Expect.isOk "Connection attempt ending should success") + + // Check if the offer is removed + connectionAttemptStore.Get offerId + |> Flip.Expect.isNone "Offer should be removed" + } + + testTask "End nonexisting connection attempt" { + let! (hub: TestHubClient) = testServer |> connectHub + + let fakeOfferId = ConnectionAttemptId.create() + + let! (error: Errors.EndConnectionAttemptError) = + hub.EndConnectionAttempt fakeOfferId + |> Task.map (Flip.Expect.wantError "Ending nonexisting connection attempt should return an error") + + Expect.equal + error + Errors.EndConnectionAttemptError.ConnectionAttemptNotFound + "Ending nonexisting connection attempt should fail" + } + + testTask "End ended connection attempt" { + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub + + // Create connection attempt + let! offerId = + Common.fakeSdpDescription + |> hub1.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // Join connection attempt + do! offerId + |> hub2.JoinConnectionAttempt + |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") + + // End connection attempt + do! offerId + |> hub1.EndConnectionAttempt + |> Task.map (Flip.Expect.isOk "Connection attempt ending should success") + + // End connection attempt again + let! (error: Errors.EndConnectionAttemptError) = + offerId + |> hub1.EndConnectionAttempt + |> Task.map (Flip.Expect.wantError "Ending already ended connection attempt should return an error") + + Expect.equal + error + Errors.EndConnectionAttemptError.ConnectionAttemptNotFound + "Ending already ended connection attempt should fail" + } + + testTask "End connection attempt as not participant" { + let! (hub1: TestHubClient) = testServer |> connectHub + let! (hub2: TestHubClient) = testServer |> connectHub + + // Create connection attempt + let! offerId = + Common.fakeSdpDescription + |> hub1.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // End connection attempt + let! (error: Errors.EndConnectionAttemptError) = + offerId + |> hub2.EndConnectionAttempt + |> Task.map (Flip.Expect.wantError "Ending connection attempt as not participant should return an error") + + Expect.equal + error + Errors.EndConnectionAttemptError.NotParticipant + "Ending connection attempt as not participant should fail" + } + + testTask "End answered connection attempt as not participant" { + let! (hub1: TestHubClient) = testServer |> connectHub // Initiator + let! (hub2: TestHubClient) = testServer |> connectHub // Answerer + let! (hub3: TestHubClient) = testServer |> connectHub // Other + + // Create connection attempt + let! offerId = + Common.fakeSdpDescription + |> hub1.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // Join connection attempt + do! offerId + |> hub2.JoinConnectionAttempt + |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") + + // End connection attempt as not participant + let! (error: Errors.EndConnectionAttemptError) = + offerId + |> hub3.EndConnectionAttempt + |> Task.map (Flip.Expect.wantError "Ending connection attempt as not participant should return an error") + + Expect.equal + error + Errors.EndConnectionAttemptError.NotParticipant + "Ending connection attempt as not participant should fail" + } + ] + + testList "SendAnswer" [ + testTask "Send answer" { + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub + + // Subscribe to SdpAnswerReceived event + let sdpAnswerReceivedTcs = Common.TimedTaskCompletionSource(1000) + + signalingHub1.SetHandlerFor.SdpAnswerReceived(fun offerId sdpDescription -> + sdpAnswerReceivedTcs.SetResult(offerId, sdpDescription) + ) + |> ignore + + // Create connection attempt + let! offerId = + Common.fakeSdpDescription + |> signalingHub1.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // Join connection attempt + do! offerId + |> signalingHub2.JoinConnectionAttempt + |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") + + // Send answer + let answerSdpDesc = Common.fakeSdpDescription + do! answerSdpDesc + |> signalingHub2.SendAnswer offerId + |> Task.map (Flip.Expect.isOk "Answer sending should success") + + // Test received answer + let! (receivedOfferId: ConnectionAttemptId, receivedSdpDesc: SdpDescription) = sdpAnswerReceivedTcs.Task + + Expect.equal + receivedOfferId + offerId + "Received offer ID should be the same" + + Expect.equal + receivedSdpDesc + answerSdpDesc + "Received SDP description should be the same" + } + + testTask "Send answer to nonexisting connection attempt" { + let! (signalingHub: TestHubClient) = testServer |> connectHub + + let fakeOfferId = ConnectionAttemptId.create() + + let! (error: Errors.SendAnswerError) = + signalingHub.SendAnswer + fakeOfferId + Common.fakeSdpDescription + |> Task.map (Flip.Expect.wantError "Sending answer to nonexisting connection attempt should return an error") + + Expect.equal + error + Errors.SendAnswerError.ConnectionAttemptNotFound + "Sending answer to nonexisting connection attempt should fail" + } + + testTask "Send answer to not joined connection attempt" { + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub + + // Create connection attempt + let! offerId = + Common.fakeSdpDescription + |> signalingHub1.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // Sending answer + let! (error: Errors.SendAnswerError) = + signalingHub2.SendAnswer + offerId + Common.fakeSdpDescription + |> Task.map (Flip.Expect.wantError "Sending answer to not joined connection attempt should return an error") + + Expect.equal + error + Errors.SendAnswerError.NotAnswerer + "Sending answer to not joined connection attempt should fail" + } + + testTask "Send answer to joined connection attempt by another player" { + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub + let! (signalingHub3: TestHubClient) = testServer |> connectHub + + // Create connection attempt + let! offerId = + Common.fakeSdpDescription + |> signalingHub1.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // Join connection attempt + do! offerId + |> signalingHub2.JoinConnectionAttempt + |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") + + // Sending answer + let! (error: Errors.SendAnswerError) = + signalingHub3.SendAnswer + offerId + Common.fakeSdpDescription + |> Task.map (Flip.Expect.wantError "Sending answer should return an error") + + Expect.equal + error + Errors.SendAnswerError.NotAnswerer + "Sending answer connection attempt should fail" + } + ] + + testList "SendIceCandidate" [ + testTask "Send ice candidate" { + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub + + // Subscribe to IceCandidateReceived event + let iceCandidateReceivedTcs = Common.TimedTaskCompletionSource(1000) + + signalingHub1.SetHandlerFor.IceCandidateReceived(fun offerId iceCandidate -> + iceCandidateReceivedTcs.SetResult(offerId, iceCandidate) + ) + |> ignore + + // Create connection attempt + let! offerId = + Common.fakeSdpDescription + |> signalingHub1.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // Join connection attempt + do! offerId + |> signalingHub2.JoinConnectionAttempt + |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") + + // Send ice candidate + let iceCandidate = Common.fakeIceCandidate + do! iceCandidate + |> signalingHub2.SendIceCandidate offerId + |> Task.map (Flip.Expect.isOk "Ice candidate sending should success") + + // Test received ice candidate + let! (receivedOfferId: ConnectionAttemptId, receivedIceCandidate: IceCandidate) = iceCandidateReceivedTcs.Task + + Expect.equal + receivedOfferId + offerId + "Received offer ID should be the same" + + Expect.equal + receivedIceCandidate + iceCandidate + "Received ice candidate should be the same" + } + + testTask "Send ice candidate to nonexisting connection attempt" { + let! (signalingHub: TestHubClient) = testServer |> connectHub + + let fakeOfferId = ConnectionAttemptId.create() + + let! (error: Errors.SendIceCandidateError) = + signalingHub.SendIceCandidate + fakeOfferId + Common.fakeIceCandidate + |> Task.map (Flip.Expect.wantError "Sending ice candidate to nonexisting connection attempt should return an error") + + Expect.equal + error + Errors.SendIceCandidateError.ConnectionAttemptNotFound + "Sending ice candidate to nonexisting connection attempt should fail" + } + + testTask "Send ice candidate to not joined connection attempt" { + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub + + // Create connection attempt + let! offerId = + Common.fakeSdpDescription + |> signalingHub1.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // Sending ice candidate + let! (error: Errors.SendIceCandidateError) = + signalingHub2.SendIceCandidate + offerId + Common.fakeIceCandidate + |> Task.map (Flip.Expect.wantError "Sending ice candidate to not joined connection attempt should return an error") + + Expect.equal + error + Errors.SendIceCandidateError.NoAnswerer + "Sending ice candidate to not joined connection attempt should fail" + } + + testTask "Send ice candidate to joined connection attempt by another player" { + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub + let! (signalingHub3: TestHubClient) = testServer |> connectHub + + // Create connection attempt + let! offerId = + Common.fakeSdpDescription + |> signalingHub1.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // Join connection attempt + do! offerId + |> signalingHub2.JoinConnectionAttempt + |> Task.map (Flip.Expect.isOk "Connection attempt joining should success") + + // Sending ice candidate + let! (error: Errors.SendIceCandidateError) = + signalingHub3.SendIceCandidate + offerId + Common.fakeIceCandidate + |> Task.map (Flip.Expect.wantError "Sending ice candidate should return an error") + + Expect.equal + error + Errors.SendIceCandidateError.NotParticipant + "Sending ice candidate connection attempt should fail" + } + + testTask "Send ice candidate to connection attempt without answerer" { + let! (signalingHub1: TestHubClient) = testServer |> connectHub + let! (signalingHub2: TestHubClient) = testServer |> connectHub + + // Create connection attempt + let! offerId = + Common.fakeSdpDescription + |> signalingHub1.StartConnectionAttempt + |> Task.map (Flip.Expect.wantOk "Connection attempt creation should success") + + // Sending ice candidate + let! (error: Errors.SendIceCandidateError) = + signalingHub2.SendIceCandidate + offerId + Common.fakeIceCandidate + |> Task.map (Flip.Expect.wantError "Sending ice candidate should return an error") + + Expect.equal + error + Errors.SendIceCandidateError.NoAnswerer + "Sending ice candidate connection attempt should fail" + } + ] + ] diff --git a/src/Behide.OnlineServices.Tests/Tests/Types.fs b/src/Behide.OnlineServices.Tests/Tests/Types.fs new file mode 100644 index 0000000..3f1e00e --- /dev/null +++ b/src/Behide.OnlineServices.Tests/Tests/Types.fs @@ -0,0 +1,58 @@ +module Behide.OnlineServices.Tests.Types + +open Behide.OnlineServices +open Expecto + +[] +let tests = + testList "Types" [ + testList "Pair" [ + testCase "Test pairs are unordered" (fun () -> + let stringPair1 = Pair.create "a" "b" + let stringPair2 = Pair.create "b" "a" + + let intPair1 = Pair.create 69 42 + let intPair2 = Pair.create 42 69 + + Expect.equal stringPair1 stringPair2 "Pairs should equal" + Expect.equal intPair1 intPair2 "Pairs should equal" + ) + + testCase "Test isInPair works" (fun () -> + let stringPair = Pair.create "a" "b" + Expect.isTrue ("a" |> Pair.isInPair stringPair) "Pair should contain \"a\"" + Expect.isTrue ("b" |> Pair.isInPair stringPair) "Pair should contain \"b\"" + + let intPair = Pair.create 69 42 + Expect.isTrue (69 |> Pair.isInPair intPair) "Pair should contain 69" + Expect.isTrue (42 |> Pair.isInPair intPair) "Pair should contain 42" + ) + ] + + testList "Signaling" [ + testList "RoomId" [ + testTheory "Correct parsing" + [ "abcd" + "ABCD" + "0123" + "a1b3" + "A1b3" + "A1B3" ] + (Signaling.RoomId.tryParse >> Flip.Expect.isSome "Room id should be parsable") + + testTheory "Incorrect room id not parsable" + [ "abc" + "abç" + "abçd" + "ABÇD" + "^123" + "ä1b3" + "A1ü3" + "Ü1Ö3" + "01234" + "😃😃😃😃" + "😃😃" ] + (Signaling.RoomId.tryParse >> Flip.Expect.isNone "Room id should not be parsable") + ] + ] + ] diff --git a/src/Behide.OnlineServices.Types/Behide.OnlineServices.Types.fsproj b/src/Behide.OnlineServices.Types/Behide.OnlineServices.Types.fsproj index ee065c0..24829c2 100644 --- a/src/Behide.OnlineServices.Types/Behide.OnlineServices.Types.fsproj +++ b/src/Behide.OnlineServices.Types/Behide.OnlineServices.Types.fsproj @@ -4,6 +4,8 @@ net9.0 + + \ No newline at end of file diff --git a/src/Behide.OnlineServices.Types/Signaling.fs b/src/Behide.OnlineServices.Types/Signaling.fs index 3f77050..786000d 100644 --- a/src/Behide.OnlineServices.Types/Signaling.fs +++ b/src/Behide.OnlineServices.Types/Signaling.fs @@ -1,7 +1,9 @@ namespace Behide.OnlineServices.Signaling open System +open System.Collections.Generic open System.Threading.Tasks +open Behide.OnlineServices type SdpDescription = { ``type``: string @@ -12,30 +14,26 @@ type IceCandidate = index: int name: string } -/// Represents a SignalR connection id -type ConnId = - private | ConnId of string - static member parse connId = ConnId connId - static member raw (ConnId connId) = connId +type PlayerId = + private | PlayerId of string + static member fromHubConnectionId connId = PlayerId connId + static member raw (PlayerId connId) = connId // --- Connection attempt -/// Represents an connection attempt id -type ConnAttemptId = - private | ConnAttemptId of Guid - static member create () = Guid.NewGuid() |> ConnAttemptId - static member raw (ConnAttemptId guid) = guid.ToString() - -/// A peer to peer connection attempt -type ConnAttempt = - { Id: ConnAttemptId - InitiatorConnectionId: ConnId - SdpDescription: SdpDescription - Answerer: ConnId option } - +type ConnectionAttemptId = + private | ConnectionAttemptId of Guid + static member create () = Guid.NewGuid() |> ConnectionAttemptId + static member raw (ConnectionAttemptId guid) = guid.ToString() + +/// A WebRTC connection attempt +type ConnectionAttempt = + { Id: ConnectionAttemptId + InitiatorConnectionId: PlayerId + Offer: SdpDescription + Answerer: PlayerId option } // --- Room -/// Represents a room id type RoomId = private | RoomId of string @@ -58,111 +56,53 @@ type RoomId = |> RoomId static member tryParse (str: string) = + let loweredStr = str.ToLowerInvariant() let validStr = - str.Length = 4 - && str |> Seq.forall (fun char -> Array.contains char chars) + loweredStr.Length = 4 + && loweredStr |> Seq.forall (fun char -> Array.contains char chars) match validStr with | false -> None - | true -> RoomId str |> Some + | true -> RoomId loweredStr |> Some /// A room, also a group of players that are connected to each other type Room = { Id: RoomId - Initiator: ConnId - /// Contains the initiator - Players: (int * ConnId) list + /// Player peer ids by player id + Players: Dictionary /// A list of the connections between the peers - Connections: (ConnId * ConnId) list } - + Connections: HashSet + ConnectionsInProgress: HashSet + Semaphore: System.Threading.SemaphoreSlim } /// Player state in the signaling process /// Only for the server to keep track of the player state -type PlayerConnection = - { ConnectionId: ConnId - ConnAttemptIds: ConnAttemptId list - Room: RoomId option } +type Player = + { Id: PlayerId + ConnectionAttemptIds: ConnectionAttemptId list + Room: {| Id: RoomId; PeerId: int |} option } -/// The connection info of a player -/// With its peer id that represent his id in the room -/// And the connection attempt id to connect to the player -/// Used when a player join a room and need to connect to the other players -type PlayerConnectionInfo = { PeerId: int; ConnAttemptId: ConnAttemptId } +/// Information needed to connect to a player +/// Used when a player join a room and need to connect to the other players +type PlayerConnectionInfo = { PeerId: int; ConnAttemptId: ConnectionAttemptId } type RoomConnectionInfo = - { PlayersConnInfo: PlayerConnectionInfo array + { PlayersConnectionInfo: PlayerConnectionInfo array FailedCreations: int array } -module Errors = - type StartConnectionAttemptError = - | PlayerConnectionNotFound = 0 - | FailedToCreateConnAttempt = 1 - | FailedToUpdatePlayerConnection = 2 - - type JoinConnectionAttemptError = - | PlayerConnectionNotFound = 0 - | OfferNotFound = 1 - | OfferAlreadyAnswered = 2 - | InitiatorCannotJoin = 3 - | FailedToUpdateOffer = 4 - - type SendAnswerError = - | PlayerConnectionNotFound = 0 - | OfferNotFound = 1 - | NotAnswerer = 2 - | FailedToTransmitAnswer = 3 - - type SendIceCandidateError = - | PlayerConnectionNotFound = 0 - | OfferNotFound = 1 - | NotAnswerer = 2 - | NotParticipant = 3 - | FailedToTransmitCandidate = 4 - - type EndConnectionAttemptError = - | PlayerConnectionNotFound = 0 - | OfferNotFound = 1 - | NotParticipant = 2 - | FailedToRemoveOffer = 3 - - type CreateRoomError = - | PlayerConnectionNotFound = 0 - | PlayerAlreadyInARoom = 1 - | FailedToRegisterRoom = 2 - | FailedToUpdatePlayerConnection = 3 - - type JoinRoomError = - | PlayerConnectionNotFound = 0 - | PlayerAlreadyInARoom = 1 - | RoomNotFound = 2 - | FailedToUpdateRoom = 3 - | FailedToUpdatePlayerConnection = 4 - - type ConnectToRoomPlayersError = - | PlayerConnectionNotFound = 0 - | NotInARoom = 1 - | PlayerNotInRoomPlayers = 2 - | FailedToUpdateRoom = 3 - - type LeaveRoomError = - | PlayerConnectionNotFound = 0 - | NotInARoom = 1 - | FailedToUpdateRoom = 2 - | FailedToRemoveRoom = 3 - | FailedToUpdatePlayerConnection = 4 - -open Errors +open Behide.OnlineServices.Signaling.Errors // Members with several parameters should have their parameters named // Otherwise, the library TypedSignalR.Client generate invalid C# code type ISignalingHub = - abstract member StartConnectionAttempt : SdpDescription -> Task> - abstract member JoinConnectionAttempt : ConnAttemptId -> Task> - abstract member SendAnswer : ConnAttemptId -> answer: SdpDescription -> Task> - abstract member SendIceCandidate : ConnAttemptId -> iceCandidate: IceCandidate -> Task> - abstract member EndConnectionAttempt : ConnAttemptId -> Task> + abstract member StartConnectionAttempt : SdpDescription -> Task> + /// Returns the offer sdp desc and allow to send the answer + abstract member JoinConnectionAttempt : ConnectionAttemptId -> Task> + abstract member SendAnswer : ConnectionAttemptId -> answer: SdpDescription -> Task> + abstract member SendIceCandidate : ConnectionAttemptId -> iceCandidate: IceCandidate -> Task> + abstract member EndConnectionAttempt : ConnectionAttemptId -> Task> abstract member CreateRoom : unit -> Task> /// Return the peerId of the player in the room @@ -171,6 +111,6 @@ type ISignalingHub = abstract member LeaveRoom : unit -> Task> type ISignalingClient = - abstract member ConnectionRequested: applicantPeerId: int -> Task - abstract member SdpAnswerReceived: ConnAttemptId -> SdpDescription -> Task - abstract member IceCandidateReceived: ConnAttemptId -> IceCandidate -> Task + abstract member ConnectionRequested: applicantPeerId: int -> Task + abstract member SdpAnswerReceived: ConnectionAttemptId -> SdpDescription -> Task + abstract member IceCandidateReceived: ConnectionAttemptId -> IceCandidate -> Task diff --git a/src/Behide.OnlineServices.Types/SignalingErrors.fs b/src/Behide.OnlineServices.Types/SignalingErrors.fs new file mode 100644 index 0000000..28dd18d --- /dev/null +++ b/src/Behide.OnlineServices.Types/SignalingErrors.fs @@ -0,0 +1,56 @@ +namespace Behide.OnlineServices.Signaling.Errors + +type StartConnectionAttemptError = + | PlayerNotFound = 0 + | FailedToCreateConnectionAttempt = 1 + | FailedToUpdatePlayer = 2 + +type JoinConnectionAttemptError = + | PlayerNotFound = 0 + | ConnectionAttemptNotFound = 1 + | ConnectionAttemptAlreadyAnswered = 2 + | InitiatorCannotJoin = 3 + | FailedToUpdateConnectionAttempt = 4 + +type SendAnswerError = + | PlayerNotFound = 0 + | ConnectionAttemptNotFound = 1 + | NotAnswerer = 2 + | FailedToTransmitAnswer = 3 + +type SendIceCandidateError = + | PlayerNotFound = 0 + | ConnectionAttemptNotFound = 1 + | NoAnswerer = 2 + | NotParticipant = 3 + | FailedToTransmitCandidate = 4 + +type EndConnectionAttemptError = + | PlayerNotFound = 0 + | ConnectionAttemptNotFound = 1 + | NotParticipant = 2 + | FailedToRemoveConnectionAttempt = 3 + +type CreateRoomError = + | PlayerNotFound = 0 + | PlayerAlreadyInARoom = 1 + | FailedToRegisterRoom = 2 + | FailedToUpdatePlayer = 3 + +type JoinRoomError = + | PlayerNotFound = 0 + | PlayerAlreadyInARoom = 1 + | RoomNotFound = 2 + | FailedToUpdatePlayer = 4 + +type ConnectToRoomPlayersError = + | PlayerNotFound = 0 + | NotInARoom = 1 + | PlayerNotInRoomPlayers = 2 + +type LeaveRoomError = + | PlayerNotFound = 0 + | NotInARoom = 1 + | FailedToUpdateRoom = 2 + | FailedToRemoveRoom = 3 + | FailedToUpdatePlayer = 4 diff --git a/src/Behide.OnlineServices.Types/Types.fs b/src/Behide.OnlineServices.Types/Types.fs new file mode 100644 index 0000000..a1b47c7 --- /dev/null +++ b/src/Behide.OnlineServices.Types/Types.fs @@ -0,0 +1,12 @@ +namespace Behide.OnlineServices + +/// Unordered pair +type Pair<'T when 'T: comparison> = + { First: 'T; Second: 'T } + +module Pair = + let create first second = + { First = min first second + Second = max first second } + + let isInPair pair value = pair.First = value || pair.Second = value diff --git a/src/Behide.OnlineServices/Behide.OnlineServices.fsproj b/src/Behide.OnlineServices/Behide.OnlineServices.fsproj index e0208f9..9c42625 100644 --- a/src/Behide.OnlineServices/Behide.OnlineServices.fsproj +++ b/src/Behide.OnlineServices/Behide.OnlineServices.fsproj @@ -7,7 +7,10 @@ - + + + + diff --git a/src/Behide.OnlineServices/Hubs/Signaling.fs b/src/Behide.OnlineServices/Hubs/Signaling.fs deleted file mode 100644 index a8e1c1c..0000000 --- a/src/Behide.OnlineServices/Hubs/Signaling.fs +++ /dev/null @@ -1,497 +0,0 @@ -namespace Behide.OnlineServices.Hubs.Signaling - -open System.Threading -open System.Threading.Tasks - -open Microsoft.AspNetCore.SignalR -open FsToolkit.ErrorHandling - -open Behide.OnlineServices -open Behide.OnlineServices.Signaling -open Behide.OnlineServices.Signaling.Errors - -/// Store of player states in the signaling process -type IPlayerConnStore = Store.IStore -type PlayerConnStore = Store.Store - -/// WebRTC connection attempts store -type IConnAttemptStore = Store.IStore -type ConnAttemptStore = Store.Store - -type IRoomStore = Store.IStore -type RoomStore = Store.Store - - -type SignalingHub(connAttemptStore: IConnAttemptStore, roomStore: IRoomStore, playerConnStore: IPlayerConnStore) = - inherit Hub() - // Should interface ISignalingHub, but it makes the methods not callable from the client - - // --- Player Connection Management --- - override hub.OnConnectedAsync() = - taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse - - let playerConn = - { ConnectionId = playerConnId - ConnAttemptIds = [] - Room = None } - - do! playerConnStore.Add - playerConnId - playerConn - |> Result.requireTrue "Failed to add player connection" - } - |> TaskResult.mapError (printfn "Error occurred while registering player: %s") - |> Task.map ignore - :> Task - - override hub.OnDisconnectedAsync _exn = - taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse - - let! playerConn = - playerConnId - |> playerConnStore.Get - |> Result.ofOption "Player connection not found" - - // Remove player from room - let! leaveRoomError = - match playerConn.Room with - | None -> None |> Task.singleton - | Some _ -> - hub.LeaveRoom() - |> Task.map (function - | Ok _ -> None - | Error _ -> Some "Failed to remove player from it's room") - - // Remove connection attempts - let removeConnAttemptsError = - playerConn.ConnAttemptIds - |> List.choose (fun connAttemptId -> - match connAttemptId |> connAttemptStore.Get with - | None -> None - | Some _ -> - match connAttemptId |> connAttemptStore.Remove with - | true -> None - | false -> Some connAttemptId - ) - |> function - | [] -> None - | failedConnAttempts -> - failedConnAttempts - |> sprintf "Failed to remove connection attempts: %A" - |> Some - - // Remove player connection - let removePlayerConnectionError = - playerConnStore.Remove playerConnId - |> Result.requireTrue "Failed to remove player connection" - |> function - | Ok _ -> None - | Error error -> Some error - - return! - match leaveRoomError, removeConnAttemptsError, removePlayerConnectionError with - | None, None, None -> Ok () - | _ -> - sprintf - "\nLeave room error: %s\nRemove connection attempt error: %s\nRemove player connection error: %s" - (leaveRoomError |> Option.defaultValue "None") - (removeConnAttemptsError |> Option.defaultValue "None") - (removePlayerConnectionError |> Option.defaultValue "None") - |> Error - } - |> TaskResult.mapError (printfn "Error occurred while deregistering player: %s") - |> Task.map ignore - :> Task - - // --- WebRTC Signaling --- - member hub.StartConnectionAttempt (sdpDescription: SdpDescription) = - taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse - - let! playerConn = - playerConnId - |> playerConnStore.Get - |> Result.ofOption StartConnectionAttemptError.PlayerConnectionNotFound - - // Create connection attempt - let connAttempt = - { Id = ConnAttemptId.create () - InitiatorConnectionId = playerConnId - SdpDescription = sdpDescription - Answerer = None } - - do! connAttemptStore.Add - connAttempt.Id - connAttempt - |> Result.requireTrue StartConnectionAttemptError.FailedToCreateConnAttempt - - // Update player connection - let newPlayerConn = - { playerConn with ConnAttemptIds = connAttempt.Id :: playerConn.ConnAttemptIds } - - do! playerConnStore.Update - playerConnId - playerConn - newPlayerConn - |> Result.requireTrue StartConnectionAttemptError.FailedToUpdatePlayerConnection - - return connAttempt.Id - } - - /// Returns the offer sdp desc and allow to send the answer - member hub.JoinConnectionAttempt (connAttemptId: ConnAttemptId) = - taskResult { - let connId = hub.Context.ConnectionId |> ConnId.parse - - // Check if client has a player connection - do! connId - |> playerConnStore.Get - |> Result.ofOption JoinConnectionAttemptError.PlayerConnectionNotFound - |> Result.ignore - - // Retrieve connection attempt - let! connAttempt = - connAttemptId - |> connAttemptStore.Get - |> Result.ofOption JoinConnectionAttemptError.OfferNotFound - - // Check if connection attempt has not been answered - do! connAttempt.Answerer - |> Result.requireNone JoinConnectionAttemptError.OfferAlreadyAnswered - - // Check if the answerer is not the initiator - do! connAttempt.InitiatorConnectionId <> connId - |> Result.requireTrue JoinConnectionAttemptError.InitiatorCannotJoin - - // Update connection attempt - do! connAttemptStore.Update - connAttemptId - connAttempt - { connAttempt with Answerer = Some connId } - |> Result.requireTrue JoinConnectionAttemptError.FailedToUpdateOffer - - return connAttempt.SdpDescription - } - - member hub.SendAnswer (connAttemptId: ConnAttemptId) (sdpDescription: SdpDescription) = - taskResult { - let connId = hub.Context.ConnectionId |> ConnId.parse - - // Check if client has a player connection - do! connId - |> playerConnStore.Get - |> Result.ofOption SendAnswerError.PlayerConnectionNotFound - |> Result.ignore - - // Retrieve connection attempt - let! connAttempt = - connAttemptId - |> connAttemptStore.Get - |> Result.ofOption SendAnswerError.OfferNotFound - - // Get answerer - let! answerer = connAttempt.Answerer |> Result.ofOption SendAnswerError.NotAnswerer - // Check if the client is the answerer - do! connId = answerer |> Result.requireTrue SendAnswerError.NotAnswerer - - // Send answer to initiator - try - do! hub.Clients.Client(connAttempt.InitiatorConnectionId |> ConnId.raw).SdpAnswerReceived connAttemptId sdpDescription - with _ -> - return! Error SendAnswerError.FailedToTransmitAnswer - } - - member hub.SendIceCandidate (connAttemptId: ConnAttemptId) (iceCandidate: IceCandidate) = - taskResult { - let connId = hub.Context.ConnectionId |> ConnId.parse - - // Check if client has a player connection - do! connId - |> playerConnStore.Get - |> Result.ofOption SendIceCandidateError.PlayerConnectionNotFound - |> Result.ignore - - let! connAttempt = - connAttemptId - |> connAttemptStore.Get - |> Result.ofOption SendIceCandidateError.OfferNotFound - - let! answerer = - connAttempt.Answerer - |> Result.ofOption SendIceCandidateError.NotAnswerer - - // Check if the client is in the connection attempt - do! (connId = connAttempt.InitiatorConnectionId || connId = answerer) - |> Result.requireTrue SendIceCandidateError.NotParticipant - - // Determine the target connection id - let targetConnId = - match connId = answerer with - | true -> connAttempt.InitiatorConnectionId - | false -> answerer - - // Send ice candidate to other peer - try - do! hub.Clients.Client(targetConnId |> ConnId.raw).IceCandidateReceived connAttemptId iceCandidate - with _ -> - return! Error SendIceCandidateError.FailedToTransmitCandidate - } - - member hub.EndConnectionAttempt (connAttemptId: ConnAttemptId) = - taskResult { - let connId = hub.Context.ConnectionId |> ConnId.parse - - // Check if client has a player connection - do! connId - |> playerConnStore.Get - |> Result.ofOption EndConnectionAttemptError.PlayerConnectionNotFound - |> Result.ignore - - let! connAttempt = - connAttemptId - |> connAttemptStore.Get - |> Result.ofOption EndConnectionAttemptError.OfferNotFound - - // Check if the client is in the connection attempt - match connId = connAttempt.InitiatorConnectionId with - | true -> () - | false -> - do! connAttempt.Answerer - |> Result.ofOption EndConnectionAttemptError.NotParticipant - |> Result.bind ((=) connId >> Result.requireTrue EndConnectionAttemptError.NotParticipant) - - // Remove connection attempt - do! connAttemptStore.Remove connAttemptId - |> Result.requireTrue EndConnectionAttemptError.FailedToRemoveOffer - } - - // --- Rooms --- - member hub.CreateRoom() = - taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse - - let! playerConn = - playerConnId - |> playerConnStore.Get - |> Result.ofOption CreateRoomError.PlayerConnectionNotFound - - // Check if player is already in a room - do! playerConn.Room |> Result.requireNone CreateRoomError.PlayerAlreadyInARoom - - // Create room - let room = - { Id = RoomId.create () - Initiator = playerConnId - Players = [ 1, playerConnId ] // Host peer id should always be 1 - Connections = [] } - - do! roomStore.Add - room.Id - room - |> Result.requireTrue CreateRoomError.FailedToRegisterRoom - - // Update player connection - let newPlayerConn = { playerConn with Room = Some room.Id } - - do! playerConnStore.Update - playerConnId - playerConn - newPlayerConn - |> Result.requireTrue CreateRoomError.FailedToUpdatePlayerConnection - - return room.Id - } - - member hub.JoinRoom (roomId: RoomId) = - taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse - - let! playerConn = - playerConnId - |> playerConnStore.Get - |> Result.ofOption JoinRoomError.PlayerConnectionNotFound - - // Check if player is already in a room - do! playerConn.Room - |> Result.requireNone JoinRoomError.PlayerAlreadyInARoom - - // Update room - let! newPeerId = lock roomStore (fun () -> taskResult { - let! room = - roomId - |> roomStore.Get - |> Result.ofOption JoinRoomError.RoomNotFound - - // Update room - let newPeerId = - room.Players - |> List.maxBy fst - |> fst - |> (+) 1 - - let newRoom = { room with Players = (newPeerId, playerConnId) :: room.Players } - - do! roomStore.Update - roomId - room - newRoom - |> Result.requireTrue JoinRoomError.FailedToUpdateRoom - - return newPeerId - }) - - // Update player connection - let newPlayerConn = { playerConn with Room = Some roomId } - - do! playerConnStore.Update - playerConnId - playerConn - newPlayerConn - |> Result.requireTrue JoinRoomError.FailedToUpdatePlayerConnection - - return newPeerId - } - - member hub.ConnectToRoomPlayers() = - taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse - - let! playerConn = - playerConnId - |> playerConnStore.Get - |> Result.ofOption ConnectToRoomPlayersError.PlayerConnectionNotFound - - return! lock roomStore (fun _ -> taskResult { - let! room = - playerConn.Room - |> Option.bind roomStore.Get - |> Result.ofOption ConnectToRoomPlayersError.NotInARoom - - let! requestingPlayerId = - room.Players - |> List.tryFind (snd >> (=) playerConn.ConnectionId) - |> Result.ofOption ConnectToRoomPlayersError.PlayerNotInRoomPlayers - |> Result.map fst - - - /// Create connection attempt on the requested client - let createConnAttemptForPlayer (targetPeerId, targetConnId) = - taskResult { - let! r = - targetConnId - |> ConnId.raw - |> hub.Clients.Client - |> _.ConnectionRequested(requestingPlayerId) - |> Task.catch - |> Task.map (function // Handle the case where the client didn't register a handler - | Choice1Of2 r -> Ok r - | Choice2Of2 _ -> Error targetPeerId - ) - - match r with - | null -> return! Error targetPeerId - | connAttemptId -> - let connInfo = { PeerId = targetPeerId; ConnAttemptId = connAttemptId } - let connection = playerConnId, targetConnId - - return connInfo, connection - } - - let alreadyConnectedPlayers = - room.Connections - |> List.choose (fun (p1, p2) -> - match playerConnId with - | Equals p1 -> Some p2 - | Equals p2 -> Some p1 - | _ -> None - ) - - let! playersConnectionInfoResults = - room.Players - |> List.choose (fun (connInfo, connId) -> - let isRequestingPlayer = connId <> playerConnId - let alreadyConnected = alreadyConnectedPlayers |> List.contains connId |> not - - // Don't connect the player to himself and don't reconnect player - match isRequestingPlayer && alreadyConnected with - | false -> None - | true -> createConnAttemptForPlayer(connInfo, connId) |> Some - ) - |> Task.WhenAll - - let mutable playersConnInfo = [] - let mutable failed = [] - let mutable newConnections = [] - - playersConnectionInfoResults |> Array.iter ( - function - | Ok (connInfo, connection) -> - playersConnInfo <- connInfo :: playersConnInfo - newConnections <- connection :: newConnections - | Error peerId -> - failed <- peerId :: failed - ) - - // Update room - do! roomStore.Update - room.Id - room - { room with Connections = newConnections @ room.Connections } - |> Result.requireTrue ConnectToRoomPlayersError.FailedToUpdateRoom - - return { PlayersConnInfo = playersConnInfo |> List.toArray - FailedCreations = failed |> List.toArray } - }) - } - - member hub.LeaveRoom() = - taskResult { - let playerConnId = hub.Context.ConnectionId |> ConnId.parse - - // Check if a player connection exists - let! playerConn = - playerConnId - |> playerConnStore.Get - |> Result.ofOption LeaveRoomError.PlayerConnectionNotFound - - do! lock roomStore (fun _ -> taskResult { - // Get player's room - let! room = - playerConn.Room - |> Option.bind roomStore.Get - |> Result.ofOption LeaveRoomError.NotInARoom - - match room.Players |> List.length with - | 1 -> // If the player is the last one in the room, remove the room - do! roomStore.Remove room.Id - |> Result.requireTrue LeaveRoomError.FailedToRemoveRoom - - | _ -> - // Remove player connections and player from room - let newConnections = - room.Connections - |> List.filter (fun (p1, p2) -> - match playerConnId with - | Equals p1 -> false - | Equals p2 -> false - | _ -> true - ) - - let newRoom = - { room with - Players = room.Players |> List.filter (snd >> (<>) playerConnId) - Connections = newConnections} - - do! roomStore.Update room.Id room newRoom - |> Result.requireTrue LeaveRoomError.FailedToUpdateRoom - }) - - // Update player connection - do! playerConnStore.Update - playerConnId - playerConn - { playerConn with Room = None } - |> Result.requireTrue LeaveRoomError.FailedToUpdatePlayerConnection - } diff --git a/src/Behide.OnlineServices/Hubs/Signaling/Common.fs b/src/Behide.OnlineServices/Hubs/Signaling/Common.fs new file mode 100644 index 0000000..a9df69c --- /dev/null +++ b/src/Behide.OnlineServices/Hubs/Signaling/Common.fs @@ -0,0 +1,15 @@ +namespace Behide.OnlineServices.Hubs.Signaling + +open Behide.OnlineServices +open Behide.OnlineServices.Signaling + +/// Store of player states in the signaling process +type IPlayerStore = Store.IStore +type PlayerStore = Store.Store + +/// WebRTC connection attempts store +type IConnectionAttemptStore = Store.IStore +type ConnectionAttemptStore = Store.Store + +type IRoomStore = Store.IStore +type RoomStore = Store.Store diff --git a/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs new file mode 100644 index 0000000..23945e3 --- /dev/null +++ b/src/Behide.OnlineServices/Hubs/Signaling/RoomManagement.fs @@ -0,0 +1,228 @@ +module Behide.OnlineServices.Hubs.Signaling.RoomManagement + +open Behide.OnlineServices +open Behide.OnlineServices.Signaling +open Behide.OnlineServices.Signaling.Errors +type Hub = Microsoft.AspNetCore.SignalR.Hub + +open System +open System.Collections.Generic +open System.Threading +open System.Threading.Tasks + +open FsToolkit.ErrorHandling + +let createRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) = + taskResult { + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId + + let! player = + playerId + |> playerStore.Get + |> Result.ofOption CreateRoomError.PlayerNotFound + + // Check if player is already in a room + do! player.Room |> Result.requireNone CreateRoomError.PlayerAlreadyInARoom + + // Create room + let room = + { Id = RoomId.create () + Players = [ KeyValuePair(playerId, 1) ] |> Dictionary // Host peer id should always be 1 + Connections = HashSet() + ConnectionsInProgress = HashSet() + Semaphore = new SemaphoreSlim(1, 1) } + + do! roomStore.Add + room.Id + room + |> Result.requireTrue CreateRoomError.FailedToRegisterRoom + + // Update player + let newPlayer = { player with Room = Some {| Id = room.Id; PeerId = 1 |} } + + do! playerStore.Update playerId player newPlayer + |> Result.requireTrue CreateRoomError.FailedToUpdatePlayer + + return room.Id + } + +let joinRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) (roomId: RoomId) = + taskResult { + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId + + let! player = + playerId + |> playerStore.Get + |> Result.ofOption JoinRoomError.PlayerNotFound + + // Check if player is already in a room + do! player.Room |> Result.requireNone JoinRoomError.PlayerAlreadyInARoom + + // Update room + let! room = + roomId + |> roomStore.Get + |> Result.ofOption JoinRoomError.RoomNotFound + do! room.Semaphore.WaitAsync() // Lock to prevent several players having the same peerId + let newPeerId = + room.Players + |> Seq.maxBy _.Value // Max by peerId + |> _.Value + |> (+) 1 + + room.Players.Add(playerId, newPeerId) + room.Semaphore.Release() |> ignore + + // Update player connection + let newPlayerConn = { player with Room = Some {| Id = roomId; PeerId = newPeerId |} } + + do! playerStore.Update + playerId + player + newPlayerConn + |> Result.requireTrue JoinRoomError.FailedToUpdatePlayer + + return newPeerId + } + + +let private findPlayersToConnectTo (requestingPlayer: Player) (room: Room) = + room.Players + |> Array.ofSeq + |> Array.filter (fun kv -> + let playerId = kv.Key + + let connectionToCheck = Pair.create requestingPlayer.Id playerId + let alreadyConnected = + room.Connections.Contains(connectionToCheck) + || room.ConnectionsInProgress.Contains(connectionToCheck) + + playerId <> requestingPlayer.Id && not alreadyConnected + ) + +let setInProgressConnections player (playersToConnectTo: KeyValuePair array) room = + playersToConnectTo |> Array.map (fun kv -> + let connection = Pair.create player.Id kv.Key + room.ConnectionsInProgress.Add connection |> ignore + connection + ) + +let requestConnectionForPlayer (hub: Hub) player requestingPeerId targetPeerId targetPlayerId = + taskResult { + let! r = + targetPlayerId + |> PlayerId.raw + |> hub.Clients.Client + |> _.ConnectionRequested(requestingPeerId) + |> _.WaitAsync(TimeSpan.FromSeconds 10.) + |> Task.catch + |> Task.map (function // Handle the case where the client didn't register a handler + | Choice1Of2 r -> Ok r + | Choice2Of2 _ -> Error targetPeerId + ) + + match r with + | null -> return! Error targetPeerId + | connAttemptId -> + return { PeerId = targetPeerId; ConnAttemptId = connAttemptId }, + Pair.create player.Id targetPlayerId + } + +let connectToRoomPlayers (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) = + taskResult { + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId + + let! player = + playerId + |> playerStore.Get + |> Result.ofOption ConnectToRoomPlayersError.PlayerNotFound + + let! room, requestingPeerId = + player.Room + |> Option.bind (fun roomInfo -> + roomInfo.Id + |> roomStore.Get + |> Option.map (fun roomId -> roomId, roomInfo.PeerId) + ) + |> Result.ofOption ConnectToRoomPlayersError.NotInARoom + + // Ensure player is in room players + do! room.Players.ContainsKey player.Id + |> Result.requireTrue ConnectToRoomPlayersError.PlayerNotInRoomPlayers + + do! room.Semaphore.WaitAsync() // Lock room + let playersToConnectTo = findPlayersToConnectTo player room + let inProgressConnections = setInProgressConnections player playersToConnectTo room + room.Semaphore.Release() |> ignore // Unlock room + + // Create connection attempts + let requestConnectionForPlayer = requestConnectionForPlayer hub player requestingPeerId + let! playersConnectionInfo = + playersToConnectTo + |> Array.map (fun kv -> requestConnectionForPlayer kv.Value kv.Key) + |> Task.WhenAll + + // Build return value and update room connections + do! room.Semaphore.WaitAsync() + let playersConnectionInfo, failed = + playersConnectionInfo |> Array.fold + (fun (playersConnInfo, failed) playerConnectionInfo -> + match playerConnectionInfo with + | Ok (connInfo, connection) -> + + // Convert in progress connections in connections + room.ConnectionsInProgress.Remove connection |> ignore + room.Connections.Add connection |> ignore + + connInfo :: playersConnInfo, failed + | Error peerId -> playersConnInfo, peerId :: failed) + (List.empty, List.empty) + + // Remove inProgressConnections (in case a connection attempt failed) + inProgressConnections |> Array.iter (fun connection -> + room.ConnectionsInProgress.Remove connection |> ignore + ) + + room.Semaphore.Release() |> ignore + + return { PlayersConnectionInfo = playersConnectionInfo |> List.toArray + FailedCreations = failed |> List.toArray } + } + +let leaveRoom (hub: Hub) (playerStore: IPlayerStore) (_connectionAttemptStore: IConnectionAttemptStore) (roomStore: IRoomStore) = + taskResult { + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId + + // Check if a player connection exists + let! player = + playerId + |> playerStore.Get + |> Result.ofOption LeaveRoomError.PlayerNotFound + + let! room = + player.Room + |> Option.bind (_.Id >> roomStore.Get) + |> Result.ofOption LeaveRoomError.NotInARoom + do! room.Semaphore.WaitAsync() + match room.Players.Count with + | 1 -> // If the player is the last one in the room, remove the room + do! roomStore.Remove room.Id + |> Result.requireTrue LeaveRoomError.FailedToRemoveRoom + + | _ -> + // Remove player connections and player from room + room.Connections.RemoveWhere(fun connection -> + playerId |> Pair.isInPair connection + ) |> ignore + + do! room.Players.Remove(playerId) + |> Result.requireTrue LeaveRoomError.FailedToUpdateRoom + room.Semaphore.Release() |> ignore + + // Update player connection + do! playerStore.Update + playerId + player + { player with Room = None } + |> Result.requireTrue LeaveRoomError.FailedToUpdatePlayer + } diff --git a/src/Behide.OnlineServices/Hubs/Signaling/Signaling.fs b/src/Behide.OnlineServices/Hubs/Signaling/Signaling.fs new file mode 100644 index 0000000..419d7b0 --- /dev/null +++ b/src/Behide.OnlineServices/Hubs/Signaling/Signaling.fs @@ -0,0 +1,97 @@ +namespace Behide.OnlineServices.Hubs.Signaling + +open System.Threading.Tasks +open Microsoft.AspNetCore.SignalR +open FsToolkit.ErrorHandling +open Behide.OnlineServices +open Behide.OnlineServices.Signaling + +type SignalingHub(connectionAttemptStore: IConnectionAttemptStore, roomStore: IRoomStore, playerStore: IPlayerStore) = + inherit Hub() + // Should interface ISignalingHub, but it makes the methods not callable from the client + + // --- Player Connection Management --- + override hub.OnConnectedAsync() = + taskResult { + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId + + let player = + { Id = playerId + ConnectionAttemptIds = List.empty + Room = None } + + do! playerStore.Add playerId player + |> Result.requireTrue "Failed to add player connection" + } + |> TaskResult.mapError (printfn "Error occurred while registering player: %s") + |> Task.map ignore + :> Task + + override hub.OnDisconnectedAsync _exn = + taskResult { + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId + + let! player = + playerId + |> playerStore.Get + |> Result.ofOption "Player connection not found" + + // Remove player from room + let! leaveRoomError = + match player.Room with + | None -> None |> Task.singleton + | Some _ -> + hub.LeaveRoom() + |> Task.map (function + | Ok _ -> None + | Error _ -> Some "Failed to remove player from it's room") + + // Remove connection attempts + let removeConnectionAttemptsError = + player.ConnectionAttemptIds + |> List.filter (fun connAttemptId -> + match connAttemptId |> connectionAttemptStore.Get with + | None -> false + | Some _ -> connAttemptId |> connectionAttemptStore.Remove // TODO: Notify other player in the connection attempt + ) + |> function + | [] -> None + | failedConnectionAttempts -> + failedConnectionAttempts + |> sprintf "Failed to remove connection attempts: %A" + |> Some + + // Remove player connection + let removePlayerConnectionError = + playerStore.Remove playerId + |> Result.requireTrue "Failed to remove player" + |> function + | Ok _ -> None + | Error error -> Some error + + return! + match leaveRoomError, removeConnectionAttemptsError, removePlayerConnectionError with + | None, None, None -> Ok () + | _ -> + Error <| sprintf + "\nLeave room error: %s\nRemove connection attempt error: %s\nRemove player error: %s" + (leaveRoomError |> Option.defaultValue "None") + (removeConnectionAttemptsError |> Option.defaultValue "None") + (removePlayerConnectionError |> Option.defaultValue "None") + } + |> TaskResult.mapError (printfn "Error occurred while deregistering player: %s") + |> Task.map ignore + :> Task + + // --- WebRTC Signaling --- + member hub.StartConnectionAttempt (sdpDescription: SdpDescription) = WebRTCSignaling.startConnectionAttempt hub playerStore connectionAttemptStore sdpDescription + member hub.JoinConnectionAttempt (connAttemptId: ConnectionAttemptId) = WebRTCSignaling.joinConnectionAttempt hub playerStore connectionAttemptStore connAttemptId + member hub.SendAnswer (connAttemptId: ConnectionAttemptId) (answer: SdpDescription) = WebRTCSignaling.sendAnswer hub playerStore connectionAttemptStore connAttemptId answer + member hub.SendIceCandidate (connAttemptId: ConnectionAttemptId) (iceCandidate: IceCandidate) = WebRTCSignaling.sendIceCandidate hub playerStore connectionAttemptStore connAttemptId iceCandidate + member hub.EndConnectionAttempt (connAttemptId: ConnectionAttemptId) = WebRTCSignaling.endConnectionAttempt hub playerStore connectionAttemptStore connAttemptId + + // --- Rooms --- + member hub.CreateRoom() = RoomManagement.createRoom hub playerStore connectionAttemptStore roomStore + member hub.JoinRoom (roomId: RoomId) = RoomManagement.joinRoom hub playerStore connectionAttemptStore roomStore roomId + member hub.ConnectToRoomPlayers() = RoomManagement.connectToRoomPlayers hub playerStore connectionAttemptStore roomStore + member hub.LeaveRoom() = RoomManagement.leaveRoom hub playerStore connectionAttemptStore roomStore diff --git a/src/Behide.OnlineServices/Hubs/Signaling/WebRTCSignaling.fs b/src/Behide.OnlineServices/Hubs/Signaling/WebRTCSignaling.fs new file mode 100644 index 0000000..4d98f00 --- /dev/null +++ b/src/Behide.OnlineServices/Hubs/Signaling/WebRTCSignaling.fs @@ -0,0 +1,167 @@ +module Behide.OnlineServices.Hubs.Signaling.WebRTCSignaling + +open Behide.OnlineServices +open Behide.OnlineServices.Signaling +open Behide.OnlineServices.Signaling.Errors +open FsToolkit.ErrorHandling + +type Hub = Microsoft.AspNetCore.SignalR.Hub + +let startConnectionAttempt (hub: Hub) (playerStore: IPlayerStore) (connectionAttemptStore: IConnectionAttemptStore) (offer: SdpDescription) = + taskResult { + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId + + let! player = + playerId + |> playerStore.Get + |> Result.ofOption StartConnectionAttemptError.PlayerNotFound + + // Create connection attempt + let connectionAttempt = + { Id = ConnectionAttemptId.create () + InitiatorConnectionId = playerId + Offer = offer + Answerer = None } + + do! connectionAttemptStore.Add connectionAttempt.Id connectionAttempt + |> Result.requireTrue StartConnectionAttemptError.FailedToCreateConnectionAttempt + + // Update player connection + let newPlayer = + { player with ConnectionAttemptIds = connectionAttempt.Id :: player.ConnectionAttemptIds } + + do! playerStore.Update + playerId + player + newPlayer + |> Result.requireTrue StartConnectionAttemptError.FailedToUpdatePlayer + + return connectionAttempt.Id + } + +/// Returns the offer sdp desc and allow to send the answer +let joinConnectionAttempt (hub: Hub) (playerStore: IPlayerStore) (connectionAttemptStore: IConnectionAttemptStore) (connectionAttemptId: ConnectionAttemptId) = + taskResult { + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId + + // Check if player exists + do! playerId + |> playerStore.Get + |> Result.ofOption JoinConnectionAttemptError.PlayerNotFound + |> Result.ignore + + // Retrieve connection attempt + let! connectionAttempt = + connectionAttemptId + |> connectionAttemptStore.Get + |> Result.ofOption JoinConnectionAttemptError.ConnectionAttemptNotFound + + // Check if connection attempt has not been answered + do! connectionAttempt.Answerer + |> Result.requireNone JoinConnectionAttemptError.ConnectionAttemptAlreadyAnswered + + // Check if the answerer is not the initiator + do! connectionAttempt.InitiatorConnectionId <> playerId + |> Result.requireTrue JoinConnectionAttemptError.InitiatorCannotJoin + + // Update connection attempt + do! connectionAttemptStore.Update + connectionAttempt.Id + connectionAttempt + { connectionAttempt with Answerer = Some playerId } + |> Result.requireTrue JoinConnectionAttemptError.FailedToUpdateConnectionAttempt + + return connectionAttempt.Offer + } + +let sendAnswer (hub: Hub) (playerStore: IPlayerStore) (connectionAttemptStore: IConnectionAttemptStore) (connectionAttemptId: ConnectionAttemptId) (sdpDescription: SdpDescription) = + taskResult { + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId + + // Check if player exists + do! playerId + |> playerStore.Get + |> Result.ofOption SendAnswerError.PlayerNotFound + |> Result.ignore + + // Retrieve connection attempt + let! connectionAttempt = + connectionAttemptId + |> connectionAttemptStore.Get + |> Result.ofOption SendAnswerError.ConnectionAttemptNotFound + + // Check if the client is the answerer + do! connectionAttempt.Answerer + |> Result.ofOption SendAnswerError.NotAnswerer + |> Result.bind (fun pId -> Result.requireEqual pId playerId SendAnswerError.NotAnswerer) + + // Send answer to initiator + try + do! hub.Clients.Client(connectionAttempt.InitiatorConnectionId |> PlayerId.raw).SdpAnswerReceived connectionAttemptId sdpDescription + with _ -> + return! Error SendAnswerError.FailedToTransmitAnswer + } + +let sendIceCandidate (hub: Hub) (playerStore: IPlayerStore) (connectionAttemptStore: IConnectionAttemptStore) (connectionAttemptId: ConnectionAttemptId) (iceCandidate: IceCandidate) = + taskResult { + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId + + // Check if player exists + do! playerId + |> playerStore.Get + |> Result.ofOption SendIceCandidateError.PlayerNotFound + |> Result.ignore + + let! connectionAttempt = + connectionAttemptId + |> connectionAttemptStore.Get + |> Result.ofOption SendIceCandidateError.ConnectionAttemptNotFound + + let! answerer = + connectionAttempt.Answerer + |> Result.ofOption SendIceCandidateError.NoAnswerer + + // Check if the player is in the connection attempt + do! (playerId = connectionAttempt.InitiatorConnectionId || playerId = answerer) + |> Result.requireTrue SendIceCandidateError.NotParticipant + + // Determine the target player + let targetPlayerId = + match playerId = answerer with + | true -> connectionAttempt.InitiatorConnectionId + | false -> answerer + + // Send ice candidate to other player + try + do! hub.Clients.Client(targetPlayerId |> PlayerId.raw).IceCandidateReceived connectionAttemptId iceCandidate + with _ -> + return! Error SendIceCandidateError.FailedToTransmitCandidate + } + +let endConnectionAttempt (hub: Hub) (playerStore: IPlayerStore) (connectionAttemptStore: IConnectionAttemptStore) (connectionAttemptId: ConnectionAttemptId) = + taskResult { + let playerId = hub.Context.ConnectionId |> PlayerId.fromHubConnectionId + + // Check if player exists + do! playerId + |> playerStore.Get + |> Result.ofOption EndConnectionAttemptError.PlayerNotFound + |> Result.ignore + + let! connectionAttempt = + connectionAttemptId + |> connectionAttemptStore.Get + |> Result.ofOption EndConnectionAttemptError.ConnectionAttemptNotFound + + // Check if the player is in the connection attempt + match playerId = connectionAttempt.InitiatorConnectionId with + | true -> () + | false -> + do! connectionAttempt.Answerer + |> Result.ofOption EndConnectionAttemptError.NotParticipant + |> Result.bind ((=) playerId >> Result.requireTrue EndConnectionAttemptError.NotParticipant) + + // Remove connection attempt + do! connectionAttemptStore.Remove connectionAttemptId + |> Result.requireTrue EndConnectionAttemptError.FailedToRemoveConnectionAttempt + } diff --git a/src/Behide.OnlineServices/Program.fs b/src/Behide.OnlineServices/Program.fs index f288341..b200594 100644 --- a/src/Behide.OnlineServices/Program.fs +++ b/src/Behide.OnlineServices/Program.fs @@ -18,9 +18,12 @@ let configureServices (services: IServiceCollection) = .AddToJsonSerializerOptions(options.PayloadSerializerOptions) ) |> ignore - services.AddSingleton() |> ignore + services + +let configureSingletons (services: IServiceCollection) = + services.AddSingleton() |> ignore services.AddSingleton() |> ignore - services.AddSingleton() |> ignore + services.AddSingleton() |> ignore services let appEndpoints = @@ -30,7 +33,7 @@ let appEndpoints = let main args = let wapp = WebApplication.CreateBuilder(args) - .AddServices(fun _ -> configureServices) + .AddServices(fun _ -> configureServices >> configureSingletons) .Build() wapp.UseRouting() diff --git a/src/Behide.OnlineServices/Stores.fs b/src/Behide.OnlineServices/Stores.fs index c10aaff..c0c40ce 100644 --- a/src/Behide.OnlineServices/Stores.fs +++ b/src/Behide.OnlineServices/Stores.fs @@ -23,6 +23,5 @@ type Store<'Id, 'Item when 'Id: not null>() = |> Seq.tryFind (_.Value >> predicate) |> Option.map _.Deconstruct() - /// Don't forget to use "lock" member _.Update id oldValue newValue = dict.TryUpdate(id, newValue, oldValue)