agones.dev/agones@v1.53.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 }