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..e136bc6 --- /dev/null +++ b/Runtime/Matchmaking/DTOs/BackfillRequestDTO.cs @@ -0,0 +1,81 @@ +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 BackfillAssignment : DeploymentDTO + { + [JsonProperty("request_id")] + public string RequestID; + } + + public class BackfillAttributes + { + [JsonProperty("assignment")] + public BackfillAssignment Assignment; + + public BackfillAttributes(BackfillAssignment assignment) + { + Assignment = assignment; + } + + public override string ToString() + { + return JsonConvert.SerializeObject(this); + } + } + + public class SimpleBackfillRequestDTO : BackfillRequestDTO + { + public SimpleBackfillRequestDTO() + : base("", null) { } + + 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..b80a00c --- /dev/null +++ b/Runtime/Matchmaking/DTOs/BackfillResponseDTO.cs @@ -0,0 +1,35 @@ +using System; +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; + + [JsonIgnore] + public DateTime? CreatedAt; + +#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..aba2ed5 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; @@ -28,4 +31,15 @@ public override string ToString() return JsonConvert.SerializeObject(this); } } + + public class BackfillAssignedTicket : InjectedTicketDTO + { +#nullable enable + [JsonIgnore] + public DateTime? AssignedAt; + + [JsonIgnore] + public DateTime? JoinedAt; +#nullable disable + } } diff --git a/Runtime/Matchmaking/GroupClient.cs b/Runtime/Matchmaking/GroupClient.cs index eb03343..0ddcc46 100644 --- a/Runtime/Matchmaking/GroupClient.cs +++ b/Runtime/Matchmaking/GroupClient.cs @@ -236,7 +236,7 @@ public void StopMatchmaking(Action onCompletedDelegate = null) { Polling = false; - if (Group.Current is null) + if (Group.Current is null || Group.Current.GroupID is null) { if (onCompletedDelegate is not null) { diff --git a/Runtime/Matchmaking/ServerAgent.cs b/Runtime/Matchmaking/ServerAgent.cs new file mode 100644 index 0000000..9d49863 --- /dev/null +++ b/Runtime/Matchmaking/ServerAgent.cs @@ -0,0 +1,542 @@ +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 ServerAgent + where B : BackfillRequestDTO, new() + { + private Api MatchmakingApi; + + public MonoBehaviour Handler; + public string Profile; + public BackfillAttributes BackfillAttributes; + public int TargetTeamSize; + + // BaseUrl may only be set with constructor + public string BaseUrl { get; } + public string AuthToken { private get; set; } + + public int RequestTimeoutSeconds; + public float PollingBackoffSeconds; + public float ExpirationPeriodSeconds; + public float ConnectionGracePeriodSeconds; + + 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 ServerAgent( + MonoBehaviour handler, + string baseUrl, + string authToken, + string profile, + BackfillAttributes attributes, + int targetTeamSize = -1, + int requestTimeoutSeconds = 3, + float pollingBackoffSeconds = 1f, + float expirationPeriodSeconds = 30f, + float connectionGracePeriodSeconds = 60f, + bool logBackfillUpdates = true, + bool logPollingUpdates = false + ) + { + if (handler == null) + { + throw new Exception("MatchmakingServer Handler not assigned."); + } + + Handler = handler; + + BaseUrl = baseUrl; + AuthToken = authToken; + Profile = profile; + BackfillAttributes = attributes; + TargetTeamSize = targetTeamSize; + + RequestTimeoutSeconds = requestTimeoutSeconds; + PollingBackoffSeconds = pollingBackoffSeconds; + ExpirationPeriodSeconds = expirationPeriodSeconds; + ConnectionGracePeriodSeconds = connectionGracePeriodSeconds; + + LogBackfillUpdates = logBackfillUpdates; + LogPollingUpdates = logPollingUpdates; + } + + public void AbandonPlayer(string ticketID) + { + if (Assignments.Remove(ticketID)) + { + L.Log($"MM | Backfill - ticket removed [{ticketID}]"); + StartNewBackfill(); + } + else + { + L.Warn($"MM | Backfill - ticket abandon failed [{ticketID}]"); + } + } + + public BackfillAssignedTicket PlayerConnected(string ticketID) + { + if (Assignments.ContainsKey(ticketID)) + { + if (Assignments[ticketID].JoinedAt is null) + { + Assignments[ticketID].JoinedAt = DateTime.Now; + } + + return Assignments[ticketID]; + } + else + { + return null; + } + } + + #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 >= TargetTeamSize) + { + Backfills._Error("maximum capacity currently reached"); + return; + } + + MatchmakingApi.CreateBackfill( + backfill, + (BackfillResponseDTO backfillRes, UnityWebRequest request) => + { + backfillRes.CreatedAt = DateTime.Now; + + Dictionary> temp = new Dictionary< + string, + BackfillResponseDTO + >(Backfills.Current); + + temp[backfillRes.ID] = backfillRes; + Backfills._Update(temp, $"created [{backfillRes.ID}]"); + + if (!Polling && Backfills.Current.Count == 1) + { + Polling = true; + Handler.StartCoroutine(DelayMethod(StartPollingBackfills)); + } + }, + (string error, UnityWebRequest request) => + { + Backfills._Error($"backfill create failed\n{error}"); + } + ); + } + + public void AddBackfills() + { + int nbBackfills = TargetTeamSize - (Assignments.Count + Backfills.Current.Count); + + for (int i = 0; i < nbBackfills; ++i) + { + StartNewBackfill(); + } + } + + public void RemoveBackfill(string backfillID, Action onCompletedDelegate = null) + { + if (!Backfills.Current.ContainsKey(backfillID)) + { + Backfills._Notify( + $"delete failed (not found) [{backfillID}]", + ObservableActionType.Warn + ); + return; + } + + MatchmakingApi.DeleteBackfill( + backfillID, + (UnityWebRequest request) => + { + Handler.StartCoroutine( + ExpireBackfill( + backfillID, + ( + bool backfillExpired, + Dictionary> updatedBackfills + ) => + { + OnBackfillExpired( + backfillID, + backfillExpired, + updatedBackfills, + () => + { + Backfills._Update( + updatedBackfills, + $"abandoned [{backfillID}]" + ); + }, + onCompletedDelegate + ); + } + ) + ); + }, + (string error, UnityWebRequest request) => + { + Handler.StartCoroutine( + ExpireBackfill( + backfillID, + ( + bool backfillExpired, + Dictionary> updatedBackfills + ) => + { + OnBackfillExpired( + backfillID, + backfillExpired, + updatedBackfills, + () => + { + if (request.responseCode == 404) + { + Backfills._Notify( + $"abandon failed (not found) [{backfillID}]", + ObservableActionType.Warn + ); + } + else + { + Backfills._Error( + $"abandon failed [{backfillID}]\n{error}", + updatedBackfills + ); + } + }, + onCompletedDelegate + ); + } + ) + ); + } + ); + } + + public void RemoveAllBackfills(Action onCompletedDelegate = null) + { + Polling = false; + Dictionary> temp = new Dictionary< + string, + BackfillResponseDTO + >(Backfills.Current); + + foreach (KeyValuePair> b in temp) + { + Handler.StartCoroutine( + ExpireBackfill( + b.Key, + ( + bool backfillExpired, + Dictionary> updatedBackfills + ) => + { + OnBackfillExpired( + b.Key, + backfillExpired, + updatedBackfills, + () => + { + Backfills._Update(updatedBackfills, $"abandoned [{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); + + foreach (InjectedTicketDTO t in tickets.Values) + { + AddAssignment(t); + } + + Status(); + } + #endregion + + #region Internals + + internal IEnumerator ExpireBackfill( + string backfillID, + Action>> onCompletedDelegate, + float delaySeconds = 0f + ) + { + yield return new WaitForSeconds(delaySeconds); + + Dictionary> temp = new Dictionary< + string, + BackfillResponseDTO + >(Backfills.Current); + + onCompletedDelegate(temp.Remove(backfillID), temp); + } + + internal void OnBackfillExpired( + string backfillID, + bool backfillExpired, + Dictionary> updatedBackfills, + Action onExpiredSuccess, + Action onCompletedDelegate = null + ) + { + if (backfillExpired) + { + onExpiredSuccess(); + } + else + { + Backfills._Notify($"expiration failed [{backfillID}]", ObservableActionType.Warn); + } + + if (onCompletedDelegate is not null) + { + onCompletedDelegate(); + } + } + + internal void AddAssignment(InjectedTicketDTO ticket) + { + BackfillAssignedTicket assignment = new BackfillAssignedTicket() + { + ID = ticket.ID, + CreatedAt = ticket.CreatedAt, + PlayerIP = ticket.PlayerIP, + GroupID = ticket.GroupID, + Attributes = ticket.Attributes, + AssignedAt = DateTime.Now, + }; + + Assignments[assignment.ID] = assignment; + Handler.StartCoroutine(DelayMethod(() => CheckTicketConnection(assignment.ID))); + } + + internal void StartNewBackfill() + { + B newBackfill = new B() + { + Profile = Profile, + Attributes = BackfillAttributes, + Tickets = Assignments, + }; + + AddBackfill(newBackfill); + } + + internal void StartPollingBackfills() + { + if (!Polling) + { + if (LogPollingUpdates) + { + Backfills._Notify($"polling stopped"); + } + return; + } + + foreach (BackfillResponseDTO b in Backfills.Current.Values) + { + MatchmakingApi.GetBackfill( + b.ID, + (BackfillResponseDTO backfill, UnityWebRequest request) => + { + if (backfill.Status == "ASSIGNED") + { + AddAssignment(backfill.AssignedTicket); + + Handler.StartCoroutine( + ExpireBackfill( + b.ID, + ( + bool backfillExpired, + Dictionary> updatedBackfills + ) => + { + OnBackfillExpired( + b.ID, + backfillExpired, + updatedBackfills, + () => + { + Backfills._Update( + updatedBackfills, + $"assigned [{b.ID}]" + ); + } + ); + } + ) + ); + } + }, + (string error, UnityWebRequest request) => + { + if ((DateTime.Now - b.CreatedAt)?.TotalSeconds >= ExpirationPeriodSeconds) + { + Backfills._Notify( + $"backfill expiration period exceeded [{b.ID}]", + ObservableActionType.Warn + ); + RemoveBackfill(b.ID, StartNewBackfill); + } + else if (request.responseCode == 404) + { + Backfills._Notify( + $"polling failed (not found) [{b.ID}]", + ObservableActionType.Warn + ); + + Handler.StartCoroutine( + ExpireBackfill( + b.ID, + ( + bool backfillExpired, + Dictionary> updatedBackfills + ) => + { + OnBackfillExpired( + b.ID, + backfillExpired, + updatedBackfills, + () => + { + Backfills._Update( + updatedBackfills, + $"abandoned [{b.ID}]" + ); + }, + StartNewBackfill + ); + } + ) + ); + } + else if (request.responseCode != 429 && request.responseCode < 500) + { + Backfills._Error($"polling failed [{b.ID}]\n{error}"); + } + } + ); + } + + Handler.StartCoroutine(DelayMethod(StartPollingBackfills)); + } + + internal void CheckTicketConnection(string ticketID) + { + double? timeSinceAssigned = ( + DateTime.Now - Assignments[ticketID].AssignedAt + )?.TotalSeconds; + + if (timeSinceAssigned >= ConnectionGracePeriodSeconds) + { + L.Log($"MM | Backfill - connection grace period expired [{ticketID}]"); + AbandonPlayer(ticketID); + } + else if (Assignments[ticketID].JoinedAt is null) + { + Handler.StartCoroutine(DelayMethod(() => CheckTicketConnection(ticketID))); + } + } + + internal IEnumerator DelayMethod(Action onDelayFinished) + { + yield return new WaitForSeconds(PollingBackoffSeconds + (0.1f * Random.value)); + onDelayFinished(); + } + #endregion + } +} diff --git a/Runtime/Matchmaking/ServerAgent.cs.meta b/Runtime/Matchmaking/ServerAgent.cs.meta new file mode 100644 index 0000000..86e412c --- /dev/null +++ b/Runtime/Matchmaking/ServerAgent.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9f3574739e980ef498f789e433eb64ea \ No newline at end of file diff --git a/Samples~/MatchmakingBackfill/BackfillClientHandlerExample.cs b/Samples~/MatchmakingBackfill/BackfillClientHandlerExample.cs new file mode 100644 index 0000000..0f70846 --- /dev/null +++ b/Samples~/MatchmakingBackfill/BackfillClientHandlerExample.cs @@ -0,0 +1,184 @@ +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; + + public void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(this); + } + else + { + Instance = this; + } + } + + void Start() + { + if (Application.isBatchMode) + { + L.Log("MM ClientHandler | Destroying self in server environment."); + Destroy(this.gameObject); + } + else + { + MatchmakingClient = new GroupClient< + MyGroupUpRequestDTO, + MyTicketsAttributes + >( + this, + BaseUrl, + AuthToken, + RequestTimeoutSeconds, + PollingBackoffSeconds, + MaxConsecutivePollingErrors, + RemoveAssignmentSeconds, + LogGroupUpdates, + LogPollingUpdates + ); + + MatchmakingClient.Initialize( + ( + 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(); + } + } + }, + ( + 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 & send ticketID to server during connection + 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(); + } + + public void StartMatchmaking(Dictionary pings, bool isReady) + { + MatchmakingClient.CreateGroup(new MyGroupUpRequestDTO(pings, BackfillGroupSize, isReady), true); + } + + public void StopMatchmaking() + { + if (enabled) + { + MatchmakingClient.StopMatchmaking(); + } + } +} diff --git a/Samples~/MatchmakingBackfill/BackfillServerHandlerExample.cs b/Samples~/MatchmakingBackfill/BackfillServerHandlerExample.cs new file mode 100644 index 0000000..1230cb6 --- /dev/null +++ b/Samples~/MatchmakingBackfill/BackfillServerHandlerExample.cs @@ -0,0 +1,271 @@ +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; + + [Header("Expiration and Grace Period")] + public float ExpirationPeriodSeconds = 30f; + public float ConnectionGracePeriodSeconds = 60f; + public float AdmissionGracePeriodSeconds = -1f; + + [Header("Logging")] + public bool LogBackfillUpdates = true; + public bool LogPollingUpdates = false; + #endregion + + public ServerAgent< + MyBackfillRequestDTO, + MyTicketsAttributes + > MatchmakingServer; + + private bool BackfillRunning = false; + private DateTime BackfillStartAt; + private BackfillAttributes BackfillAttributes; + private SafeHttpRequest Request; + + [Header("Environment")] + public bool MockEnv = false; + public DeploymentEnvironmentDTO DeploymentEnvs { get; private set; } + + public MatchEnvironmentDTO MatchEnvs { get; private set; } + + public void Awake() + { + if (Instance != null && Instance != this) + { + Destroy(this); + } + else + { + Instance = this; + } + } + + void Start() + { + IDictionary envs = Environment.GetEnvironmentVariables(); + + #region mock data + +#if UNITY_EDITOR + MockEnv = true; +#endif + + MockEnv = MockEnv || !string.IsNullOrEmpty(envs["ARBITRIUM_MOCK_ENV"]?.ToString()); + + if (MockEnv) + { + // define mock env variables here + envs["MM_MATCH_PROFILE"] = "backfill-example"; + envs["MM_TICKET_IDS"] = "[\"cusfn10msflc73beiik0\",\"cusfn18msflc73beiil0\"]"; + envs["MM_TICKET_cusfn10msflc73beiik0"] = "{\"id\":\"cusfn10msflc73beiik0\",\"created_at\":\"2025-02-21T22:17:42.3886970Z\",\"player_ip\":\"174.93.233.25\",\"group_id\":\"b2080c27-19c9-4fb0-8fe7-4bf1e5d285d1\",\"team_id\":\"cusfn1gmsflc73beiim0\",\"attributes\":{\"beacons\":{\"Chicago\":12.3,\"LosAngeles\":145.6,\"Tokyo\":233.2},\"backfill_group_size\":[\"new\",\"1\"]}}"; + envs["MM_TICKET_cusfn18msflc73beiil0"] = "{\"id\":\"cusfn18msflc73beiil0\",\"created_at\":\"2025-02-21T22:17:42.2548390Z\",\"player_ip\":\"174.93.233.23\",\"group_id\":\"015d4dc8-6c79-4b5c-bbc6-f309b9787c8f\",\"team_id\":\"cusfn1gmsflc73beiim0\",\"attributes\":{\"beacons\":{\"Chicago\":87.3,\"LosAngeles\":32.4,\"Tokyo\":253.2},\"backfill_group_size\":[\"new\",\"1\"]}}"; + envs["MM_GROUPS"] = "{\"b2080c27-19c9-4fb0-8fe7-4bf1e5d285d1\":[\"cusfn10msflc73beiik0\"],\"015d4dc8-6c79-4b5c-bbc6-f309b9787c8f\":[\"cusfn18msflc73beiil0\"]}"; + envs["MM_TEAMS"] = "{\"cusfn1gmsflc73beiim0\":[\"b2080c27-19c9-4fb0-8fe7-4bf1e5d285d1\",\"015d4dc8-6c79-4b5c-bbc6-f309b9787c8f\"]}"; + + envs["ARBITRIUM_REQUEST_ID"] = "editor"; + envs["ARBITRIUM_PUBLIC_IP"] = "localhost"; + envs["ARBITRIUM_DEPLOYMENT_TAGS"] = "tag1,tag2"; + envs["ARBITRIUM_HOST_BASE_CLOCK_FREQUENCY"] = "2000"; + envs["ARBITRIUM_DEPLOYMENT_VCPU_UNITS"] = "1536"; + envs["ARBITRIUM_DEPLOYMENT_MEMORY_MB"] = "3072"; + envs["ARBITRIUM_DEPLOYMENT_LOCATION"] = + "{\"city\":\"Chicago\",\"country\":\"United States of America\",\"continent\":\"North America\",\"administrative_division\":\"Illinois\",\"timezone\":\"Central Time\"}"; + + // todo edit external port value + envs["ARBITRIUM_PORTS_MAPPING"] = + "{\"ports\":{\"gameport\":{\"name\":\"GamePort\",\"internal\":7777,\"external\":31504,\"protocol\":\"UDP\"}}}"; + } + #endregion + + DeploymentEnvs = new DeploymentEnvironmentDTO(envs); + MatchEnvs = new MatchEnvironmentDTO(envs); + BaseUrl ??= envs["MM_BASE_URL"]?.ToString(); + AuthToken ??= envs["MM_AUTH_TOKEN"]?.ToString(); + + BackfillAssignment deployment = new BackfillAssignment() + { + RequestID = DeploymentEnvs.RequestID, + Fqdn = DeploymentEnvs.Fqdn, + PublicIP = DeploymentEnvs.PublicIP, + Ports = DeploymentEnvs.PortMapping, + Location = DeploymentEnvs.Location, + }; + BackfillAttributes = new BackfillAttributes(deployment); + + Request = new SafeHttpRequest(this); + + if (!MockEnv && !Application.isBatchMode) + { + L.Log("MM ServerHandler | Destroying self in client environment."); + Destroy(gameObject); + } + else + { + MatchmakingServer = new ServerAgent< + MyBackfillRequestDTO, + MyTicketsAttributes + >( + this, + BaseUrl, + AuthToken, + MatchEnvs.MatchProfile, + BackfillAttributes, + TargetPlayerCount, + RequestTimeoutSeconds, + PollingBackoffSeconds, + ExpirationPeriodSeconds, + ConnectionGracePeriodSeconds, + LogBackfillUpdates, + LogPollingUpdates + ); + + MatchmakingServer.Initialize( + MatchEnvs.Tickets, + ( + Observable monitor, + ObservableActionType action, + string message + ) => + { + if (message == "healthy") + { + if (!BackfillRunning) + { + BackfillRunning = true; + BackfillStartAt = DateTime.Now; + } + + MatchmakingServer.AddBackfills(); + } + else + { + // todo handle outage/maintenance + L.Error($"Matchmaking error.\n{monitor.Current}"); + StopBackfill(); + } + }, + ( + Observable>> backfills, + ObservableActionType action, + string message + ) => + { + if ( + action == ObservableActionType.Update + && message.Contains("assigned") + ) + { + // todo handling + } + + if (message.Contains("abandon")) + { + // todo handling + } + + if ( + action == ObservableActionType.Update + && message.Contains("create") + ) + { + // todo handling + } + } + ); + + // todo listen for joining players & their ticketID => OnPlayerConnecting + // todo listen for leaving players => OnPlayerLeaving + + L.Log( + $"MM ServerHandler | Started successfully for deployment '{DeploymentEnvs.RequestID}'." + ); + } + } + + void Update() + { + if (BackfillRunning) + { + if (MatchmakingServer.Assignments.Count == 0) + { + StopBackfill(StopServer); + } + else if (AdmissionGracePeriodSeconds > 0 && (DateTime.Now - BackfillStartAt).TotalSeconds >= AdmissionGracePeriodSeconds) + { + StopBackfill(() => + { + // todo extend with custom code to decide if new connections are still accepted + } + ); + } + } + } + + public void OnApplicationQuit() + { + if (!enabled) + return; + StopBackfill(); + } + + 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 } + ); + } + + public void OnPlayerConnecting(string ticketID) + { + BackfillAssignedTicket ticket = MatchmakingServer.PlayerConnected(ticketID); + + // todo if ticket is null, kick/ban/reject connection through netcode-specific methods + // otherwise map the connection with the ticketID + } + + public void OnPlayerLeaving() + { + // todo get ticketID from connection - ticketID mapping => MatchmakingServer.AbandonPlayer(ticketID); + } +}