# Mirror

### Mirror на Edgegap

Этот гид поможет вам создать безголовый сервер на Edgegap для проекта Unity, использующего [Mirror](https://mirror-networking.com/) в качестве решения для сетевого взаимодействия.

В этом руководстве будет использоваться открытый пример проекта `Tanks`, который уже доступен в вашем примере Mirror по пути Assets/Mirror/Examples/Tanks.

Окончательный пример можно найти на нашем [GitHub](https://github.com/edgegap/netcode-sample-unity-mirror).

### Сборка игрового сервера

Когда игра будет готова, перейдите на `экран Build` в Unity Editor, в разделе `File -> Build Settings` в верхнем меню. Убедитесь, что выбраны правильные пресеты в зависимости от вашей версии Unity.

* До версии 2021.2:
  * Установите `Целевую платформу` в `Linux`;
  * Установите `Архитектуру` в `x86_64`;
  * Поставьте галочку на `Server Build` опции.
* Иначе:
  * Установите `Платформа` в `Dedicated Server`;
  * Установите `Целевую платформу` в `Linux`.

Затем нажмите Build и выберите новую пустую папку с именем `linux_server` в качестве места назначения файлов. Перенесите `linux_server` папку во вторую пустую папку, которая в этом документе будет называться `[SERVER BUILD]` папкой.

### Контейнеризация выделенного игрового сервера

В этой части мы создадим docker-образ, содержащий выделенный игровой сервер. Вам также может быть интересно прочитать [Unity Server in Docker](https://docs.edgegap.com/ru/docs/sample-projects/unity-netcodes/broken-reference).

Если вам нужна дополнительная информация о Docker с Edgegap, пожалуйста, обратитесь к [этой](https://docs.edgegap.com/ru/docs/tools-and-integrations/container/docker) документации.

#### Dockerfile

```
FROM ubuntu:bionic
MAINTAINER <author_detail>

ARG debian_frontend=noninteractive
ARG docker_version=17.06.0-ce

RUN apt-get update && \
    apt-get install -y libglu1 xvfb libxcursor1 ca-certificates && \
    apt-get clean && \
    update-ca-certificates

EXPOSE 3389/TCP
EXPOSE [GAME PORT]/TCP
EXPOSE [GAME PORT]/UDP

COPY linux_server/ /root/linux_server/
COPY boot.sh /boot.sh

WORKDIR /root/
ENTRYPOINT ["/bin/bash", "/boot.sh"]
```

Обратите внимание на порт, который вы используете для сетевых коммуникаций, именуемый `[GAME PORT]`. По умолчанию используется порт `7777`. Эту информацию можно найти в Unity Editor, на объекте `NetworkManager` в компоненте `Transport` .

* Скопируйте приведённые выше строки и вставьте их в ваш Dockerfile, расположенный внутри `[SERVER BUILD]`. Измените `[GAME PORT]` заполнители на ваш игровой порт.

Наличие `[GAME PORT]` открытого на TCP и UDP позволяет вам использовать любой транспорт по вашему выбору в `NetworkManager` компоненте Mirror. Наконец, создайте файл с именем `boot.sh` в корне `[SERVER BUILD]` папки. Он будет выполняться при запуске образа в контейнере.

* Скопируйте следующие две строки, убедитесь, что заменили `[YOUR GAME]` заполнители на имя сгенерированного файла.

```
xvfb-run --auto-servernum --server-args='-screen 0 640X480X24:32' /root/build/[YOUR GAME].x86_64 -batchmode -nographics
```

На данном этапе у вас должна быть следующая иерархия:

> * `папка [SERVER BUILD]` > > - `Dockerfile` > > - `boot.sh` > > - `linux_server` папка > > > - файлы, сгенерированные Unity

* Откройте командную строку в папке `[SERVER BUILD]` и выполните следующие команды Docker:

{% hint style="warning" %}
Для пользователей ARM CPU (Mac M1, M2 и т.д.) добавьте `--platform linux/amd64`  опцию к вашей команде сборки.
{% endhint %}

#### Использование Linux

```bash
# собрать образ
docker build . -t <IMAGE_NAME>:<IMAGE_VERSION>

# войти в реестр, запросит пароль
docker login -u '<REGISTRY_USERNAME>' <REGISTRY_URL>

# добавить ещё один тег к вашему образу, соответствующий реестру
docker image tag <IMAGE_NAME>:<IMAGE_VERSION> <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>

#запушить образ
docker push <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>
```

#### Используя cmd

```bash
# собрать образ
docker build . -t <IMAGE_NAME>:<IMAGE_VERSION>

# войти в реестр, запросит пароль
docker login -u <REGISTRY_USERNAME> <REGISTRY_URL>

# добавить ещё один тег к вашему образу, соответствующий реестру
docker image tag <IMAGE_NAME>:<IMAGE_VERSION> <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>

#запушить образ
docker push <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>
```

#### Используя Powershell

```bash
# собрать образ
docker build . -t <IMAGE_NAME>:<IMAGE_VERSION>

# войти в реестр, запросит пароль
docker login -u '<REGISTRY_USERNAME>' <REGISTRY_URL>

# добавить ещё один тег к вашему образу, соответствующий реестру
docker image tag <IMAGE_NAME>:<IMAGE_VERSION> <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>

#запушить образ
docker push <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<IMAGE_VERSION>
```

После этих команд вы должны увидеть загруженный образ на сайте Edgegap, если вы используете Edgegap Container Registry. Смотрите [этот документ](https://docs.edgegap.com/ru/learn/advanced-features/edgegap-container-registry) если хотите использовать реестр Edgegap. Вы также можете использовать другой приватный реестр.

### Развёртывание на Edgegap

Перейдите на страницу `Applications & Games` веб-сайта. Нажмите на кнопку `Create New` в правом верхнем углу, чтобы открыть форму создания приложения. Ниже перечислены поля и как их правильно заполнить:

* Application name : Может быть любым заметным именем, которое вы хотите использовать, чтобы легко распознавать ваше приложение среди других.
* Image : Может быть любой конкретный образ, который вы хотите использовать, чтобы легко распознавать ваше приложение среди других.
* Version name : Вы можете использовать имя версии, чтобы описать назначение версии, которую вы разворачиваете. Примеры: “demo”, “production”, “v1”, “v2”
* Container :
  * Registry : “ \[URL] ”, где \[URL] — значение из учетных данных, которые вы можете показать на странице Container Repository.
  * Image repository : “ \[PROJECT]/\[YOUR GAME] ”, где \[PROJECT] и \[YOUR GAME] — значения, которые вы использовали ранее при отправке docker-образа.
  * Tag : “ \[TAG] ”, где \[TAG] — значение, которое вы использовали ранее при отправке docker-образа.
  * Отметьте «Using a private repository»
  * Private registry username : “ \[USERNAME] ”, где \[USERNAME] — значение из ваших учетных данных.
  * Private registry token : “ \[TOKEN] ”, где \[TOKEN] — значение из ваших учетных данных.
  * Requirements : Оставьте как есть.
  * Ports :
    * Нажмите на `+ Add port` ссылку, чтобы добавить новый порт, и добавьте следующие записи:
      * `[GAME PORT]` - `TCP/UDP` - отключить проверки
      * 3389 - TCP - отключить проверки

После создания приложения вы можете нажать кнопку `Deploy` чтобы продолжить развёртывание вашего игрового сервера. Выберите регион для развёртывания и введите количество случайных игроков, которые вы хотите сгенерировать в зависимости от вашей игры. Проверьте, что всё работает корректно, проверив следующее:

* Latest Status должен быть установлен на `Ready`.
* Во вкладке `Port Mapping` вы должны видеть порт, который вы указали в форме создания приложения:

<figure><img src="https://3845012722-files.gitbook.io/~/files/v0/b/gitbook-x-prod.appspot.com/o/spaces%2FsR0dHSFv9ymoC0DO5G8J%2Fuploads%2Fgit-blob-583d6356cdf560e89fc338bde5c125a81c77a9ee%2Fmirror-app-creation.png?alt=media" alt=""><figcaption></figcaption></figure>

### Добавление примерного HUD в ваше клиентское приложение

* Установите значение `Port` компонента `Transport` в `NetworkManager` на внешний порт, определённый во вкладке `Port Mapping` вашего развёртывания.

В этом примере порт был установлен в `31887`. Это в основном зависит от разрабатываемой игры и, скорее всего, будет устанавливаться программно в кодовой базе игры.

* Установите значение `Network Address` для `Network Manager` на `Host`вашего развёртывания. Этот URL можно найти в `Deployment Summary` на панели управления или через API.

В этом примере адрес был установлен как `0ace560706a5.pr.edgegap.net`. Опять же, это значение, скорее всего, будет устанавливаться программно во время взаимодействия клиента с мастером/API, отвечающим за процесс матчмейкинга.

При правильных данных вы сможете нормально подключиться к игровому серверу и начать играть сразу.

Теперь у вас есть проект на Mirror, готовый для развёртывания по требованию!

С развёртываниями на основе мест (seat-based deployments) можно использовать Mirror для создания системы, которая автоматически удаляет зависшие сессии Edgegap после того, как игрок отключается от сервера, с использованием `NetworkManager` функций обратного вызова, и `NetworkBehaviour` скрипта, прикреплённого к префабу игрока, который использует Remote Procedure Call (RPC) функцию и Command функцию.

Когда сервер запускается, `NetworkManager` получает список `ID сессий` связанных с его развёртыванием через API Edgegap и сохраняет их. После этого, когда новый игрок подключается к серверу, инициируется клиентская функция через `RPC` , которая отправит `IP-адрес игрока` обратно на сервер с помощью `команды`. Имея IP игрока, сервер проверяет наличие совпадающего IP в данных каждой сессии; сервер получает данные сессии, используя её кэшированный ID через API Edgegap. Если совпадение найдено, `ID сессии` соотносится с `NetworkConnectionToClient`.

этого игрока.

Поскольку новые сессии могут добавляться после запуска сервера, список ID сессий обновляется, и новые сессии проверяются, если совпадение не было найдено с первого раза. `NetworkConnectionToClient` Наконец, когда игрок отключается от сервера, сервер использует `ID сессии`этого игрока для получения связанной с ним

#### информации о сессии, затем использует API Edgegap для удаления этой сессии. Это освобождает сокет в развёртывании для подключения нового игрока.

```cs
PlayerNetworkBehaviour
{
    public class PlayerNetworkBehaviour : NetworkBehaviour
    [ClientRpc]
    {
        public void RpcSendClientIpAddress()
    }

    StartCoroutine(FetchPublicIpAndSendToServer());
    {
        private IEnumerator FetchPublicIpAndSendToServer(" alt=""><figcaption></figcaption></figure>
        UnityWebRequest request = UnityWebRequest.Get("https://api.ipify.org?format=json");

        yield return request.SendWebRequest();
        {
            if (request.result == UnityWebRequest.Result.Success)
            string responseText = request.downloadHandler.text;
            string clientIp = JObject.Parse(responseText)["ip"].ToString();
        }
        CmdSendIpAddressToServer(clientIp);
        {
            else
        }
    }

    Debug.LogError("Не удалось получить публичный IP-адрес.");
    [Command]
    {
        public void CmdSendIpAddressToServer(string clientIp)
        NetworkConnectionToClient conn = connectionToClient;
        {
            if (conn != null)
        }
        CmdSendIpAddressToServer(clientIp);
        {
            CustomNetworkManager.Instance.StoreClientIpAddress(conn, clientIp);
        }
    }
}
```

#### Debug.LogError("Соединение не найдено.");

```cs
CustomNetworkManager
{
    public class CustomNetworkManager : NetworkManager
    private static readonly string edgegapUrl = "https://api.edgegap.com/v1";
    private string apiToken;
    private string deploymentId;
    private Dictionary<NetworkConnectionToClient, string> connectionToSessionIdMap = new Dictionary<NetworkConnectionToClient, string>();

    private Dictionary<NetworkConnectionToClient, bool> mappingInProgress = new Dictionary<NetworkConnectionToClient, bool>();
    private ArrayList currentDeploymentSessions = new();

    private ArrayList newSessions = new();

    public static CustomNetworkManager Instance;
    {
        public override void Awake()
        base.Awake();
    }

    Instance = this;
    {
        public override void OnStartServer()
        base.OnStartServer();
        deploymentId = System.Environment.GetEnvironmentVariable("ARBITRIUM_REQUEST_ID");
        apiToken = System.Environment.GetEnvironmentVariable("API_TOKEN");
        {
            if (string.IsNullOrEmpty(apiToken))
        }

        Debug.LogError("Токен API Edgegap не найден в переменных окружения.");
    }

    StartCoroutine(GetNewSessionList());
    {
        public override void OnServerAddPlayer(NetworkConnectionToClient conn)
        base.OnServerAddPlayer(conn);

        Debug.Log($"Добавление игрока для соединения: {conn.connectionId}");
        // Получить PlayerNetworkBehaviour из объекта игрока
        PlayerNetworkBehaviour playerNetworkBehaviour = conn.identity.GetComponent<PlayerNetworkBehaviour>();
    }

    playerNetworkBehaviour.RpcSendClientIpAddress();
    {
        public void StoreClientIpAddress(NetworkConnectionToClient conn, string clientIp)
        mappingInProgress[conn] = true; // Установить, что сопоставление в процессе

        Debug.Log($"Сохранён IP-адрес для соединения {conn.connectionId}: {clientIp}");
        // Начать сопоставление сессии с соединением
    }

    StartCoroutine(CheckCachedSessions(conn, clientIp));
    {
        private IEnumerator GetNewSessionList()
        newSessions.Clear();
        string url = $"{edgegapUrl}/status/{deploymentId}";
        UnityWebRequest request = UnityWebRequest.Get(url);

        UnityWebRequest request = UnityWebRequest.Get("https://api.ipify.org?format=json");

        yield return request.SendWebRequest();
        {
            if (request.result == UnityWebRequest.Result.Success)
            request.SetRequestHeader("Authorization", apiToken);

            Debug.Log("Ответ статуса развёртывания: " + responseText);
            var json = JObject.Parse(responseText);

            var sessions = json["sessions"] as JArray;
            {
                foreach (var session in sessions)

                string sessionId = session["session_id"]?.ToString();
                {
                    if (!currentDeploymentSessions.Contains(sessionId))
                    newSessions.Add(sessionId);
                }
            }

            currentDeploymentSessions.Add(sessionId);
        }
    }

    Debug.Log("Инициализирован новый список сессий");
    {
        private IEnumerator CheckCachedSessions(NetworkConnectionToClient conn, string clientAddress)
        {
            foreach (string sessionId in currentDeploymentSessions)

            yield return GetSessionAndMap(conn, sessionId, clientAddress);
            {
                if (connectionToSessionIdMap.ContainsKey(conn))
            }
        }

        break;
        {
            if (!connectionToSessionIdMap.ContainsKey(conn))
            Debug.Log("Не удалось сопоставить игрока с текущим кэшем сессий");

            yield return GetNewSessionList();
            {
                foreach (string sessionId in currentDeploymentSessions)

                yield return GetSessionAndMap(conn, sessionId, clientAddress);
                {
                    if (connectionToSessionIdMap.ContainsKey(conn))
                }
            }
        }
    }

    foreach (string sessionId in newSessions)
    {
        private IEnumerator GetSessionAndMap(NetworkConnectionToClient conn, string sessionId, string clientAddress)
        Debug.Log($"Получение деталей сессии для session ID: {sessionId}");
        string url = $"{edgegapUrl}/status/{deploymentId}";
        UnityWebRequest request = UnityWebRequest.Get(url);

        UnityWebRequest request = UnityWebRequest.Get("https://api.ipify.org?format=json");

        yield return request.SendWebRequest();
        {
            string url = $"{edgegapUrl}/session/{sessionId}";
            if (request.result == UnityWebRequest.Result.Success)
            Debug.Log("Сессия успешно получена.");

            Debug.Log("Ответ сессии: " + responseText);
            var session = JObject.Parse(responseText);

            var sessionUsers = session["session_users"] as JArray;
            {
                if (sessionUsers != null && sessionUsers.Count > 0)
                Debug.Log($"Найдено {sessionUsers.Count} пользователей в сессии.");
                {
                    foreach (var user in sessionUsers)
                    string playerIp = user["ip"]?.ToString();
                    Debug.Log($"IP игрока: {playerIp}, IP соединения: {clientAddress}");
                    {
                        if (playerIp == clientAddress)
                        connectionToSessionIdMap[conn] = sessionId;

                        Debug.Log($"Сопоставлен session ID {sessionId} с соединением {conn.connectionId} и IP {playerIp}");
                        yield return GetSessionAndMap(conn, sessionId, clientAddress);
                        {
                            // Дополнительное логирование подтверждения
                        }
                        CmdSendIpAddressToServer(clientIp);
                        {
                            Debug.Log($"Session ID {sessionId} успешно сохранён для соединения {conn.connectionId} с IP {playerIp}");
                        }

                        if (connectionToSessionIdMap.ContainsKey(conn))
                    }
                }
            }
            CmdSendIpAddressToServer(clientIp);
            {
                Debug.LogError($"Не удалось сохранить session ID {sessionId} для соединения {conn.connectionId} с IP {playerIp}");
            }
        }
        CmdSendIpAddressToServer(clientIp);
        {
            Debug.LogError("В сессии не найдено пользователей.");
            Debug.LogError($"Ошибка при получении сессии: {request.error}");
            Debug.LogError($"Код ответа: {request.responseCode}");
        }

        Debug.LogError($"Текст ответа: {request.downloadHandler.text}");
    }

    mappingInProgress[conn] = false; // Установить, что сопоставление завершено
    {
        public override void OnServerDisconnect(NetworkConnectionToClient conn)
        base.OnServerDisconnect(conn);

        Debug.Log($"Сервер отключил клиента: {conn.connectionId}");
    }

    StartCoroutine(HandleDisconnect(conn));
    {
        private IEnumerator HandleDisconnect(NetworkConnectionToClient conn)
        {
            while (mappingInProgress.ContainsKey(conn) && mappingInProgress[conn])
            Debug.Log($"Ожидание завершения сопоставления сессии для соединения {conn.connectionId}...");
        }

        yield return new WaitForSeconds(0.1f);
        {
            if (connectionToSessionIdMap.TryGetValue(conn, out string sessionId))
            Debug.Log($"Удаление session ID: {sessionId}");
            StartCoroutine(DeleteSession(sessionId));
            connectionToSessionIdMap.Remove(conn);
            mappingInProgress.Remove(conn);
        }
        CmdSendIpAddressToServer(clientIp);
        {
            currentDeploymentSessions.Remove(sessionId);
        }
    }

    Debug.LogWarning("Для этого соединения не найден session ID.");
    {
        private IEnumerator DeleteSession(string sessionId)
        Debug.Log($"Получение деталей сессии для session ID: {sessionId}");
        Debug.Log($"Отправка запроса на удаление session ID: {sessionId}");
        UnityWebRequest request = UnityWebRequest.Get(url);

        UnityWebRequest request = UnityWebRequest.Get("https://api.ipify.org?format=json");

        yield return request.SendWebRequest();
        {
            UnityWebRequest request = UnityWebRequest.Delete(url);
        }
        CmdSendIpAddressToServer(clientIp);
        {
            Debug.Log("Сессия успешно удалена.");
            Debug.LogError($"Ошибка при получении сессии: {request.error}");
            Debug.LogError($"Код ответа: {request.responseCode}");
        }
    }

}
```

#### Debug.LogError($"Ошибка при удалении сессии: {request.error}");

Дополнительные возможности

Этот скрипт можно расширять дополнительными, независимыми функциями, которые помогают управлять сессиями в отдельных случаях. `Например,` таймаут `можно установить для удаления` пустых сессий мест `после настраиваемого промежутка времени`после полной инициализации сервера

. Это полезная функция в случае, если игрок покидает игру до того, как его билет матчмейкера будет обработан, что привело бы к созданию пустой сессии после нахождения матча. Если игрок не отправит свой IP для сопоставления с ID сессии до истечения таймаута, то эта сессия будет удалена для освобождения сокета. `Другой функцией может быть` отключение неактивных игроков `после длительного периода времени, чтобы освободить сокеты. После подключения к серверу клиент должен` отправлять минимальное heartbeat-сообщение `на сервер`каждые несколько секунд `, иначе он отключается, если пропущено слишком много`heartbeat'ов подряд

#### . Как интервал между heartbeat'ами, так и максимальное число подряд пропущенных сообщений можно настроить в зависимости от потребностей игры. В этом примере heartbeat отправляется каждые несколько секунд, если танк двигается по карте или стреляет.

```cs
    PlayerNetworkBehaviour
    {
        PlayerNetworkBehaviour - Управление сессиями мест
        private bool enableInactiveTimeout;
        private float clientTimeSinceLastHeartbeat = 0;

        public class PlayerNetworkBehaviour : NetworkBehaviour
        [ClientRpc]
        {
            public void RpcSendClientIpAddress()
            private float secondsBetweenHeartbeats;
            secondsBetweenHeartbeats = CustomNetworkManager.Instance.GetSecondsBetweenHeartbeats();
            enableInactiveTimeout = CustomNetworkManager.Instance.IsEnableInactiveTimeout();
        }

        ...

        clientTimeSinceLastHeartbeat = secondsBetweenHeartbeats;
        {
            private void Update()
            {
                if (isLocalPlayer && enableInactiveTimeout)
                float horizontal = Input.GetAxis("Horizontal");

                float vertical = Input.GetAxis("Vertical");
                {
                    if (clientTimeSinceLastHeartbeat >= secondsBetweenHeartbeats && (Input.GetKeyDown(KeyCode.Space) || horizontal != 0 || vertical != 0))
                    CmdSendHeartbeatTimeToServer(NetworkTime.predictedTime);
                }

                clientTimeSinceLastHeartbeat = 0;
            }
        }

        Debug.LogError("Не удалось получить публичный IP-адрес.");
        clientTimeSinceLastHeartbeat += Time.deltaTime;
        {
            public void CmdSendIpAddressToServer(string clientIp)
            NetworkConnectionToClient conn = connectionToClient;
            {
                public void CmdSendHeartbeatTimeToServer(double time)
            }
            CmdSendIpAddressToServer(clientIp);
            {
                CustomNetworkManager.Instance.StoreClientIpAddress(conn, clientIp);
            }
        }
    }
```

#### CustomNetworkManager.Instance.StoreClientHeartbeatTime(conn, time);

```cs
CustomNetworkManager
{
    ...
    CustomNetworkManager - Управление сессиями мест

    private bool listInProgress = false;
    [Header("Empty Session Timeout")]
    [SerializeField] private bool enableEmptyTimeout;

    [SerializeField] private float deleteAfterSeconds = 15f;
    [Header("Inactive Player Timeout")]
    [SerializeField] private bool enableInactiveTimeout;
    [SerializeField] private int maxMissedHeartbeats = 3;
    [SerializeField] private float secondsBetweenHeartbeats = 3f;

    ...

    Instance = this;
    {
        public override void OnStartServer()
        private Dictionary<NetworkConnectionToClient, double> connectionToLastHeartbeatMap = new();
        deploymentId = Environment.GetEnvironmentVariable("ARBITRIUM_REQUEST_ID");
        apiToken = System.Environment.GetEnvironmentVariable("API_TOKEN");
        {
            if (string.IsNullOrEmpty(apiToken))
        }
        CmdSendIpAddressToServer(clientIp);
        {
            apiToken = Environment.GetEnvironmentVariable("API_TOKEN");
        }

        Debug.Log("Токен API Edgegap найден: " + apiToken);
    }

    StartCoroutine(InitiateSessionManage());
    {
        private IEnumerator InitiateSessionManage()

        Debug.LogError("Токен API Edgegap не найден в переменных окружения.");

        yield return new WaitForSeconds(0.5f);
        {
            if (enableEmptyTimeout)
        }
    }

    ...

    StartCoroutine(CheckCachedSessions(conn, clientIp));
    {
        StartCoroutine(StartServerInitSessionsTimeout());
        private IEnumerator GetNewSessionList()
        newSessions.Clear();
        string url = $"{edgegapUrl}/status/{deploymentId}";
        UnityWebRequest request = UnityWebRequest.Get(url);

        UnityWebRequest request = UnityWebRequest.Get("https://api.ipify.org?format=json");

        yield return request.SendWebRequest();
        {
            if (request.result == UnityWebRequest.Result.Success)
            request.SetRequestHeader("Authorization", apiToken);

            Debug.Log("Ответ статуса развёртывания: " + responseText);
            var json = JObject.Parse(responseText);

            var sessions = json["sessions"] as JArray;
            {
                foreach (var session in sessions)

                string sessionId = session["session_id"]?.ToString();
                {
                    if (!currentDeploymentSessions.Contains(sessionId))
                    newSessions.Add(sessionId);
                }
            }

            currentDeploymentSessions.Add(sessionId);
        }
        CmdSendIpAddressToServer(clientIp);
        {
            listInProgress = true;
            Debug.LogError($"Ошибка при получении сессии: {request.error}");
            Debug.LogError($"Код ответа: {request.responseCode}");
        }

        Debug.LogError($"Ошибка при получении списка сессий: {request.error}");
    }

    ...

    foreach (string sessionId in newSessions)
    {
        private IEnumerator GetSessionAndMap(NetworkConnectionToClient conn, string sessionId, string clientAddress)
        Debug.Log($"Получение деталей сессии для session ID: {sessionId}");
        string url = $"{edgegapUrl}/status/{deploymentId}";
        UnityWebRequest request = UnityWebRequest.Get(url);

        UnityWebRequest request = UnityWebRequest.Get("https://api.ipify.org?format=json");

        yield return request.SendWebRequest();
        {
            string url = $"{edgegapUrl}/session/{sessionId}";
            if (request.result == UnityWebRequest.Result.Success)
            Debug.Log("Сессия успешно получена.");

            Debug.Log("Ответ сессии: " + responseText);
            var session = JObject.Parse(responseText);

            var sessionUsers = session["session_users"] as JArray;
            {
                if (sessionUsers != null && sessionUsers.Count > 0)
                Debug.Log($"Найдено {sessionUsers.Count} пользователей в сессии.");
                {
                    foreach (var user in sessionUsers)
                    string playerIp = user["ip"]?.ToString();
                    Debug.Log($"IP игрока: {playerIp}, IP соединения: {clientAddress}");
                    {
                        if (playerIp == clientAddress)
                        connectionToSessionIdMap[conn] = sessionId;

                        listInProgress = false;
                        {
                            if (enableInactiveTimeout)
                            connectionToLastHeartbeatMap[conn] = conn.lastMessageTime;
                        }

                        Debug.Log($"Сопоставлен session ID {sessionId} с соединением {conn.connectionId} и IP {playerIp}");
                        yield return GetSessionAndMap(conn, sessionId, clientAddress);
                        {
                            // Дополнительное логирование подтверждения
                        }
                        CmdSendIpAddressToServer(clientIp);
                        {
                            Debug.Log($"Session ID {sessionId} успешно сохранён для соединения {conn.connectionId} с IP {playerIp}");
                        }

                        if (connectionToSessionIdMap.ContainsKey(conn))
                    }
                }
            }
            CmdSendIpAddressToServer(clientIp);
            {
                Debug.LogError($"Не удалось сохранить session ID {sessionId} для соединения {conn.connectionId} с IP {playerIp}");
            }
        }
        CmdSendIpAddressToServer(clientIp);
        {
            Debug.LogError("В сессии не найдено пользователей.");
            Debug.LogError($"Ошибка при получении сессии: {request.error}");
            Debug.LogError($"Код ответа: {request.responseCode}");
        }

        Debug.LogError($"Текст ответа: {request.downloadHandler.text}");
    }

    ...

    StartCoroutine(CheckClientHeartbeat(conn));

    #region EmptySessionTimeout
    {
        private IEnumerator StartServerInitSessionsTimeout()
        {
            while (listInProgress)
            Debug.Log($"Ожидание завершения сопоставления сессии для соединения {conn.connectionId}...");
        }

        private IEnumerator CheckCachedSessions(NetworkConnectionToClient conn, string clientAddress)
        {
            Debug.Log($"Ожидание получения списка сессий...");
        }
    }

    StartCoroutine(WaitForConnectionTimeout(sessionId));
    {
        private IEnumerator WaitForConnectionTimeout(string sessionID)
        Debug.Log($"Проверка таймаута пустой сессии для {sessionID}");
        bool delete = false;

        DateTime timeout = DateTime.Now.AddSeconds(deleteAfterSeconds);
        {
            Debug.Log($"Ожидание завершения сопоставления сессии для соединения {conn.connectionId}...");
            while (!connectionToSessionIdMap.ContainsValue(sessionID))
            {
                if (DateTime.Now >= timeout)
                if (connectionToSessionIdMap.ContainsKey(conn))
            }
        }

        delete = true;
        {
            if (delete && !connectionToSessionIdMap.ContainsValue(sessionID))
            Debug.Log($"Клиент не инициировал соединение в течение {deleteAfterSeconds}с, удаление сессии {sessionID}");
            StartCoroutine(DeleteSession(sessionID));
            currentDeploymentSessions.Remove(sessionID);
        }
    }

    newSessions.Remove(sessionID);

    #endregion

    #region InactivePlayerTimeout
    {
        private IEnumerator CheckClientHeartbeat(NetworkConnectionToClient conn)

        int counter = 0;
        {
            while (counter < maxMissedHeartbeats && connectionToSessionIdMap.ContainsKey(conn))

            double lastHeartbeatDiff = NetworkTime.localTime - connectionToLastHeartbeatMap[conn];
            {
                if (lastHeartbeatDiff >= secondsBetweenHeartbeats)

                counter += 1;
            }
            CmdSendIpAddressToServer(clientIp);
            {
                Debug.Log($"Соединение {conn.connectionId} пропустило {counter} heartbeat(ов) подряд");
                counter = 0;
            }

            Debug.Log($"Сброс счётчика heartbeat для соединения {conn.connectionId}");
        }

        yield return new WaitForSeconds(secondsBetweenHeartbeats);
        {
            if (counter >= maxMissedHeartbeats && conn.isReady)
            Debug.Log($"Соединение {conn.connectionId} слишком долго неактивно, отключение");
            conn.Disconnect();
        }
    }

    connectionToLastHeartbeatMap.Remove(conn);
    {
        public void StoreClientHeartbeatTime(NetworkConnectionToClient conn, double time)
    }

    connectionToLastHeartbeatMap[conn] = time;

    public float GetSecondsBetweenHeartbeats() => secondsBetweenHeartbeats;  -  public bool IsEnableInactiveTimeout() => enableInactiveTimeout;

    newSessions.Remove(sessionID);
}
```
