agones.dev/agones@v1.54.0/sdks/unity/AgonesSdk.cs (about)

     1  // Copyright 2019 Google LLC
     2  // All Rights Reserved.
     3  //
     4  // Licensed under the Apache License, Version 2.0 (the "License");
     5  // you may not use this file except in compliance with the License.
     6  // You may obtain a copy of the License at
     7  //
     8  //     http://www.apache.org/licenses/LICENSE-2.0
     9  //
    10  // Unless required by applicable law or agreed to in writing, software
    11  // distributed under the License is distributed on an "AS IS" BASIS,
    12  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  // See the License for the specific language governing permissions and
    14  // limitations under the License.
    15  
    16  using System;
    17  using System.Collections.Generic;
    18  using System.Linq;
    19  using System.Net;
    20  using System.Runtime.CompilerServices;
    21  using System.Text;
    22  using System.Threading;
    23  using System.Threading.Tasks;
    24  using Agones.Model;
    25  using MiniJSON;
    26  using UnityEngine;
    27  using UnityEngine.Networking;
    28  
    29  namespace Agones
    30  {
    31      /// <summary>
    32      /// Agones SDK for Unity.
    33      /// </summary>
    34      public class AgonesSdk : MonoBehaviour, IRequestSender
    35      {
    36          /// <summary>
    37          /// Handles sending HTTP requests to the Agones sidecar.
    38          /// </summary>
    39          public IRequestSender requestSender;
    40          /// <summary>
    41          /// Interval of the server sending a health ping to the Agones sidecar.
    42          /// </summary>
    43          [Range(0.01f, 5)] public float healthIntervalSecond = 5.0f;
    44  
    45          /// <summary>
    46          /// Whether the server sends a health ping to the Agones sidecar.
    47          /// </summary>
    48          public bool healthEnabled = true;
    49  
    50          /// <summary>
    51          /// Debug Logging Enabled. Debug logging for development of this Plugin.
    52          /// </summary>
    53          public bool logEnabled = false;
    54  
    55          private string sidecarAddress;
    56          private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
    57  
    58          private struct KeyValueMessage
    59          {
    60              public string key;
    61              public string value;
    62              public KeyValueMessage(string k, string v) => (key, value) = (k, v);
    63          }
    64  
    65          private List<WatchGameServerCallback> watchCallbacks = new List<WatchGameServerCallback>();
    66          private bool watchingForUpdates = false;
    67  
    68          #region Unity Methods
    69          // Use this for initialization.
    70          private void Awake()
    71          {
    72              String port = Environment.GetEnvironmentVariable("AGONES_SDK_HTTP_PORT");
    73              sidecarAddress = "http://localhost:" + (port ?? "9358");
    74          }
    75  
    76          private void Start()
    77          {
    78              requestSender ??= this;
    79              HealthCheckAsync();
    80          }
    81  
    82          private void OnApplicationQuit()
    83          {
    84              cancellationTokenSource.Dispose();
    85          }
    86          #endregion
    87  
    88          #region AgonesRestClient Public Methods
    89  
    90          /// <summary>
    91          /// Async method that waits to connect to the SDK Server. Will timeout
    92          /// and return false after 30 seconds.
    93          /// </summary>
    94          /// <returns>A task that indicated whether it was successful or not</returns>
    95          public async Task<bool> Connect()
    96          {
    97              for (var i = 0; i < 30; i++)
    98              {
    99                  Log($"Attempting to connect...{i + 1}");
   100                  try
   101                  {
   102                      var gameServer = await GameServer();
   103                      if (gameServer != null)
   104                      {
   105                          Log("Connected!");
   106                          return true;
   107                      }
   108                  }
   109                  catch (Exception ex)
   110                  {
   111                      Log($"Connection exception: {ex.Message}");
   112                  }
   113  
   114                  Log("Connection failed, retrying.");
   115                  await Task.Delay(1000);
   116              }
   117  
   118              return false;
   119          }
   120  
   121          /// <summary>
   122          /// Marks this Game Server as ready to receive connections.
   123          /// </summary>
   124          /// <returns>
   125          /// A task that represents the asynchronous operation and returns true if the request was successful.
   126          /// </returns>
   127          public async Task<bool> Ready()
   128          {
   129              return await requestSender.SendRequestAsync("/ready", "{}").ContinueWith(task => task.Result.ok);
   130          }
   131  
   132          /// <summary>
   133          /// Retrieve the GameServer details
   134          /// </summary>
   135          /// <returns>The current GameServer configuration</returns>
   136          public async Task<GameServer> GameServer()
   137          {
   138              var result = await requestSender.SendRequestAsync("/gameserver", "{}", UnityWebRequest.kHttpVerbGET);
   139              if (!result.ok)
   140              {
   141                  return null;
   142              }
   143  
   144              var data = Json.Deserialize(result.json) as Dictionary<string, object>;
   145              return new GameServer(data);
   146          }
   147  
   148          /// <summary>
   149          /// Marks this Game Server as ready to shutdown.
   150          /// </summary>
   151          /// <returns>
   152          /// A task that represents the asynchronous operation and returns true if the request was successful.
   153          /// </returns>
   154          public async Task<bool> Shutdown()
   155          {
   156              return await requestSender.SendRequestAsync("/shutdown", "{}").ContinueWith(task => task.Result.ok);
   157          }
   158  
   159          /// <summary>
   160          /// Marks this Game Server as Allocated.
   161          /// </summary>
   162          /// <returns>
   163          /// A task that represents the asynchronous operation and returns true if the request was successful.
   164          /// </returns>
   165          public async Task<bool> Allocate()
   166          {
   167              return await requestSender.SendRequestAsync("/allocate", "{}").ContinueWith(task => task.Result.ok);
   168          }
   169  
   170          /// <summary>
   171          /// Set a metadata label that is stored in k8s.
   172          /// </summary>
   173          /// <param name="key">label key</param>
   174          /// <param name="value">label value</param>
   175          /// <returns>
   176          /// A task that represents the asynchronous operation and returns true if the request was successful.
   177          /// </returns>
   178          public async Task<bool> SetLabel(string key, string value)
   179          {
   180              string json = JsonUtility.ToJson(new KeyValueMessage(key, value));
   181              return await requestSender.SendRequestAsync("/metadata/label", json, UnityWebRequest.kHttpVerbPUT)
   182                  .ContinueWith(task => task.Result.ok);
   183          }
   184  
   185          /// <summary>
   186          /// Set a metadata annotation that is stored in k8s.
   187          /// </summary>
   188          /// <param name="key">annotation key</param>
   189          /// <param name="value">annotation value</param>
   190          /// <returns>
   191          /// A task that represents the asynchronous operation and returns true if the request was successful.
   192          /// </returns>
   193          public async Task<bool> SetAnnotation(string key, string value)
   194          {
   195              string json = JsonUtility.ToJson(new KeyValueMessage(key, value));
   196              return await requestSender.SendRequestAsync("/metadata/annotation", json, UnityWebRequest.kHttpVerbPUT)
   197                  .ContinueWith(task => task.Result.ok);
   198          }
   199  
   200          private struct Duration
   201          {
   202              public int seconds;
   203  
   204              public Duration(int seconds)
   205              {
   206                  this.seconds = seconds;
   207              }
   208          }
   209  
   210          /// <summary>
   211          /// Move the GameServer into the Reserved state for the specified Timespan (0 seconds is forever)
   212          /// Smallest unit is seconds.
   213          /// </summary>
   214          /// <param name="duration">The time span to reserve for</param>
   215          /// <returns>
   216          /// A task that represents the asynchronous operation and returns true if the request was successful
   217          /// </returns>
   218          public async Task<bool> Reserve(TimeSpan duration)
   219          {
   220              string json = JsonUtility.ToJson(new Duration(seconds: duration.Seconds));
   221              return await requestSender.SendRequestAsync("/reserve", json).ContinueWith(task => task.Result.ok);
   222          }
   223  
   224          /// <summary>
   225          /// WatchGameServerCallback is the callback that will be executed every time
   226          /// a GameServer is changed and WatchGameServer is notified
   227          /// </summary>
   228          /// <param name="gameServer">The GameServer value</param>
   229          public delegate void WatchGameServerCallback(GameServer gameServer);
   230  
   231          /// <summary>
   232          /// WatchGameServer watches for changes in the backing GameServer configuration.
   233          /// </summary>
   234          /// <param name="callback">This callback is executed whenever a GameServer configuration change occurs</param>
   235          public void WatchGameServer(WatchGameServerCallback callback)
   236          {
   237              this.watchCallbacks.Add(callback);
   238              if (!this.watchingForUpdates)
   239              {
   240                  StartWatchingForUpdates();
   241              }
   242          }
   243          #endregion
   244  
   245          #region AgonesRestClient Private Methods
   246  
   247          private void NotifyWatchUpdates(GameServer gs)
   248          {
   249              this.watchCallbacks.ForEach((callback) =>
   250              {
   251                  try
   252                  {
   253                      callback(gs);
   254                  }
   255                  catch (Exception ignore) { } // Ignore callback exceptions
   256              });
   257          }
   258  
   259          private void StartWatchingForUpdates()
   260          {
   261              var req = new UnityWebRequest(sidecarAddress + "/watch/gameserver", UnityWebRequest.kHttpVerbGET);
   262              req.downloadHandler = new GameServerHandler(this);
   263              req.SetRequestHeader("Content-Type", "application/json");
   264              req.SendWebRequest();
   265              this.watchingForUpdates = true;
   266              Log("Agones Watch Started");
   267          }
   268  
   269          private async void HealthCheckAsync()
   270          {
   271              while (healthEnabled)
   272              {
   273                  await Task.Delay(TimeSpan.FromSeconds(healthIntervalSecond));
   274  
   275                  try
   276                  {
   277                      await requestSender.SendRequestAsync("/health", "{}");
   278                  }
   279                  catch (ObjectDisposedException)
   280                  {
   281                      break;
   282                  }
   283              }
   284          }
   285  
   286          /// <summary>
   287          /// Result of a Async HTTP request
   288          /// </summary>
   289          public struct AsyncResult
   290          {
   291              public bool ok;
   292              public string json;
   293          }
   294  
   295          public async Task<AsyncResult> SendRequestAsync(string api, string json,
   296              string method = UnityWebRequest.kHttpVerbPOST)
   297          {
   298              // To prevent that an async method leaks after destroying this gameObject.
   299              cancellationTokenSource.Token.ThrowIfCancellationRequested();
   300  
   301              var req = new UnityWebRequest(sidecarAddress + api, method)
   302              {
   303                  uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json)),
   304                  downloadHandler = new DownloadHandlerBuffer()
   305              };
   306              req.SetRequestHeader("Content-Type", "application/json");
   307  
   308              await new AgonesAsyncOperationWrapper(req.SendWebRequest());
   309  
   310              var result = new AsyncResult();
   311  
   312              result.ok = req.responseCode == (long)HttpStatusCode.OK;
   313  
   314              if (result.ok)
   315              {
   316                  result.json = req.downloadHandler.text;
   317                  Log($"Agones SendRequest ok: {method} {api} {json} {req.downloadHandler.text}");
   318              }
   319              else
   320              {
   321                  Log($"Agones SendRequest failed: {method} {api} {json} {req.error}");
   322              }
   323  
   324              req.Dispose();
   325  
   326              return result;
   327          }
   328  
   329          private void Log(object message)
   330          {
   331              if (!logEnabled)
   332              {
   333                  return;
   334              }
   335  
   336              Debug.Log(message);
   337          }
   338          #endregion
   339  
   340          #region AgonesRestClient Nested Classes
   341          private class AgonesAsyncOperationWrapper
   342          {
   343              public UnityWebRequestAsyncOperation AsyncOp { get; }
   344              public AgonesAsyncOperationWrapper(UnityWebRequestAsyncOperation unityOp)
   345              {
   346                  AsyncOp = unityOp;
   347              }
   348  
   349              public AgonesAsyncOperationAwaiter GetAwaiter()
   350              {
   351                  return new AgonesAsyncOperationAwaiter(this);
   352              }
   353          }
   354  
   355          private class AgonesAsyncOperationAwaiter : INotifyCompletion
   356          {
   357              private UnityWebRequestAsyncOperation asyncOp;
   358              private Action continuation;
   359              public bool IsCompleted => asyncOp.isDone;
   360  
   361              public AgonesAsyncOperationAwaiter(AgonesAsyncOperationWrapper wrapper)
   362              {
   363                  asyncOp = wrapper.AsyncOp;
   364                  asyncOp.completed += OnRequestCompleted;
   365              }
   366  
   367              // C# Awaiter Pattern requires that the GetAwaiter method has GetResult(),
   368              // And AgonesAsyncOperationAwaiter does not return a value in this case.
   369              public void GetResult()
   370              {
   371                  asyncOp.completed -= OnRequestCompleted;
   372              }
   373  
   374              public void OnCompleted(Action continuation)
   375              {
   376                  this.continuation = continuation;
   377              }
   378  
   379              private void OnRequestCompleted(AsyncOperation _)
   380              {
   381                  continuation?.Invoke();
   382                  continuation = null;
   383              }
   384          }
   385  
   386          /// <summary>
   387          /// Custom UnityWebRequest http data handler
   388          /// that fires a callback whenever it receives data
   389          /// from the SDK.Watch() REST endpoint 
   390          /// </summary>
   391          private class GameServerHandler : DownloadHandlerScript
   392          {
   393              private AgonesSdk sdk;
   394              private StringBuilder stringBuilder;
   395  
   396              public GameServerHandler(AgonesSdk sdk)
   397              {
   398                  this.sdk = sdk;
   399                  this.stringBuilder = new StringBuilder();
   400              }
   401  
   402              protected override bool ReceiveData(byte[] data, int dataLength)
   403              {
   404                  string dataString = Encoding.UTF8.GetString(data);
   405                  this.stringBuilder.Append(dataString);
   406  
   407                  string bufferString = stringBuilder.ToString();
   408                  int newlineIndex;
   409  
   410                  while ((newlineIndex = bufferString.IndexOf('\n')) >= 0)
   411                  {
   412                      string fullLine = bufferString.Substring(0, newlineIndex);
   413                      try
   414                      {
   415                          var dictionary = (Dictionary<string, object>)Json.Deserialize(fullLine);
   416                          var gameServer = new GameServer(dictionary["result"] as Dictionary<string, object>);
   417                          this.sdk.NotifyWatchUpdates(gameServer);
   418                      }
   419                      catch (Exception ignore) { } // Ignore parse errors
   420                      bufferString = bufferString.Substring(newlineIndex + 1);
   421                  }
   422  
   423                  stringBuilder.Clear();
   424                  stringBuilder.Append(bufferString);
   425                  return true;
   426              }
   427  
   428              protected override void CompleteContent()
   429              {
   430                  base.CompleteContent();
   431                  this.sdk.StartWatchingForUpdates();
   432              }
   433          }
   434          #endregion
   435      }
   436  }