diff --git a/sdks/unity/AgonesBetaSdk.cs b/sdks/unity/AgonesBetaSdk.cs new file mode 100644 index 0000000000..d298b29e90 --- /dev/null +++ b/sdks/unity/AgonesBetaSdk.cs @@ -0,0 +1,322 @@ +// Copyright 2022 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. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Agones.Model; +using JetBrains.Annotations; +using MiniJSON; +using UnityEngine; +using UnityEngine.Networking; + +namespace Agones +{ + /// + /// Agones Beta SDK for Unity. + /// + public class AgonesBetaSdk : AgonesSdk + { + #region AgonesRestClient Public Methods + + /// + /// GetCounterCountAsync returns the Count for a Counter, given the Counter's key (name). + /// Always returns 0 if the key was not predefined in the GameServer resource on creation. + /// + /// The counter's count + public async Task GetCounterCount(string key) + { + var result = await SendRequestAsync($"/v1beta1/counters/{key}", "{}", UnityWebRequest.kHttpVerbGET); + if (!result.ok) + { + return 0; + } + + if (Json.Deserialize(result.json) is not Dictionary data + || !data.TryGetValue("count", out object countObject) + || countObject is not string countString + || !long.TryParse(countString, out long count)) + { + return 0; + } + + return count; + } + + private struct CounterUpdateRequest + { + public long countDiff; + } + + /// + /// IncrementCounterAsync increases a counter by the given nonnegative integer amount. + /// Will execute the increment operation against the current CRD value. Will max at max(int64). + /// Throws error if the key was not predefined in the GameServer resource on creation. + /// Throws error if the count is at the current capacity (to the latest knowledge of the SDK), + /// and no increment will occur. + /// + /// Note: A potential race condition here is that if count values are set from both the SDK and + /// through the K8s API (Allocation or otherwise), since the SDK append operation back to the CRD + /// value is batched asynchronous any value incremented past the capacity will be silently truncated. + /// + /// + /// A task that represents the asynchronous operation and returns true if the request was successful. + /// + public async Task IncrementCounter(string key, long amount) + { + if (amount < 0) + { + throw new ArgumentOutOfRangeException($"CountIncrement amount must be a positive number, found {amount}"); + } + + string json = JsonUtility.ToJson(new CounterUpdateRequest {countDiff = amount }); + return await SendRequestAsync($"/v1beta1/counters/{key}", json, "PATCH").ContinueWith(task => task.Result.ok); + } + + /// + /// DecrementCounterAsync decreases the current count by the given nonnegative integer amount. + /// The Counter will not go below 0. Will execute the decrement operation against the current CRD value. + /// Throws error if the count is at 0 (to the latest knowledge of the SDK), and no decrement will occur. + /// + /// + /// A task that represents the asynchronous operation and returns true if the request was successful. + /// + public async Task DecrementCounter(string key, long amount) + { + if (amount < 0) + { + throw new ArgumentOutOfRangeException($"CountIncrement amount must be a positive number, found {amount}"); + } + + string json = JsonUtility.ToJson(new CounterUpdateRequest {countDiff = amount * -1}); + return await SendRequestAsync($"/v1beta1/counters/{key}", json, "PATCH").ContinueWith(task => task.Result.ok); + } + + private struct CounterSetRequest { + public long count; + } + + /// + /// SetCounterCountAsync sets a count to the given value. Use with care, as this will + /// overwrite any previous invocations’ value. Cannot be greater than Capacity. + /// + /// + /// A task that represents the asynchronous operation and returns true if the request was successful. + /// + public async Task SetCounterCount(string key, long amount) + { + string json = JsonUtility.ToJson(new CounterSetRequest {count = amount}); + return await SendRequestAsync($"/v1beta1/counters/{key}", json, "PATCH").ContinueWith(task => task.Result.ok); + } + + /// + /// GetCounterCapacityAsync returns the Capacity for a Counter, given the Counter's key (name). + /// Always returns 0 if the key was not predefined in the GameServer resource on creation. + /// + /// The Counter's capacity + public async Task GetCounterCapacity(string key) + { + var result = await SendRequestAsync($"/v1beta1/counters/{key}", "{}", UnityWebRequest.kHttpVerbGET); + if (!result.ok) + { + return 0; + } + + if (Json.Deserialize(result.json) is not Dictionary data + || !data.TryGetValue("capacity", out object capacityObject) + || capacityObject is not string capacityString + || !long.TryParse(capacityString, out long capacity)) + { + return 0; + } + + return capacity; + } + + private struct CounterSetCapacityRequest { + public long capacity; + } + + /// + /// SetCounterCapacityAsync sets the capacity for the given Counter. + /// A capacity of 0 is no capacity. + /// + /// + /// A task that represents the asynchronous operation and returns true if the request was successful. + /// + public async Task SetCounterCapacity(string key, long amount) + { + string json = JsonUtility.ToJson(new CounterSetCapacityRequest {capacity = amount}); + return await SendRequestAsync($"/v1beta1/counters/{key}", json, "PATCH").ContinueWith(task => task.Result.ok); + } + + /// + /// GetListCapacityAsync returns the Capacity for a List, given the List's key (name). + /// Always returns 0 if the key was not predefined in the GameServer resource on creation. + /// + /// The List's capacity + public async Task GetListCapacity(string key) + { + var result = await SendRequestAsync($"/v1beta1/lists/{key}", "{}", UnityWebRequest.kHttpVerbGET); + if (!result.ok) + { + return 0; + } + + if (Json.Deserialize(result.json) is not Dictionary data + || !data.TryGetValue("capacity", out object capacityObject) + || capacityObject is not string capacityString + || !long.TryParse(capacityString, out long capacity)) + { + return 0; + } + + return capacity; + } + + private struct ListSetCapacityRequest { + public long capacity; + } + + /// + /// SetListCapacityAsync sets the capacity for a given list. Capacity must be between 0 and 1000. + /// Always returns false if the key was not predefined in the GameServer resource on creation. + /// + /// + /// A task that represents the asynchronous operation and returns true if the request was successful. + /// + public async Task SetListCapacity(string key, long amount) + { + string json = JsonUtility.ToJson(new ListSetCapacityRequest { + capacity = amount + }); + return await SendRequestAsync($"/v1beta1/lists/{key}", json, "PATCH").ContinueWith(task => task.Result.ok); + } + + /// + /// ListContainsAsync returns if a string exists in a List's values list, given the List's key + /// and the string value. Search is case-sensitive. + /// Always returns false if the key was not predefined in the GameServer resource on creation. + /// + /// True if the value is found in the List + public async Task ListContains(string key, string value) + { + var result = await SendRequestAsync($"/v1beta1/lists/{key}", "{}", UnityWebRequest.kHttpVerbGET); + + if (!result.ok) + { + return false; + } + + if (Json.Deserialize(result.json) is not Dictionary data + || !data.TryGetValue("values", out object listObject) + || listObject is not List list) + { + return false; + } + + return list.Where(l => l is string).Select(l => l.ToString()).Contains(value); + } + + /// + /// GetListLengthAsync returns the length of the Values list for a List, given the List's key. + /// Always returns 0 if the key was not predefined in the GameServer resource on creation. + /// + /// The length of List's values array + public async Task GetListLength(string key) + { + var result = await SendRequestAsync($"/v1beta1/lists/{key}", "{}", UnityWebRequest.kHttpVerbGET); + + if (!result.ok) + { + return 0; + } + + if (Json.Deserialize(result.json) is not Dictionary data + || !data.TryGetValue("values", out object listObject) + || listObject is not List list) + { + return 0; + } + + return list.Count(); + } + + /// + /// GetListValuesAsync returns the Values for a List, given the List's key (name). + /// Always returns an empty list if the key was not predefined in the GameServer resource on creation. + /// + /// The List's values array + public async Task> GetListValues(string key) + { + var result = await SendRequestAsync($"/v1beta1/lists/{key}", "{}", UnityWebRequest.kHttpVerbGET); + + if (!result.ok) + { + return new List(); + } + + if (Json.Deserialize(result.json) is not Dictionary data + || !data.TryGetValue("values", out object listObject) + || listObject is not List list) + { + return new List(); + } + + return list.Where(l => l is string).Select(l => l.ToString()).ToList(); + } + + private struct ListUpdateValuesRequest + { + public string value; + } + + /// + /// AppendListValueAsync appends a string to a List's values list, given the List's key (name) + /// and the string value. Throws error if the string already exists in the list. + /// Always returns false if the key was not predefined in the GameServer resource on creation. + /// + /// + /// A task that represents the asynchronous operation and returns true if the request was successful. + /// + public async Task AppendListValue(string key, string value) + { + string json = JsonUtility.ToJson(new ListUpdateValuesRequest {value = value}); + return await SendRequestAsync($"/v1beta1/lists/{key}:addValue", json, "POST").ContinueWith(task => task.Result.ok); + } + + /// + /// DeleteListValueAsync removes a string from a List's values list, given the List's key + /// and the string value. Throws error if the string does not exist in the list. + /// Always returns false if the key was not predefined in the GameServer resource on creation. + /// + /// + /// A task that represents the asynchronous operation and returns true if the request was successful. + /// + public async Task DeleteListValue(string key, string value) + { + string json = JsonUtility.ToJson(new ListUpdateValuesRequest {value = value}); + return await SendRequestAsync($"/v1beta1/lists/{key}:removeValue", json, "POST").ContinueWith(task => task.Result.ok); + } + + #endregion + + } +} \ No newline at end of file diff --git a/sdks/unity/AgonesBetaSdk.cs.meta b/sdks/unity/AgonesBetaSdk.cs.meta new file mode 100644 index 0000000000..1cf928006a --- /dev/null +++ b/sdks/unity/AgonesBetaSdk.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: f44128187c9744e8922d81fbb9b102ac +timeCreated: 1722368073 \ No newline at end of file diff --git a/sdks/unity/AgonesSdk.cs b/sdks/unity/AgonesSdk.cs index 7e29a896df..e202110823 100644 --- a/sdks/unity/AgonesSdk.cs +++ b/sdks/unity/AgonesSdk.cs @@ -15,6 +15,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Runtime.CompilerServices; using System.Text; @@ -61,6 +62,9 @@ private struct KeyValueMessage public KeyValueMessage(string k, string v) => (key, value) = (k, v); } + private List watchCallbacks = new List(); + private bool watchingForUpdates = false; + #region Unity Methods // Use this for initialization. private void Awake() @@ -223,22 +227,44 @@ public async Task Reserve(TimeSpan duration) /// /// 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) + { + this.watchCallbacks.Add(callback); + if (!this.watchingForUpdates) + { + StartWatchingForUpdates(); + } + } + #endregion + + #region AgonesRestClient Private Methods + + private void NotifyWatchUpdates(GameServer gs) + { + this.watchCallbacks.ForEach((callback) => + { + try + { + callback(gs); + } + catch (Exception ignore) { } // Ignore callback exceptions + }); + } + + private void StartWatchingForUpdates() { var req = new UnityWebRequest(sidecarAddress + "/watch/gameserver", UnityWebRequest.kHttpVerbGET); - req.downloadHandler = new GameServerHandler(callback); + req.downloadHandler = new GameServerHandler(this); req.SetRequestHeader("Content-Type", "application/json"); req.SendWebRequest(); + this.watchingForUpdates = true; Log("Agones Watch Started"); } - #endregion - - #region AgonesRestClient Private Methods private async void HealthCheckAsync() { @@ -283,16 +309,16 @@ public async Task SendRequestAsync(string api, string json, var result = new AsyncResult(); - result.ok = req.responseCode == (long) HttpStatusCode.OK; + result.ok = req.responseCode == (long)HttpStatusCode.OK; if (result.ok) { result.json = req.downloadHandler.text; - Log($"Agones SendRequest ok: {api} {req.downloadHandler.text}"); + Log($"Agones SendRequest ok: {method} {api} {json} {req.downloadHandler.text}"); } else { - Log($"Agones SendRequest failed: {api} {req.error}"); + Log($"Agones SendRequest failed: {method} {api} {json} {req.error}"); } req.Dispose(); @@ -364,12 +390,12 @@ private void OnRequestCompleted(AsyncOperation _) /// private class GameServerHandler : DownloadHandlerScript { - private WatchGameServerCallback callback; + private AgonesSdk sdk; private StringBuilder stringBuilder; - public GameServerHandler(WatchGameServerCallback callback) + public GameServerHandler(AgonesSdk sdk) { - this.callback = callback; + this.sdk = sdk; this.stringBuilder = new StringBuilder(); } @@ -386,11 +412,11 @@ protected override bool ReceiveData(byte[] data, int dataLength) string fullLine = bufferString.Substring(0, newlineIndex); try { - var dictionary = (Dictionary) Json.Deserialize(fullLine); + var dictionary = (Dictionary)Json.Deserialize(fullLine); var gameServer = new GameServer(dictionary["result"] as Dictionary); - this.callback(gameServer); + this.sdk.NotifyWatchUpdates(gameServer); } - catch (Exception ignore) {} // Ignore parse errors + catch (Exception ignore) { } // Ignore parse errors bufferString = bufferString.Substring(newlineIndex + 1); } @@ -398,6 +424,12 @@ protected override bool ReceiveData(byte[] data, int dataLength) stringBuilder.Append(bufferString); return true; } + + protected override void CompleteContent() + { + base.CompleteContent(); + this.sdk.StartWatchingForUpdates(); + } } #endregion } diff --git a/sdks/unity/Tests/Runtime/PlayMode/AgonesSdkIntegrationTests.cs b/sdks/unity/Tests/Runtime/PlayMode/AgonesSdkIntegrationTests.cs new file mode 100644 index 0000000000..3f89a7e6c8 --- /dev/null +++ b/sdks/unity/Tests/Runtime/PlayMode/AgonesSdkIntegrationTests.cs @@ -0,0 +1,164 @@ +// Copyright 2024 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. + +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; +using Agones; +using System.Threading.Tasks; +using System; + +namespace Tests.Runtime.Agones +{ + public class AgonesSdkIntegrationTests + { + + private AgonesSdk sdk; + private AgonesBetaSdk betaSdk; + private AgonesAlphaSdk alphaSdk; + + [UnitySetUp] + public IEnumerator UnitySetUp() + { + GameObject gameObject = new GameObject(); + yield return null; + + this.sdk = gameObject.AddComponent(); + this.betaSdk = gameObject.AddComponent(); + this.alphaSdk = gameObject.AddComponent(); + + Assert.IsNotNull(this.sdk); + Assert.IsNotNull(this.betaSdk); + Assert.IsNotNull(this.alphaSdk); + } + + [UnityTest] + public IEnumerator TestSdk() + { + var task = RunSdkTests(); + yield return new WaitUntil(() => task.IsCompleted); + + if (task.Exception != null) + { + Debug.LogError(task.Exception); + Assert.Fail(); + } + } + + private async Task RunSdkTests() + { + var connected = await this.sdk.Connect(); + Assert.IsTrue(connected); + + bool hasReceivedUpdates = false; + this.sdk.WatchGameServer((gs) => + { + hasReceivedUpdates = true; + Assert.NotNull(gs.ObjectMeta); + Assert.NotNull(gs.Status); + Assert.NotNull(gs.Spec); + }); + + // Run tests + var ready = await this.sdk.Ready(); + Assert.IsTrue(ready); + + var setLabel = await this.sdk.SetLabel("label", "test_label"); + Assert.IsTrue(setLabel); + + var setAnnotation = await this.sdk.SetAnnotation("annotation", "test_annotation"); + Assert.IsTrue(setAnnotation); + + var reserved = await this.sdk.Reserve(TimeSpan.FromSeconds(5)); + Assert.IsTrue(reserved); + + await Task.Delay(1000); + + var allocated = await this.sdk.Allocate(); + Assert.IsTrue(allocated); + + // Run beta tests + await this.RunBetaSdkTests(); + + Assert.IsTrue(hasReceivedUpdates); + + // Shutdown + var shutdown = await this.sdk.Shutdown(); + Assert.IsTrue(shutdown); + } + + private async Task RunBetaSdkTests() + { + // LocalSDKServer starting "rooms": {Count: 1, Capacity: 10} + // Counters + string counter = "rooms"; + + var countSet = await this.betaSdk.SetCounterCount(counter, 4); + Assert.IsTrue(countSet); + + var counterValue = await this.betaSdk.GetCounterCount(counter); + Assert.AreEqual(4, counterValue); + + var incremented = await this.betaSdk.IncrementCounter(counter, 2); + Assert.IsTrue(incremented); + + var incrementedValue = await this.betaSdk.GetCounterCount(counter); + Assert.AreEqual(6, incrementedValue); + + var decremented = await this.betaSdk.DecrementCounter(counter, 1); + Assert.IsTrue(decremented); + + var decrementedValue = await this.betaSdk.GetCounterCount(counter); + Assert.AreEqual(5, decrementedValue); + + var setCounterCapacity = await this.betaSdk.SetCounterCapacity(counter, 123); + Assert.IsTrue(setCounterCapacity); + + var counterCapacity = await this.betaSdk.GetCounterCapacity(counter); + Assert.AreEqual(123, counterCapacity); + + // LocalSDKServer starting "players": {Values: []string{"test0", "test1", "test2"}, Capacity: 100}} + // Lists + string list = "players"; + + var listSet = await this.betaSdk.AppendListValue(list, "test123"); + Assert.IsTrue(listSet); + + var listValues = await this.betaSdk.GetListValues(list); + Assert.NotNull(listValues); + Assert.AreEqual(4, listValues.Count); + Assert.AreEqual("test123", listValues[3]); + + var listSize = await this.betaSdk.GetListLength(list); + Assert.AreEqual(4, listSize); + + var setCapacity = await this.betaSdk.SetListCapacity(list, 25); + Assert.IsTrue(setCapacity); + + var capacity = await this.betaSdk.GetListCapacity(list); + Assert.AreEqual(25, capacity); + + var removedValue = await this.betaSdk.DeleteListValue(list, "test123"); + Assert.IsTrue(removedValue); + + var removedValue2 = await this.betaSdk.DeleteListValue(list, "test0"); + Assert.IsTrue(removedValue2); + + var newSize = await this.betaSdk.GetListLength(list); + Assert.AreEqual(2, newSize); + } + } +} diff --git a/sdks/unity/Tests/Runtime/PlayMode/AgonesSdkIntegrationTests.cs.meta b/sdks/unity/Tests/Runtime/PlayMode/AgonesSdkIntegrationTests.cs.meta new file mode 100644 index 0000000000..a97d458a1e --- /dev/null +++ b/sdks/unity/Tests/Runtime/PlayMode/AgonesSdkIntegrationTests.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: c51c083eaa644aca9303670d38012ae2 +timeCreated: 1722368169 \ No newline at end of file diff --git a/test/sdk/unity/Packages/manifest.json b/test/sdk/unity/Packages/manifest.json index b42908cac6..b02183c813 100644 --- a/test/sdk/unity/Packages/manifest.json +++ b/test/sdk/unity/Packages/manifest.json @@ -2,6 +2,7 @@ "dependencies": { "com.googleforgames.agones": "file:../../../../sdks/unity", "com.unity.ide.rider": "3.0.31", + "com.unity.ide.visualstudio": "2.0.22", "com.unity.ide.vscode": "1.2.5", "com.unity.test-framework": "1.1.33", "com.unity.toolchain.macos-arm64-linux-x86_64": "2.0.3", diff --git a/test/sdk/unity/Packages/packages-lock.json b/test/sdk/unity/Packages/packages-lock.json index 0a8bc2c37a..7a8f91bfe6 100644 --- a/test/sdk/unity/Packages/packages-lock.json +++ b/test/sdk/unity/Packages/packages-lock.json @@ -22,6 +22,15 @@ }, "url": "https://packages.unity.com" }, + "com.unity.ide.visualstudio": { + "version": "2.0.22", + "depth": 0, + "source": "registry", + "dependencies": { + "com.unity.test-framework": "1.1.9" + }, + "url": "https://packages.unity.com" + }, "com.unity.ide.vscode": { "version": "1.2.5", "depth": 0, diff --git a/test/sdk/unity/README.md b/test/sdk/unity/README.md index f0e6b7f253..92b108bbb0 100644 --- a/test/sdk/unity/README.md +++ b/test/sdk/unity/README.md @@ -1,5 +1,17 @@ # Agones Unity Test Project + This project is intended to be used for developing the Agones Unity SDK. ## Add Agones Unity Test Project to Unity Hub + Contained within this folder is a Unity project. After opening the Unity Hub application find the "Add" button. In the file path dialog, select this directory. You will then be able to use the Unity Editor to run Agones Unity SDK tests! + +# Running Tests + +This project implements basic tests for the Unity SDK using the [Unity Test Framework](https://docs.unity3d.com/Packages/com.unity.test-framework@1.1/manual/index.html). These tests are PlayMode tests. + +To run these tests, open the Test Framework window in the Unity editor using Window > General > Test Framwork. More information [in the Unity guide](https://docs.unity3d.com/Packages/com.unity.test-framework@1.1/manual/workflow-run-test.html). + +# Connecting to the Agones SDK Server + +These tests require a local SDK server to connect to. The easiest way to run a server is by [running it locally](https://agones.dev/site/docs/guides/client-sdks/local/#running-the-sdk-server). The tests expect the CountsAndLists flag to be enabled with a list called "players" and a counter called "rooms".