diff --git a/Runtime/Matchmaking/Api.cs b/Runtime/Matchmaking/Api.cs index 6a67e30..80e3de3 100644 --- a/Runtime/Matchmaking/Api.cs +++ b/Runtime/Matchmaking/Api.cs @@ -19,6 +19,7 @@ public class Api internal string PATH_TICKETS = "tickets"; internal string PATH_GROUP_TICKETS = "group-tickets"; internal string PATH_GROUP_UP = "groups"; + internal string PATH_BACKFILL = "backfills"; public Api(MonoBehaviour parent, string authToken, string baseUrl) { @@ -27,6 +28,8 @@ public Api(MonoBehaviour parent, string authToken, string baseUrl) BaseUrl = baseUrl; } + #region Utility + public void GetMonitor( Action onSuccessDelegate, Action onErrorDelegate @@ -82,7 +85,9 @@ Action onErrorDelegate onErrorDelegate ); } + #endregion + #region Server-to-Server Tickets public void CreateTicketAsync( T ticket, Action onSuccessDelegate, @@ -190,7 +195,9 @@ Action onErrorDelegate onErrorDelegate ); } + #endregion + #region Client Group-Up public void CreateGroup( G group, Action onSuccessDelegate, @@ -381,5 +388,87 @@ Action onErrorDelegate onErrorDelegate ); } + #endregion + + #region Server Backfill + public void CreateBackfill( + B backfill, + Action, UnityWebRequest> onSuccessDelegate, + Action onErrorDelegate + ) + where B : BackfillRequestDTO + { + Request.Post( + $"{BaseUrl}/{PATH_BACKFILL}", + AuthToken, + JsonConvert.SerializeObject(backfill), + (string response, UnityWebRequest request) => + { + try + { + BackfillResponseDTO backfillRes = JsonConvert.DeserializeObject< + BackfillResponseDTO + >(response); + onSuccessDelegate(backfillRes, request); + } + catch (Exception e) + { + L.Error( + $"Couldn't parse backfill, consider updating Matchmaking SDK. {e.Message}" + ); + throw; + } + }, + onErrorDelegate + ); + } + + public void GetBackfill( + string backfillID, + Action, UnityWebRequest> onSuccessDelegate, + Action onErrorDelegate + ) + { + Request.Get( + $"{BaseUrl}/{PATH_BACKFILL}/{backfillID}", + AuthToken, + (string response, UnityWebRequest request) => + { + try + { + BackfillResponseDTO backfillRes = JsonConvert.DeserializeObject< + BackfillResponseDTO + >(response); + onSuccessDelegate(backfillRes, request); + } + catch (Exception e) + { + L.Error( + $"Couldn't parse backfill, consider updating Matchmaking SDK. {e.Message}" + ); + throw; + } + }, + onErrorDelegate + ); + } + + public void DeleteBackfill( + string backfillID, + Action onSuccessDelegate, + Action onErrorDelegate + ) + { + Request.Delete( + $"{BaseUrl}/{PATH_BACKFILL}/{backfillID}", + AuthToken, + (string response, UnityWebRequest request) => + { + onSuccessDelegate(request); + }, + onErrorDelegate + ); + } + #endregion } } diff --git a/Runtime/Matchmaking/DTOs/BackfillRequestDTO.cs b/Runtime/Matchmaking/DTOs/BackfillRequestDTO.cs new file mode 100644 index 0000000..d2193e4 --- /dev/null +++ b/Runtime/Matchmaking/DTOs/BackfillRequestDTO.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Edgegap.Matchmaking +{ + public abstract class BackfillRequestDTO + { + [JsonProperty("profile")] + public string Profile; + + [JsonProperty("attributes")] + public BackfillAttributes Attributes; + + [JsonProperty("tickets")] + public Dictionary> Tickets; + + public BackfillRequestDTO(string profile, BackfillAttributes attributes) + { + Profile = profile; + Attributes = attributes; + } + + public override string ToString() + { + return JsonConvert.SerializeObject(this); + } + } + + public class BackfillAttributes + { + [JsonProperty("assignment")] + public DeploymentDTO Assignment; + + public BackfillAttributes(DeploymentDTO assignment) + { + Assignment = assignment; + } + + public override string ToString() + { + return JsonConvert.SerializeObject(this); + } + } + + public class SimpleBackfillRequestDTO : BackfillRequestDTO + { + public SimpleBackfillRequestDTO( + string profile, + BackfillAttributes attributes, + Dictionary> tickets + ) + : base(profile, attributes) + { + Tickets = tickets; + } + } + + public class BackfillTicketAttributesDTO : LatenciesAttributesDTO + { + [JsonProperty("backfill_group_size")] + public string[] BackfillGroupSize; + + public BackfillTicketAttributesDTO( + Dictionary beacons, + string[] backfillGroupSize + ) + : base(beacons) + { + BackfillGroupSize = backfillGroupSize; + } + } +} diff --git a/Runtime/Matchmaking/DTOs/BackfillRequestDTO.cs.meta b/Runtime/Matchmaking/DTOs/BackfillRequestDTO.cs.meta new file mode 100644 index 0000000..7d1d311 --- /dev/null +++ b/Runtime/Matchmaking/DTOs/BackfillRequestDTO.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d60bff1acfb4f41489d10742e451f007 \ No newline at end of file diff --git a/Runtime/Matchmaking/DTOs/BackfillResponseDTO.cs b/Runtime/Matchmaking/DTOs/BackfillResponseDTO.cs new file mode 100644 index 0000000..2693632 --- /dev/null +++ b/Runtime/Matchmaking/DTOs/BackfillResponseDTO.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace Edgegap.Matchmaking +{ + public class BackfillResponseDTO + { + [JsonProperty("id")] + public string ID; + + [JsonProperty("profile")] + public string Profile; + + [JsonProperty("tickets")] + public Dictionary> Tickets; + + [JsonProperty("status")] + public string Status; + +#nullable enable + [JsonProperty("assigned_ticket")] + public InjectedTicketDTO? AssignedTicket; + +#nullable disable + + public override string ToString() + { + return JsonConvert.SerializeObject(this); + } + } +} diff --git a/Runtime/Matchmaking/DTOs/BackfillResponseDTO.cs.meta b/Runtime/Matchmaking/DTOs/BackfillResponseDTO.cs.meta new file mode 100644 index 0000000..5795b57 --- /dev/null +++ b/Runtime/Matchmaking/DTOs/BackfillResponseDTO.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 16a1338f92fc32845872dd17ca46d5e4 \ No newline at end of file diff --git a/Runtime/Matchmaking/DTOs/GroupUpRequestDTO.cs b/Runtime/Matchmaking/DTOs/GroupUpRequestDTO.cs index 2d3e0cf..70f1228 100644 --- a/Runtime/Matchmaking/DTOs/GroupUpRequestDTO.cs +++ b/Runtime/Matchmaking/DTOs/GroupUpRequestDTO.cs @@ -27,6 +27,19 @@ public SimpleGroupUpRequestDTO( } } + public class BackfillGroupUpRequestDTO : GroupUpRequestDTO + { + public BackfillGroupUpRequestDTO( + Dictionary latencyBeacons, + string[] backfillGroupSize, + bool isReady = false + ) + : base("backfill-example", isReady) + { + Attributes = new BackfillTicketAttributesDTO(latencyBeacons, backfillGroupSize); + } + } + public class AdvancedGroupUpRequestDTO : GroupUpRequestDTO { public AdvancedGroupUpRequestDTO( diff --git a/Runtime/Matchmaking/DTOs/InjectedTicketDTO.cs b/Runtime/Matchmaking/DTOs/InjectedTicketDTO.cs index 1046888..969b7b3 100644 --- a/Runtime/Matchmaking/DTOs/InjectedTicketDTO.cs +++ b/Runtime/Matchmaking/DTOs/InjectedTicketDTO.cs @@ -17,8 +17,11 @@ public class InjectedTicketDTO [JsonProperty("group_id")] public string GroupID; - [JsonProperty("team_id")] - public string TeamID; +#nullable enable + [JsonProperty("team_id", NullValueHandling = NullValueHandling.Ignore)] + public string? TeamID; + +#nullable disable [JsonProperty("attributes")] public A Attributes; diff --git a/Runtime/Matchmaking/Server.cs b/Runtime/Matchmaking/Server.cs new file mode 100644 index 0000000..010ba8a --- /dev/null +++ b/Runtime/Matchmaking/Server.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.Networking; +using Random = UnityEngine.Random; + +namespace Edgegap.Matchmaking +{ + using L = Logger; + + public class Server + where B : BackfillRequestDTO + { + private Api MatchmakingApi; + + public MonoBehaviour Handler; + public int TargetPlayerCount; + + // BaseUrl may only be set with constructor + public string BaseUrl { get; } + public string AuthToken { private get; set; } + + public int RequestTimeoutSeconds; + public float PollingBackoffSeconds; + public int MaxConsecutivePollingErrors; + public float RemoveBackfillSeconds; + + public bool LogBackfillUpdates; + public bool LogPollingUpdates; + + public Observable Monitor { get; private set; } = + new Observable() { }; + + public Observable>> Backfills + { + get; + private set; + } = new Observable>>() { }; + + public Dictionary> Assignments { get; private set; } = + new Dictionary>() { }; + + private protected bool Polling = false; + + public Server( + MonoBehaviour handler, + string baseUrl, + string authToken, + int targetPlayerCount = -1, + int requestTimeoutSeconds = 3, + float pollingBackoffSeconds = 1f, + int maxConsecutivePollingErrors = 10, + bool logBackfillUpdates = true, + bool logPollingUpdates = false + ) + { + if (handler == null) + { + throw new Exception("MatchmakingServer Handler not assigned."); + } + + Handler = handler; + TargetPlayerCount = targetPlayerCount; + + BaseUrl = baseUrl; + AuthToken = authToken; + + RequestTimeoutSeconds = requestTimeoutSeconds; + PollingBackoffSeconds = pollingBackoffSeconds; + MaxConsecutivePollingErrors = maxConsecutivePollingErrors; + + LogBackfillUpdates = logBackfillUpdates; + LogPollingUpdates = logPollingUpdates; + } + + public void RemoveAssignment(string ticketID) + { + L.Log($"Removing assigned ticket {ticketID}"); + Assignments.Remove(ticketID); + } + + #region Server API + + public void Status() + { + MatchmakingApi.GetMonitor( + (MonitorResponseDTO monitor, UnityWebRequest request) => + { + if (monitor.Status.ToLower() == "healthy") + { + Monitor._Update(monitor, "healthy"); + } + else + { + Monitor._Update(monitor, "unhealthy"); + } + }, + (string error, UnityWebRequest request) => + { + Monitor._Error($"get monitor failed (unexpected error)\n{error}", null); + } + ); + } + + public void AddBackfill(B backfill) + { + if (Assignments.Count + Backfills.Current.Count >= TargetPlayerCount) + { + Backfills._Error("maximum capacity currently reached"); + return; + } + + MatchmakingApi.CreateBackfill( + backfill, + (BackfillResponseDTO backfillRes, UnityWebRequest request) => + { + Dictionary> temp = new Dictionary< + string, + BackfillResponseDTO + >(Backfills.Current); + + temp[backfillRes.ID] = backfillRes; + Backfills._Update(temp, $"created:{backfillRes.ID}"); + + Polling = true; + Handler.StartCoroutine(DelayPollingBackfill(backfillRes.ID)); + }, + (string error, UnityWebRequest request) => + { + Backfills._Error($"backfill create failed\n{error}"); + } + ); + } + + public void RemoveBackfill(string backfillID, Action onCompletedDelegate = null) + { + if (!Backfills.Current.ContainsKey(backfillID)) + { + Backfills._Error($"{backfillID} not found"); + return; + } + + MatchmakingApi.DeleteBackfill( + backfillID, + (UnityWebRequest request) => + { + UntrackBackfill( + backfillID, + $"abandoned:{backfillID}", + false, + onCompletedDelegate + ); + }, + (string error, UnityWebRequest request) => + { + if (request.responseCode == 404) + { + UntrackBackfill( + backfillID, + $"{backfillID} abandon failed (not found)", + false, + onCompletedDelegate + ); + } + else + { + UntrackBackfill( + backfillID, + $"{backfillID} abandon failed\n{error}", + true, + onCompletedDelegate + ); + } + } + ); + } + + public void RemoveAllBackfills(Action onCompletedDelegate = null) + { + Polling = false; + Dictionary> temp = new Dictionary< + string, + BackfillResponseDTO + >(Backfills.Current); + + foreach (KeyValuePair> b in temp) + { + Handler.StartCoroutine( + RemoveBackfillRoutine( + b.Key, + () => + { + if (onCompletedDelegate is not null && Backfills.Current.Count == 0) + { + onCompletedDelegate(); + } + } + ) + ); + } + } + #endregion + + #region Initialization + public void Initialize( + Dictionary> tickets, + UnityAction< + Observable, + ObservableActionType, + string + > onMonitorUpdate, + UnityAction< + Observable>>, + ObservableActionType, + string + > onBackfillUpdate + ) + { + if (string.IsNullOrEmpty(BaseUrl.Trim())) + { + throw new Exception("BaseUrl not declared."); + } + + if (string.IsNullOrEmpty(AuthToken.Trim())) + { + throw new Exception("AuthToken not declared."); + } + + MatchmakingApi = new Api(Handler, AuthToken, BaseUrl); + + L.SubscribeLogger(Monitor, "MM", "Monitor"); + Monitor.Subscribe(onMonitorUpdate); + + L.SubscribeLogger(Backfills, "MM", "Backfill", LogBackfillUpdates); + Backfills.Subscribe(onBackfillUpdate); + Backfills._Update(new Dictionary>(), "initializing"); + + foreach (var t in tickets) + { + AddAssignment(t.Value); + } + + Status(); + } + #endregion + + #region Internals + + internal void UntrackBackfill( + string backfillID, + string msg, + bool error, + Action onCompletedDelegate = null + ) + { + Dictionary> temp = new Dictionary< + string, + BackfillResponseDTO + >(Backfills.Current); + temp.Remove(backfillID); + + if (error) + { + Backfills._Error(msg, temp); + } + else + { + Backfills._Update(temp, msg); + } + + if (onCompletedDelegate is not null) + { + onCompletedDelegate(); + } + } + + internal void AddAssignment(InjectedTicketDTO ticket) + { + Assignments[ticket.ID] = ticket; + } + + internal void StartPollingBackfill(string backfillID, int consecutiveErrors = 0) + { + if (!Polling) + { + if (LogPollingUpdates) + { + Backfills._Notify($"polling {backfillID} stopped"); + } + return; + } + + if (LogPollingUpdates) + { + Backfills._Notify( + $"polling {backfillID} [{consecutiveErrors + 1}/{MaxConsecutivePollingErrors}]" + ); + } + + MatchmakingApi.GetBackfill( + backfillID, + (BackfillResponseDTO backfill, UnityWebRequest request) => + { + if (backfill.Status == "ASSIGNED") + { + AddAssignment(backfill.AssignedTicket); + UntrackBackfill(backfillID, $"backfill {backfillID} assigned", false); + } + else + { + Handler.StartCoroutine(DelayPollingBackfill(backfillID)); + } + }, + (string error, UnityWebRequest request) => + { + if (consecutiveErrors + 1 > MaxConsecutivePollingErrors) + { + Backfills._Error( + $"polling {backfillID} failed, reached maximum retries\n{error}" + ); + RemoveBackfill(backfillID); + } + else + { + if (request.responseCode == 429 || request.responseCode >= 500) + { + Handler.StartCoroutine( + DelayPollingBackfill(backfillID, consecutiveErrors + 1) + ); + } + else + { + Backfills._Error($"polling {backfillID} failed\n{error}"); + RemoveBackfill(backfillID); + } + } + } + ); + } + + internal IEnumerator DelayPollingBackfill(string backfillID, int consecutiveErrors = 0) + { + yield return new WaitForSeconds(PollingBackoffSeconds + (0.1f * Random.value)); + StartPollingBackfill(backfillID, consecutiveErrors); + } + + internal IEnumerator RemoveBackfillRoutine( + string backfillID, + Action onCompletedDelegate = null + ) + { + L.Log($"Removing backfill {backfillID}"); + RemoveBackfill(backfillID, onCompletedDelegate); + yield return null; + } + #endregion + } +} diff --git a/Runtime/Matchmaking/Server.cs.meta b/Runtime/Matchmaking/Server.cs.meta new file mode 100644 index 0000000..86e412c --- /dev/null +++ b/Runtime/Matchmaking/Server.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9f3574739e980ef498f789e433eb64ea \ No newline at end of file diff --git a/Runtime/SharedDTOs/DeploymentDTO.cs b/Runtime/SharedDTOs/DeploymentDTO.cs index 2eef11e..f651282 100644 --- a/Runtime/SharedDTOs/DeploymentDTO.cs +++ b/Runtime/SharedDTOs/DeploymentDTO.cs @@ -17,6 +17,19 @@ public class DeploymentDTO [JsonProperty("location")] public LocationDTO Location; + public DeploymentDTO( + string fqdn, + string publicIP, + Dictionary ports, + LocationDTO location + ) + { + Fqdn = fqdn; + PublicIP = publicIP; + Ports = ports; + Location = location; + } + public override string ToString() { return JsonConvert.SerializeObject(this); diff --git a/Runtime/SharedDTOs/DeploymentEnvironmentDTO.cs b/Runtime/SharedDTOs/DeploymentEnvironmentDTO.cs index 7d56d0f..fb8d7ae 100644 --- a/Runtime/SharedDTOs/DeploymentEnvironmentDTO.cs +++ b/Runtime/SharedDTOs/DeploymentEnvironmentDTO.cs @@ -32,6 +32,8 @@ public class DeploymentEnvironmentDTO public string Fqdn => string.IsNullOrEmpty(RequestID) ? "" : $"{RequestID}.pr.edgegap.net"; + public DeploymentDTO Deployment { get; private set; } + public DeploymentEnvironmentDTO(IDictionary env) { foreach (DictionaryEntry entry in env) @@ -106,6 +108,8 @@ public DeploymentEnvironmentDTO(IDictionary env) { port.Link ??= $"{Fqdn}:{port.External}"; } + + StoreDeploymentDTO(); } public static string TryParseEnvVariableString(DictionaryEntry keyValuePair) @@ -152,6 +156,11 @@ public override string ToString() { return JsonConvert.SerializeObject(this); } + + internal void StoreDeploymentDTO() + { + Deployment = new DeploymentDTO(Fqdn, PublicIP, PortMapping, Location); + } } internal class PortMappingEnvironmentVariable diff --git a/Samples~/MatchmakingBackfill/BackfillClientHandlerExample.cs b/Samples~/MatchmakingBackfill/BackfillClientHandlerExample.cs new file mode 100644 index 0000000..d7ed652 --- /dev/null +++ b/Samples~/MatchmakingBackfill/BackfillClientHandlerExample.cs @@ -0,0 +1,197 @@ +using System.Collections.Generic; +using Edgegap; +using Edgegap.Matchmaking; +using UnityEngine; +using UnityEngine.Networking; +using UnityEngine.UI; +using L = Edgegap.Logger; +using MyGroupUpRequestDTO = Edgegap.Matchmaking.BackfillGroupUpRequestDTO; +using MyTicketsAttributes = Edgegap.Matchmaking.BackfillTicketAttributesDTO; + +// todo replace BackfillTicketAttributesDTO with CustomTicketsAttributes +// todo replace BackfillGroupUpRequestDTO with CustomGroupUpRequestDTO + +public class BackfillClientHandlerExample : MonoBehaviour +{ + public static BackfillClientHandlerExample Instance { get; private set; } + + #region Matchmaking Configuration + + [Header("Matchmaker Instance")] + public string BaseUrl; + public string AuthToken; + public string[] BackfillGroupSize = { "new", "1" }; + + [Header("Exponential Retry")] + public int RequestTimeoutSeconds = 3; + public float PollingBackoffSeconds = 1f; + public int MaxConsecutivePollingErrors = 10; + + [Header("Expiration and Cleanup")] + public float RemoveAssignmentSeconds = 30f; + public bool DeleteGroupOnPause = false; + public bool DeleteGroupOnQuit = true; + + [Header("Logging")] + public bool LogGroupUpdates = true; + public bool LogPollingUpdates = false; + #endregion + + public GroupClient< + MyGroupUpRequestDTO, + MyTicketsAttributes + > MatchmakingClient; + + private string TicketID; + + private void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(this); + } + else + { + Instance = this; + } + } + + // Start is called once before the first execution of Update after the MonoBehaviour is created + void Start() + { + if (Application.isBatchMode) + { + L.Log("MM ClientHandler | Destroying self in server environment."); + Destroy(this.gameObject); + } + else + { + // configure Matchmaking + MatchmakingClient = new GroupClient< + MyGroupUpRequestDTO, + MyTicketsAttributes + >( + this, + BaseUrl, + AuthToken, + RequestTimeoutSeconds, + PollingBackoffSeconds, + MaxConsecutivePollingErrors, + RemoveAssignmentSeconds, + LogGroupUpdates, + LogPollingUpdates + ); + + // initialize Matchmaking + MatchmakingClient.Initialize( + // handle service monitoring + ( + Observable monitor, + ObservableActionType action, + string message + ) => + { + if (action == ObservableActionType.Update) + { + if (message == "healthy") + { + // todo update UI + + MatchmakingClient.Beacons( + (BeaconsResponseDTO beacons) => + { + Debug.Log($"beacons: {beacons}"); + + MatchmakingClient.MeasureBeaconsRoundTripTime( + beacons.Beacons, + (Dictionary pings) => + { + StartMatchmaking(pings, true); + } + ); + }, + (string error, UnityWebRequest request) => + { + // todo handle beacon downtime, create tickets without beacons? + L.Log($"beacon error: {request}"); + } + ); + } + else if (message != "healthy") + { + // todo handle outage/maintenance + L.Error($"Matchmaking error.\n{monitor.Current}"); + MatchmakingClient.StopMatchmaking(); + } + } + }, + // handle group assignment + ( + Observable group, + ObservableActionType action, + string message + ) => + { + if ( + action == ObservableActionType.Update + && ( + message.Contains("created") + || message.Contains("joined") + || message.Contains("updated") + || message.Contains("abandon") + ) + ) + { + // todo update UI + } + + if ( + action == ObservableActionType.Update + && message.Contains("updated") + && group.Current.Status == "HOST_ASSIGNED" + ) + { + // todo join game on pre-defined game port + TicketID = group.Current.TicketID; + L.Log($"joining game: {group.Current.Assignment.Ports["gameport"].Link}"); + } + } + ); + } + } + + public void OnApplicationPause(bool pause) + { + if (!DeleteGroupOnPause || MatchmakingClient.Group.Current is null) + return; + StopMatchmaking(); + } + + public void OnApplicationQuit() + { + if (!DeleteGroupOnQuit) + return; + StopMatchmaking(); + + // todo if connected to server => Disconnect(); + } + + public void StartMatchmaking(Dictionary pings, bool isReady) + { + MatchmakingClient.CreateGroup(new MyGroupUpRequestDTO(pings, BackfillGroupSize, isReady)); + } + + public void StopMatchmaking() + { + if (enabled) + { + MatchmakingClient.StopMatchmaking(); + } + } + + public void Disconnect() + { + // todo notify server with player's ticket ID, then disconnect player once processed + L.Log($"Player {TicketID} leaving game"); + } +} diff --git a/Samples~/MatchmakingBackfill/BackfillServerHandlerExample.cs b/Samples~/MatchmakingBackfill/BackfillServerHandlerExample.cs new file mode 100644 index 0000000..8dd5e47 --- /dev/null +++ b/Samples~/MatchmakingBackfill/BackfillServerHandlerExample.cs @@ -0,0 +1,212 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using Edgegap; +using Edgegap.Matchmaking; +using UnityEngine; +using UnityEngine.Networking; +using L = Edgegap.Logger; +using MyBackfillRequestDTO = Edgegap.Matchmaking.SimpleBackfillRequestDTO; +using MyTicketsAttributes = Edgegap.Matchmaking.BackfillTicketAttributesDTO; + +// todo replace BackfillTicketAttributesDTO with CustomTicketsAttributes +// todo replace SimpleBackfillRequestDTO with CustomBackfillRequestDTO + +public class BackfillServerHandlerExample : MonoBehaviour +{ + public static BackfillServerHandlerExample Instance { get; private set; } + + #region Matchmaking Configuration + + [Header("Matchmaker Instance")] + public string BaseUrl; + public string AuthToken; + public int TargetPlayerCount = -1; + + [Header("Exponential Retry")] + public int RequestTimeoutSeconds = 3; + public float PollingBackoffSeconds = 1f; + public int MaxConsecutivePollingErrors = 10; + + [Header("Expiration and Cleanup")] + public bool DeleteBackfillOnQuit = true; + + [Header("Logging")] + public bool LogBackfillUpdates = true; + public bool LogPollingUpdates = false; + #endregion + + public Server< + MyBackfillRequestDTO, + MyTicketsAttributes + > MatchmakingServer; + + private bool BackfillRunning = false; + private int UnprocessedBackfills = 0; + private BackfillAttributes BackfillAttributes; + private SafeHttpRequest Request; + + public DeploymentEnvironmentDTO DeploymentEnvs { get; private set; } + + public MatchEnvironmentDTO MatchEnvs { get; private set; } + + private void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(this); + } + else + { + Instance = this; + } + } + + // Start is called once before the first execution of Update after the MonoBehaviour is created + void Start() + { + if (!Application.isBatchMode) + { + L.Log("MM ServerHandler | Destroying self in client environment."); + Destroy(this.gameObject); + } + else + { + IDictionary envs = Environment.GetEnvironmentVariables(); + DeploymentEnvs = new DeploymentEnvironmentDTO(envs); + MatchEnvs = new MatchEnvironmentDTO(envs); + + BackfillAttributes = new BackfillAttributes(DeploymentEnvs.Deployment); + Request = new SafeHttpRequest(this); + + MatchmakingServer = new Server< + MyBackfillRequestDTO, + MyTicketsAttributes + >( + this, + BaseUrl, + AuthToken, + TargetPlayerCount, + RequestTimeoutSeconds, + PollingBackoffSeconds, + MaxConsecutivePollingErrors, + LogBackfillUpdates, + LogPollingUpdates + ); + + MatchmakingServer.Initialize( + MatchEnvs.Tickets, + // handle service monitoring + ( + Observable monitor, + ObservableActionType action, + string message + ) => + { + if (action == ObservableActionType.Update) + { + if (message == "healthy") + { + BackfillRunning = true; + } + else if (message != "healthy") + { + // todo handle outage/maintenance + L.Error($"Matchmaking error.\n{monitor.Current}"); + StopBackfill(); + } + } + }, + // handle backfills + ( + Observable>> backfills, + ObservableActionType action, + string message + ) => + { + if ( + action == ObservableActionType.Update + && (message.Contains("assigned") || message.Contains("abandon")) + ) + { + // todo handling + } + + if (message.Contains("create")) + { + --UnprocessedBackfills; + } + } + ); + + // todo listen for leaving players & their ticketID => MatchmakingServer.RemoveAssignment(ticketID); + } + } + + // Update is called once per frame + void Update() + { + if (BackfillRunning && MatchmakingServer.Assignments.Count == 0) + { + StopBackfill(() => + { + StopServer(); + }); + } + + if ( + BackfillRunning + && TargetPlayerCount > 0 + && MatchmakingServer.Assignments.Count + + MatchmakingServer.Backfills.Current.Count + + UnprocessedBackfills + < TargetPlayerCount + ) + { + StartNewBackfill(); + } + } + + public void OnApplicationQuit() + { + if (!DeleteBackfillOnQuit) + return; + StopBackfill(); + } + + public void StartNewBackfill() + { + ++UnprocessedBackfills; + + MyBackfillRequestDTO backfill = new MyBackfillRequestDTO( + MatchEnvs.MatchProfile, + BackfillAttributes, + MatchmakingServer.Assignments + ); + + MatchmakingServer.AddBackfill(backfill); + } + + public void StopBackfill(Action onCompletedDelegate = null) + { + BackfillRunning = false; + MatchmakingServer.RemoveAllBackfills(onCompletedDelegate); + } + + public void StopServer() + { + Request.Delete( + DeploymentEnvs.SelfStopURL, + DeploymentEnvs.SelfStopToken, + (string response, UnityWebRequest request) => + { + L.Log($"MM ServerHandler | Successfully called Self-Stop API.\n{response}"); + }, + (string error, UnityWebRequest request) => + { + L.Error($"MM ServerHandler | Couldn't reach Self-Stop API.\n{error}"); + }, + new RetryParameters { MaxAttempts = 10 } + ); + } +}