// Copyright 2019 Google LLC // All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // Modified from https://github.com/googleforgames/agones unity sdk using System; using System.Collections.Generic; using System.Net; using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; using Unity.Cn.Multiverse.Model; using MiniJSON; using UnityEngine; using UnityEngine.Networking; namespace Unity.Cn.Multiverse { /// /// Multiverse SDK for Unity. /// public class MultiverseSdk : MonoBehaviour { /// /// Interval of the server sending a health ping to the Multiverse sidecar. /// [Range(0.01f, 5)] public float healthIntervalSecond = 5.0f; /// /// Whether the server sends a health ping to the Multiverse sidecar. /// public bool healthEnabled = true; /// /// Debug Logging Enabled. Debug logging for development of this Plugin. /// public bool logEnabled = false; private string sidecarAddress; private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource(); private struct KeyValueMessage { public string key; public string value; public KeyValueMessage(string k, string v) => (key, value) = (k, v); } #region Unity Methods // Use this for initialization. private void Awake() { String port = Environment.GetEnvironmentVariable("MULTIVERSE_SDK_HTTP_PORT"); sidecarAddress = "http://localhost:" + (port ?? "9358"); } private void Start() { HealthCheckAsync(); } private void OnApplicationQuit() { cancellationTokenSource.Dispose(); } #endregion #region MultiverseRestClient Public Methods /// /// Async method that waits to connect to the SDK Server. Will timeout /// and return false after 30 seconds. /// /// A task that indicated whether it was successful or not public async Task Connect() { for (var i = 0; i < 30; i++) { Log($"Attempting to connect...{i + 1}"); try { var gameServer = await GameServer(); if (gameServer != null) { Log("Connected!"); return true; } } catch (Exception ex) { Log($"Connection exception: {ex.Message}"); } Log("Connection failed, retrying."); await Task.Delay(1000); } return false; } /// /// Marks this Game Server as ready to receive connections. /// /// /// A task that represents the asynchronous operation and returns true if the request was successful. /// public async Task Ready() { return await SendRequestAsync("/ready", "{}").ContinueWith(task => task.Result.ok); } /// /// Retrieve the GameServer details /// /// The current GameServer configuration public async Task GameServer() { var result = await SendRequestAsync("/gameserver", "{}", UnityWebRequest.kHttpVerbGET); if (!result.ok) { return null; } var data = Json.Deserialize(result.json) as Dictionary; return new GameServer(data); } /// /// Marks this Game Server as ready to shutdown. /// /// /// A task that represents the asynchronous operation and returns true if the request was successful. /// public async Task Shutdown() { return await SendRequestAsync("/shutdown", "{}").ContinueWith(task => task.Result.ok); } /// /// Marks this Game Server as Allocated. /// /// /// A task that represents the asynchronous operation and returns true if the request was successful. /// public async Task Allocate() { return await SendRequestAsync("/allocate", "{}").ContinueWith(task => task.Result.ok); } /// /// Set a metadata label that is stored in k8s. /// /// label key /// label value /// /// A task that represents the asynchronous operation and returns true if the request was successful. /// public async Task SetLabel(string key, string value) { string json = JsonUtility.ToJson(new KeyValueMessage(key, value)); return await SendRequestAsync("/metadata/label", json, UnityWebRequest.kHttpVerbPUT) .ContinueWith(task => task.Result.ok); } /// /// Set a metadata annotation that is stored in k8s. /// /// annotation key /// annotation value /// /// A task that represents the asynchronous operation and returns true if the request was successful. /// public async Task SetAnnotation(string key, string value) { string json = JsonUtility.ToJson(new KeyValueMessage(key, value)); return await SendRequestAsync("/metadata/annotation", json, UnityWebRequest.kHttpVerbPUT) .ContinueWith(task => task.Result.ok); } private struct Duration { public int seconds; public Duration(int seconds) { this.seconds = seconds; } } /// /// Move the GameServer into the Reserved state for the specified Timespan (0 seconds is forever) /// Smallest unit is seconds. /// /// The time span to reserve for /// /// A task that represents the asynchronous operation and returns true if the request was successful /// public async Task Reserve(TimeSpan duration) { string json = JsonUtility.ToJson(new Duration(seconds: duration.Seconds)); return await SendRequestAsync("/reserve", json).ContinueWith(task => task.Result.ok); } /// /// WatchGameServerCallback is the callback that will be executed every time /// a GameServer is changed and WatchGameServer is notified /// /// The GameServer value public delegate void WatchGameServerCallback(GameServer gameServer); /// /// WatchGameServer watches for changes in the backing GameServer configuration. /// /// This callback is executed whenever a GameServer configuration change occurs public void WatchGameServer(WatchGameServerCallback callback) { var req = new UnityWebRequest(sidecarAddress + "/watch/gameserver", UnityWebRequest.kHttpVerbGET); req.downloadHandler = new GameServerHandler(callback); req.SetRequestHeader("Content-Type", "application/json"); req.SendWebRequest(); Log("Multiverse Watch Started"); } #endregion #region MultiverseRestClient Private Methods private async void HealthCheckAsync() { while (healthEnabled) { await Task.Delay(TimeSpan.FromSeconds(healthIntervalSecond)); try { await SendRequestAsync("/health", "{}"); } catch (ObjectDisposedException) { break; } } } /// /// Result of a Async HTTP request /// protected struct AsyncResult { public bool ok; public string json; } protected async Task SendRequestAsync(string api, string json, string method = UnityWebRequest.kHttpVerbPOST) { // To prevent that an async method leaks after destroying this gameObject. cancellationTokenSource.Token.ThrowIfCancellationRequested(); var req = new UnityWebRequest(sidecarAddress + api, method) { uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(json)), downloadHandler = new DownloadHandlerBuffer() }; req.SetRequestHeader("Content-Type", "application/json"); await new MultiverseAsyncOperationWrapper(req.SendWebRequest()); var result = new AsyncResult(); result.ok = req.responseCode == (long) HttpStatusCode.OK; if (result.ok) { result.json = req.downloadHandler.text; Log($"Multiverse SendRequest ok: {api} {req.downloadHandler.text}"); } else { Log($"Multiverse SendRequest failed: {api} {req.error}"); } return result; } private void Log(object message) { if (!logEnabled) { return; } Debug.Log(message); } #endregion #region MultiverseRestClient Nested Classes private class MultiverseAsyncOperationWrapper { public UnityWebRequestAsyncOperation AsyncOp { get; } public MultiverseAsyncOperationWrapper(UnityWebRequestAsyncOperation unityOp) { AsyncOp = unityOp; } public MultiverseAsyncOperationAwaiter GetAwaiter() { return new MultiverseAsyncOperationAwaiter(this); } } private class MultiverseAsyncOperationAwaiter : INotifyCompletion { private UnityWebRequestAsyncOperation asyncOp; private Action continuation; public bool IsCompleted => asyncOp.isDone; public MultiverseAsyncOperationAwaiter(MultiverseAsyncOperationWrapper wrapper) { asyncOp = wrapper.AsyncOp; asyncOp.completed += OnRequestCompleted; } // C# Awaiter Pattern requires that the GetAwaiter method has GetResult(), // And MultiverseAsyncOperationAwaiter does not return a value in this case. public void GetResult() { asyncOp.completed -= OnRequestCompleted; } public void OnCompleted(Action continuation) { this.continuation = continuation; } private void OnRequestCompleted(AsyncOperation _) { continuation?.Invoke(); continuation = null; } } /// /// Custom UnityWebRequest http data handler /// that fires a callback whenever it receives data /// from the SDK.Watch() REST endpoint /// private class GameServerHandler : DownloadHandlerScript { private WatchGameServerCallback callback; public GameServerHandler(WatchGameServerCallback callback) { this.callback = callback; } protected override bool ReceiveData(byte[] data, int dataLength) { string json = Encoding.UTF8.GetString(data); var dictionary = (Dictionary) Json.Deserialize(json); var gameServer = new GameServer(dictionary["result"] as Dictionary); this.callback(gameServer); return true; } } #endregion } }