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)