# Мост Playfab

Это руководство покажет вам, как настроить игру с PlayFab, чтобы автоматически развертывать сервер на Edgegap после того, как игроки будут сопоставлены через матчмейкер PlayFab. Клиенты будут получать информацию для подключения к серверу через PlayFab Player Data, которые сервер обновит, когда будет готов. Перед началом мы предполагаем, что:

* Вы в настоящее время используете PlayFab;
* Вы уже [создали](https://docs.edgegap.com/ru/learn/orkestraciya/application-and-versions) и [развернули](https://docs.edgegap.com/ru/learn) приложение с Edgegap ранее.

Вы можете найти окончательный пример проекта на нашем [GitHub](https://github.com/edgegap/sample-playfab-matchmaker-fishnet). Этот пример был протестирован с использованием Unity v2021.3.10 и Fishnet v3.11.14 в качестве сетевого кода.

### Настройка PlayFab Cloud Script

#### Создать новую функцию

Войдите в свою учетную запись PlayFab, затем перейдите на страницу обзора вашей игры на панели управления. В `Automation` вкладке создайте новую функцию Cloud Script, которая будет отправлять запрос к API Edgegap для развертывания нового сервера.

В запросе вам нужно передать список широты/долготы игроков, чтобы сервер мог быть развернут в наилучшей возможной локации для всех.

Вам также нужно передать некоторые пользовательские переменные окружения в виде пар ключ-значение, а именно:

* the `Match ID` сгенерированный матчмейкером PlayFab;
* a PlayFab `Title Secret Key`, который можно найти в настройках вашего титула;
* a `list` каждого игрока `Master Player Account Id`.

Эти переменные будут использоваться для обновления PlayFab `Player Data` игроков информацией, необходимой для подключения к серверу. Запоминайте ключи, которые вы используете для установки этих переменных; в этом примере они: `"PLAYFAB_MATCH_ID"`, `"PLAYFAB_TITLE_SECRET"`, и `"PLAYFAB_MATCH_PLAYERS_ID_LIST"`.

{% hint style="info" %}
Самый простой способ создать эту функцию — развернуть новое ревизию в `Revisions (Legacy)` вкладке, добавив в неё следующий код. Обязательно обновите `Edgegap_AppName`, `Edgegap_AppVersion`, `Edgegap_ApiToken` и `PlayFab_TitleSecret` переменные вверху вашими собственными значениями сначала.

Название приложения и версия будут использоваться позже при контейнеризации игрового сервера на Edgegap.

Список ID игроков хранится здесь как одна строка, где каждый ID разделяется запятой.
{% endhint %}

```javascript
var Edgegap_AppName = "APP_NAME";
var Edgegap_AppVersion = "APP_VERSION";
var Edgegap_ApiToken = "token XXXX"; //include the "token" keyword
var PlayFab_TitleSecret = "TITLE_SECRET";

handlers.StartEdgegapDeployment = function (args, context) {
  var QueueName = context.playStreamEvent.Payload.QueueName;
  var MatchId = context.playStreamEvent.Payload.MatchId;

  var GeoIpList = [];
  var PlayerIds = "";

  var MatchMember = multiplayer.GetMatch({
    QueueName: QueueName,
    MatchId: MatchId,
    EscapeObject: false,
    ReturnMemberAttributes: false,
  }).Members;

  MatchMember.forEach((Member) => {
    var Profile = entity.GetProfile({
      Entity: Member.Entity,
    });

    var Location = server.GetPlayerProfile({
      PlayFabId: Profile.Profile.Lineage.MasterPlayerAccountId,
      ProfileConstraints: {
        ShowLocations: true,
      },
    });

    GeoIpList.push({
      ip: "1.2.3.4",
      latitude: Location.PlayerProfile.Locations[0].Latitude,
      longitude: Location.PlayerProfile.Locations[0].Longitude,
    });

    PlayerIds += Profile.Profile.Lineage.MasterPlayerAccountId + ",";
  });

  PlayerIds = PlayerIds.slice(0, -1);

  var response = JSON.parse(
    http.request(
      "https://api.edgegap.com/v1/deploy",
      "post",
      JSON.stringify({
        app_name: Edgegap_AppName,
        version_name: Edgegap_AppVersion,
        geo_ip_list: [...GeoIpList],
        tags: [MatchId.slice(0, 20)],
        env_vars: [
          {
            key: "PLAYFAB_MATCH_ID",
            value: MatchId,
          },
          {
            key: "PLAYFAB_TITLE_SECRET",
            value: TitleSecret,
          },
          {
            key: "PLAYFAB_MATCH_PLAYERS_ID_LIST",
            value: PlayerIds,
          },
        ],
      }),
      "application/json",
      { authorization: Edgegap_ApiToken }
    )
  );

  log.info("response", response);
};
```

{% hint style="info" %}
Эта функция Cloud Script создаёт интерфейс между игровым клиентом и API Edgegap, который скрывает ваши `Edgegap API token` и `PlayFab Title Secret` от игроков.
{% endhint %}

### Правило автоматизации

Все ещё в `Automation`, перейдите на вкладку `Rules` вкладку. Создайте новое правило со следующими настройками:

* Установите `Event Type` к `playfab.matchmaking.match_found`;
* Добавьте новое `Action`:
  * Установите `Type` к `Execute Entity Cloud Script`;
  * Установите `Cloud Script Function` к `StartEdgegapDeployment`.

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

### Очередь матчмейкера и игроки

В `Multiplayer` вкладке, в разделе `Matchmaking`убедитесь, что у вас есть доступная очередь, иначе создайте новую. Для быстрой проверки настройте её так, чтобы она просто сопоставляла любые двух игроков вместе.

Вам потребуется достаточное количество игроков, зарегистрированных в вашем титуле PlayFab, чтобы удовлетворить правила матчмейкера, поэтому в данном случае подойдут только двое. При необходимости вы можете создать новых на вкладке `Players` . Для этого примера проекта обязательно запоминайте `Custom ID` и `Title Player Account ID` этих игроков для последующего тестирования.

### Редактирование игры

#### Сервер

После открытия примера игры в редакторе Unity мы добавили новый пустой gameObject в `BattleScene` найденную в `Assets/SpaceEdge/Scenes`. Затем мы прикрепили к нему новый скрипт C# под названием `PlayerDataUpdateService` . Он содержит функцию обратного вызова, которая будет выполнена, как только сервер будет готов.

Мы также создали второй скрипт под названием `PlayfabRestApiService`, который содержит различные запросы, которые необходимо отправлять к различным API PlayFab.

В `PlayerDataUpdateService.cs`мы получаем несколько переменных окружения, внедрённых в развертывании:

* Пользовательские переменные:
  * `PLAYFAB_MATCH_ID`;
  * `PLAYFAB_TITLE_SECRET`;
  * `PLAYFAB_MATCH_PLAYERS_ID_LIST`
* Стандартные переменные Edgegap:
  * `ARBITRIUM_PUBLIC_IP`;
  * `ARBITRIUM_PORTS_MAPPING`;

В `ARBITRIUM_PORTS_MAPPING`, нам нужно получить конкретное внешнее `порт` значение, которое позволит клиенту подключиться к серверу; в этом примере это значение с именем `"Game Port"`.

Используя `PLAYFAB_TITLE_SECRET` в качестве аутентификации, мы отправляем запрос к Server API PlayFab, чтобы обновить `Player Data` для каждого соответствующего ID в `PLAYFAB_MATCH_PLAYERS_ID_LIST` следующей парой ключ-значение:

* Ключ: `PLAYFAB_MATCH_ID`;
* Значение: `"{ARBITRIUM_PUBLIC_IP}:{port}"`;

#### PlayerDataUpdateService

```cs
public class PlayerDataUpdateService : NetworkBehaviour
{
    private PlayfabRestApiService _playfabService;

    [SerializeField] private string KeyMatchId = "PLAYFAB_MATCH_ID";
    [SerializeField] private string KeyTitleSecret = "PLAYFAB_TITLE_SECRET";
    [SerializeField] private string KeyPlayerIds = "PLAYFAB_MATCH_PLAYERS_ID_LIST";
    [SerializeField] private string ServerPortName = "Game Port";

    private string matchID;
    private string titleSecretKey;
    private string[] playerIdList;

    public override void OnStartServer()
    {
        _playfabService = FindObjectOfType<PlayfabRestApiService>();

        matchID = Environment.GetEnvironmentVariable(KeyMatchId);
        titleSecretKey = Environment.GetEnvironmentVariable(KeyTitleSecret);
        playerIdList = Environment.GetEnvironmentVariable(KeyPlayerIds)?.Split(',');

        string address = Environment.GetEnvironmentVariable("ARBITRIUM_PUBLIC_IP");
        string portsMapping = Environment.GetEnvironmentVariable("ARBITRIUM_PORTS_MAPPING");

        if (!string.IsNullOrEmpty(portsMapping))
        {
            int port = JSON.ParseString(portsMapping).GetJSON("ports").GetJSON(ServerPortName).GetInt("external");

            foreach (string playerId in playerIdList)
            {
                if (!string.IsNullOrEmpty(playerId))
                {
                    _playfabService.UpdatePlayerData(titleSecretKey, playerId, matchID, $"{address}:{port}");
                }
            }
        }
        else
        {
            Debug.Log("Port Mapping Unavailable");
        }
    }

    ...
}
```

#### PlayfabRestApiService

```cs
public class PlayfabRestApiService : MonoBehaviour
{
    ...

    [SerializeField] private string TitleID = "TITLE_ID";

    private const string TypeHeaderValue = "application/json";
    private readonly HttpClient _playfabHttpClient = new();
    private HttpResponseMessage _request;
    private StringContent _requestData;
    private string _response;

    ...

    public async void UpdatePlayerData(string secretKey, string playerID, string key, string value)
    {
        if (!_playfabHttpClient.DefaultRequestHeaders.Contains("X-SecretKey"))
        {
            _playfabHttpClient.DefaultRequestHeaders.Add("X-SecretKey", secretKey);
        }

        var dataProperty = new JSON();
        dataProperty.Add(key, value);

        var requestJson = new JSON();
        requestJson.Add("PlayFabId", playerID);
        requestJson.Add("Data", dataProperty);

        _requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);
        _request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Server/UpdateUserData", _requestData);
        _response = await _request.Content.ReadAsStringAsync();

        if (!_request.IsSuccessStatusCode)
        {
            Debug.Log($"Could Not Update PlayerData of ID {playerID}, Error {(int)_request.StatusCode} With Message: \n{_response}");
        }
    }
}
```

#### Клиент

Настройка на стороне клиента будет управляться в `MainMenu` сцене, найденной в `Assets/SpaceEdge/Scenes`. Мы создали новый скрипт под названием `MainMenuService` для управления интерфейсом сцены и отображения различных сообщений игроку, и добавили новые функции в `PlayfabRestApiService.cs`. Мы также прикрепили этот `PlayfabRestApiService` скрипт к новому gameObject в этой сцене.

Первое, что должен сделать игрок — войти через PlayFab, что позволит ему отправлять дополнительные запросы к Client и Multiplayer API PlayFab.

{% hint style="info" %}
Мы используем `LoginWithCustomID` эндпоинт для простоты, однако существуют более подходящие способы реализации входа в зависимости от ситуации (например: для мобильных игр).
{% endhint %}

После входа игрока и корректной установки заголовков `X-Authorization` и `X-EntityToken` для последующих запросов, он может создать новый билет на матчмейкинг, используя кнопку UI `Start Game` . Это отправит запрос к мультиплеерному API PlayFab и сохранит `ticket ID` для последующего использования.

После этого игровой клиент периодически будет проверять статус билета каждые несколько секунд, пока матч не будет найден или билет не будет отменён. Как только матч найден, `match ID` сохраняется как переменная.

Клиент затем периодически будет посылать запросы к Client API PlayFab, чтобы получить Title игрока `Player Data`, используя `match ID` в качестве ключа для конкретных данных, которые необходимо получить. Как только данные найдены, он устанавливает `адрес` сервера `порт` и внешнее `значение в` компоненте транспорта сетевого кода, затем подключается к серверу.

Когда клиент подключится и корректно запустится, будет отправлен запрос на удаление информации о подключении из PlayFab этого игрока `Player Data` благодаря функции обратного вызова.

{% hint style="info" %}
Важно убедиться, что эти пары ключ-значение удаляются, желательно до завершения развертывания. Это необходимо, чтобы избежать засорения `Player Data` слишком большим количеством ненужных записей.
{% endhint %}

#### MainMenuService

```cs
public class MainMenuService : MonoBehaviour
{
    [SerializeField] private Button startGameButton;
    [SerializeField] private TMP_InputField playerNameInput;
    [SerializeField] private TMP_Text serverStatus;

    private PlayfabRestApiService _playfabService;

    private void Start()
    {
        if (!InstanceFinder.ServerManager.GetStartOnHeadless())
        {
            _playfabService = FindObjectOfType<PlayfabRestApiService>();
            _playfabService.OnStatusUpdate += ServerStatusUpdate;
            _playfabService.OnUpdateButton += UpdateButtonState;

            startGameButton.onClick.AddListener(StartGame);
            playerNameInput.onValueChanged.AddListener(NameChanged);
            if (PlayerPrefs.HasKey("PlayerName"))
                playerNameInput.text = PlayerPrefs.GetString("PlayerName");

            _playfabService.LoginPlayer();
        }
    }

    private void OnDestroy()
    {
        if (!InstanceFinder.ServerManager.GetStartOnHeadless())
        {
            _playfabService.OnStatusUpdate -= ServerStatusUpdate;
            _playfabService.OnUpdateButton -= UpdateButtonState;
        }
    }

    private void StartGame()
    {
        startGameButton.interactable = false;
        serverStatus.text = "";
        _playfabService.CreateTicket();
    }

    private void ServerStatusUpdate(string status, bool isError)
    {
        serverStatus.text += "\n" + status;
        serverStatus.color = isError ? Color.red : Color.green;
        Debug.Log(status);
    }

    private void UpdateButtonState(bool state)
    {
        startGameButton.interactable = state;
        serverStatus.text = "";
    }

    private void NameChanged(string text) => PlayerPrefs.SetString("PlayerName", text);
}
```

#### PlayfabRestApiService

```cs
public class PlayfabRestApiService : MonoBehaviour
{
    [SerializeField] private string PlayerCustomID = "PLAYER_CUSTOM_ID";
    [SerializeField] private string PlayerTitleID = "PLAYER_TITLE_ID";
    [SerializeField] private string TitleID = "TITLE_ID";
    [SerializeField] private string QueueName = "QUEUE_NAME";

    private const string TypeHeaderValue = "application/json";
    private readonly HttpClient _playfabHttpClient = new();
    private HttpResponseMessage _request;
    private StringContent _requestData;
    private string _response;
    private string _ticketID = "";
    private string _matchID = "";

    public Action<string, bool> OnStatusUpdate;
    public Action<bool> OnUpdateButton;

    private static PlayfabRestApiService _instance = null;

    private void Awake()
    {
        _playfabHttpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue(TypeHeaderValue));

        if (_instance is null)
        {
            DontDestroyOnLoad(this);
            _instance = this;
        }
        else
        {
            Destroy(this);
        }
    }

    public async void LoginPlayer()
    {
        var requestJson = new JSON();
        requestJson.Add("CustomID", PlayerCustomID);
        requestJson.Add("CreateAccount", false);
        requestJson.Add("TitleID", TitleID);
        _requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);

        _request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Client/LoginWithCustomID", _requestData);
        _response = await _request.Content.ReadAsStringAsync();

        if (_request.IsSuccessStatusCode)
        {
            var responseJson = JSON.ParseString(_response);

            string token = responseJson.GetJSON("data").GetJSON("EntityToken").GetString("EntityToken");
            _playfabHttpClient.DefaultRequestHeaders.Add("X-EntityToken", token);

            string sessionTicket = responseJson.GetJSON("data").GetString("SessionTicket");
            _playfabHttpClient.DefaultRequestHeaders.Add("X-Authorization", sessionTicket);

            OnUpdateButton?.Invoke(true);
            OnStatusUpdate?.Invoke("Login Successful", false);
        }
        else
        {
            OnStatusUpdate?.Invoke($"Unable To Login Player, Error {(int)_request.StatusCode} With Message: \n{_response}", true);
        }
    }

    public async void CreateTicket()
    {
        var requestJson = new JSON();

        var entityProperty = new JSON();
        entityProperty.Add("Id", PlayerTitleID);
        entityProperty.Add("Type", "title_player_account");

        var creatorProperty = new JSON();
        creatorProperty.Add("Entity", entityProperty);

        requestJson.Add("Creator", creatorProperty);
        requestJson.Add("GiveUpAfterSeconds", 90);
        requestJson.Add("QueueName", QueueName);
        _requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);

        _request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Match/CreateMatchmakingTicket", _requestData);
        _response = await _request.Content.ReadAsStringAsync();

        if (_request.IsSuccessStatusCode)
        {
            var responseJson = JSON.ParseString(_response);
            _ticketID = responseJson.GetJSON("data").GetString("TicketId");

            OnStatusUpdate?.Invoke($"Ticket Created, ID #{_ticketID}", false);

            GetMatchIdAttempt();
        }
        else
        {
            OnUpdateButton?.Invoke(true);
            OnStatusUpdate?.Invoke($"Unable To Create Ticket, Error {(int)_request.StatusCode} With Message: \n{_response}", true);
        }
    }

    private async void GetMatchIdAttempt()
    {
        bool isMatchFound = false;
        var requestJson = new JSON();
        requestJson.Add("EscapeObject", true);
        requestJson.Add("QueueName", QueueName);
        requestJson.Add("TicketId", _ticketID);
        _requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);

        while (!isMatchFound)
        {
            _request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Match/GetMatchmakingTicket", _requestData);
            _response = await _request.Content.ReadAsStringAsync();

            if (_request.IsSuccessStatusCode)
            {
                var responseJson = JSON.ParseString(_response);
                string ticketStatus = responseJson.GetJSON("data").GetString("Status");
                isMatchFound = ticketStatus == "Matched";

                if (isMatchFound)
                {
                    _matchID = responseJson.GetJSON("data").GetString("MatchId");
                    OnStatusUpdate?.Invoke($"Ticket Has Been Matched, ID #{_matchID}", false);

                    StartConnectionAttempt();
                }
                else if (ticketStatus == "Canceled")
                {
                    isMatchFound = true;
                    string reason = responseJson.GetJSON("data").GetString("CancellationReason");

                    OnUpdateButton?.Invoke(true);
                    OnStatusUpdate?.Invoke($"Ticket Has Been Cancelled Due To {reason}", false);
                }
                else
                {
                    OnStatusUpdate?.Invoke("Match Not Found, Retrying In 10 Seconds...", false);
                    await Task.Delay(10000);
                }

            }
            else
            {
                isMatchFound = true;
                OnUpdateButton?.Invoke(true);
                OnStatusUpdate?.Invoke($"Could Not Get Ticket Info, Error {(int)_request.StatusCode} With Message: \n{_response}", true);
            }
        }
    }

    private async void StartConnectionAttempt()
    {
        bool gotConnectionInfo = false;

        var requestJson = new JSON();
        requestJson.Add("Keys", new List<string> { _matchID });

        _requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);

        while (!gotConnectionInfo)
        {
            _request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Client/GetUserData", _requestData);
            _response = await _request.Content.ReadAsStringAsync();

            if (_request.IsSuccessStatusCode)
            {
                var responseJson = JSON.ParseString(_response);
                var dataObject = responseJson.GetJSON("data").GetJSON("Data");

                if (dataObject.Count > 0)
                {
                    gotConnectionInfo = true;
                    string connectionInfo = dataObject.GetJSON(_matchID).GetString("Value");
                    string serverAddress = connectionInfo.Split(':')[0];

                    if (ushort.TryParse(connectionInfo.Split(':')[1], out ushort serverPort))
                    {
                        InstanceFinder.TransportManager.Transport.SetPort(serverPort);
                        InstanceFinder.TransportManager.Transport.SetClientAddress(serverAddress);
                        Debug.Log($"Connecting To Server Using Port {serverPort} And IP {serverAddress}");
                        InstanceFinder.ClientManager.StartConnection();
                    }
                    else
                    {
                        OnUpdateButton?.Invoke(true);
                        OnStatusUpdate?.Invoke($"Error While Parsing Server Port Value", true);
                    }
                }
                else
                {
                    OnStatusUpdate?.Invoke("No Connection Data Found, Retrying In 10 Seconds...", false);
                    await Task.Delay(10000);
                }
            }
            else
            {
                gotConnectionInfo = true;
                OnUpdateButton?.Invoke(true);
                OnStatusUpdate?.Invoke($"Could Not Get Player Data, Error {(int)_request.StatusCode} With Message: \n{_response}", true);
            }
        }
    }

    public async void RemovePlayerData()
    {
        var requestJson = new JSON();
        requestJson.Add("KeysToRemove", new string[] { _matchID });

        _requestData = new StringContent(requestJson.CreateString(), Encoding.UTF8, TypeHeaderValue);
        _request = await _playfabHttpClient.PostAsync($"https://{TitleID}.playfabapi.com/Client/UpdateUserData", _requestData);
        _response = await _request.Content.ReadAsStringAsync();

        if (!_request.IsSuccessStatusCode)
        {
            Debug.Log($"Could Not Update PlayerData, Error {(int)_request.StatusCode} With Message: \n{_response}");
        }
    }

    ...
}
```

#### PlayerDataUpdateService

```cs
public class PlayerDataUpdateService : NetworkBehaviour
{
    private PlayfabRestApiService _playfabService;

    ...

    public override void OnStartClient()
    {
        _playfabService = FindObjectOfType<PlayfabRestApiService>();
        _playfabService.RemovePlayerData();
    }
}
```

### Сборка игрового сервера и контейнеризация

Перед контейнеризацией сборки сервера убедитесь, что включена опция `Start On Headless` в `NetworkManager` gameObject.

Откройте плагин Edgegap через меню панели инструментов `Tools/Edgegap Hosting` . Проверьте ваш `Edgegap API Token` и либо создайте, либо загрузите приложение для игры. Убедитесь, что `порт` значение совпадает со значением компонента Tugboat у `NetworkManager` gameObject. Выберите `UDP` protocol, затем введите `New Version Tag`. Убедитесь, что и название приложения, и версия совпадают со значениями, которые использовались при настройке Cloud Script.

Когда всё будет правильно настроено, нажмите `Build and Push`, что автоматически контейнеризирует ваш игровой сервер и создаст новую версию приложения на панели Edgegap после короткого ожидания.

{% hint style="info" %}
При создании версии приложения плагин по умолчанию назовёт порт \`"Game Port"\`.

Если вы решите вручную контейнеризировать и создать версию приложения вместо использования плагина, убедитесь, что используете это имя при выборе порта.
{% endhint %}

### Тестирование

Сначала убедитесь, что отключили `Start On Headless` в `NetworkManager` gameObject. Установите `PlayfabRestApiService` скрипт со значением `Custom ID` и `Title ID` вашего первого игрока, а также `Title ID` и `Queue Name`вашей игры. `В`Build Settings

создайте новую клиентскую сборку с этими настройками. `Custom ID` и `Title ID` После завершения сборки измените

значения на значения вашего второго игрока. При желании вы можете создать отдельную клиентскую сборку, хотя редактор Unity в режиме Play также должен работать. `Start Game` Если оба клиента корректно войдут при запуске, нажмите на кнопку
