using LobbyRelaySample.lobby; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; using Unity.Services.Authentication; using Unity.Services.Lobbies; using Unity.Services.Lobbies.Models; using UnityEngine; namespace LobbyRelaySample { /// /// An abstraction layer between the direct calls into the Lobby API and the outcomes you actually want. E.g. you can request to get a readable list of /// current lobbies and not need to make the query call directly. /// public class LobbyAsyncRequests : IDisposable { // Just doing a singleton since static access is all that's really necessary but we also need to be able to subscribe to the slow update loop. private static LobbyAsyncRequests s_instance; private const int k_maxLobbiesToShow = 16; // If more are necessary, consider retrieving paginated results or using filters. public static LobbyAsyncRequests Instance { get { if (s_instance == null) s_instance = new LobbyAsyncRequests(); return s_instance; } } public Action onLobbyUpdated; //Once connected to a lobby, cache the local lobby object so we don't query for it for every lobby operation. // (This assumes that the player will be actively in just one lobby at a time, though they could passively be in more.) Lobby m_RemoteLobby; #region Lobby API calls are rate limited, and some other operations might want an alert when the rate limits have passed. // Note that some APIs limit to 1 call per N seconds, while others limit to M calls per N seconds. We'll treat all APIs as though they limited to 1 call per N seconds. // Also, this is seralized, so don't reorder the values unless you know what that will affect. public enum RequestType { Query = 0, Join, QuickJoin, Host } public RateLimitCooldown GetRateLimit(RequestType type) { if (type == RequestType.Join) return m_rateLimitJoin; else if (type == RequestType.QuickJoin) return m_rateLimitQuickJoin; else if (type == RequestType.Host) return m_rateLimitHost; return m_rateLimitQuery; } private RateLimitCooldown m_rateLimitQuery = new RateLimitCooldown(1.5f); // Used for both the lobby list UI and the in-lobby updating. In the latter case, updates can be cached. private RateLimitCooldown m_rateLimitJoin = new RateLimitCooldown(3f); private RateLimitCooldown m_rateLimitQuickJoin = new RateLimitCooldown(10f); private RateLimitCooldown m_rateLimitHost = new RateLimitCooldown(3f); #endregion private static Dictionary CreateInitialPlayerData(LobbyUser player) { Dictionary data = new Dictionary(); PlayerDataObject dataObjName = new PlayerDataObject(PlayerDataObject.VisibilityOptions.Member, player.DisplayName); data.Add("DisplayName", dataObjName); return data; } //TODO Back to Polling i Guess /// /// Attempt to create a new lobby and then join it. /// public async Task CreateLobbyAsync(string lobbyName, int maxPlayers, bool isPrivate, LobbyUser localUser) { if (m_rateLimitHost.IsInCooldown) { UnityEngine.Debug.LogWarning("Create Lobby hit the rate limit."); return null; } try { string uasId = AuthenticationService.Instance.PlayerId; CreateLobbyOptions createOptions = new CreateLobbyOptions { IsPrivate = isPrivate, Player = new Player(id: uasId, data: CreateInitialPlayerData(localUser)) }; var lobby = await LobbyService.Instance.CreateLobbyAsync(lobbyName, maxPlayers, createOptions); #pragma warning disable 4014 LobbyHeartBeatLoop(); #pragma warning restore 4014 JoinLobby(lobby); return lobby; } catch (Exception ex) { Debug.LogError($"Lobby Create failed:\n{ex}"); return null; } } public async Task GetLobbyAsync(string lobbyId) { await m_rateLimitQuery.WaitUntilCooldown(); return await LobbyService.Instance.GetLobbyAsync(lobbyId); } /// /// Attempt to join an existing lobby. Either ID xor code can be null. /// public async Task JoinLobbyAsync(string lobbyId, string lobbyCode, LobbyUser localUser) { if (m_rateLimitJoin.IsInCooldown || (lobbyId == null && lobbyCode == null)) { return null; } string uasId = AuthenticationService.Instance.PlayerId; Lobby joinedLobby = null; var playerData = CreateInitialPlayerData(localUser); if (!string.IsNullOrEmpty(lobbyId)) { JoinLobbyByIdOptions joinOptions = new JoinLobbyByIdOptions { Player = new Player(id: uasId, data: playerData) }; joinedLobby = await LobbyService.Instance.JoinLobbyByIdAsync(lobbyId, joinOptions); } else { JoinLobbyByCodeOptions joinOptions = new JoinLobbyByCodeOptions { Player = new Player(id: uasId, data: playerData) }; joinedLobby = await LobbyService.Instance.JoinLobbyByCodeAsync(lobbyCode, joinOptions); } JoinLobby(joinedLobby); return joinedLobby; } /// /// Attempt to join the first lobby among the available lobbies that match the filtered limitToColor. /// public async Task QuickJoinLobbyAsync(LobbyUser localUser, LobbyColor limitToColor = LobbyColor.None) { if (m_rateLimitQuickJoin.IsInCooldown) { UnityEngine.Debug.LogWarning("Quick Join Lobby hit the rate limit."); return null; } var filters = LobbyColorToFilters(limitToColor); string uasId = AuthenticationService.Instance.PlayerId; var joinRequest = new QuickJoinLobbyOptions { Filter = filters, Player = new Player(id: uasId, data: CreateInitialPlayerData(localUser)) }; var lobby = await LobbyService.Instance.QuickJoinLobbyAsync(joinRequest); JoinLobby(lobby); return lobby; } void JoinLobby(Lobby response) { m_RemoteLobby = response; } /// /// Used for getting the list of all active lobbies, without needing full info for each. /// /// If called with null, retrieval was unsuccessful. Else, this will be given a list of contents to display, as pairs of a lobby code and a display string for that lobby. public async Task RetrieveLobbyListAsync(LobbyColor limitToColor = LobbyColor.None) { await m_rateLimitQuery.WaitUntilCooldown(); Debug.Log("Retrieving Lobby List"); var filters = LobbyColorToFilters(limitToColor); QueryLobbiesOptions queryOptions = new QueryLobbiesOptions { Count = k_maxLobbiesToShow, Filters = filters }; return await LobbyService.Instance.QueryLobbiesAsync(queryOptions); } private List LobbyColorToFilters(LobbyColor limitToColor) { List filters = new List(); if (limitToColor == LobbyColor.Orange) filters.Add(new QueryFilter(QueryFilter.FieldOptions.N1, ((int)LobbyColor.Orange).ToString(), QueryFilter.OpOptions.EQ)); else if (limitToColor == LobbyColor.Green) filters.Add(new QueryFilter(QueryFilter.FieldOptions.N1, ((int)LobbyColor.Green).ToString(), QueryFilter.OpOptions.EQ)); else if (limitToColor == LobbyColor.Blue) filters.Add(new QueryFilter(QueryFilter.FieldOptions.N1, ((int)LobbyColor.Blue).ToString(), QueryFilter.OpOptions.EQ)); return filters; } /// /// Attempt to leave a lobby, and then delete it if no players remain. /// /// Called once the request completes, regardless of success or failure. public async Task LeaveLobbyAsync(string lobbyId) { string uasId = AuthenticationService.Instance.PlayerId; await LobbyService.Instance.RemovePlayerAsync(lobbyId, uasId); m_RemoteLobby = null; // Lobbies will automatically delete the lobby if unoccupied, so we don't need to take further action. } /// Key-value pairs, which will overwrite any existing data for these keys. Presumed to be available to all lobby members but not publicly. public async Task UpdatePlayerDataAsync(Dictionary data) { await m_rateLimitQuery.WaitUntilCooldown(); string playerId = Locator.Get.Identity.GetSubIdentity(Auth.IIdentityType.Auth).GetContent("id"); Dictionary dataCurr = new Dictionary(); foreach (var dataNew in data) { PlayerDataObject dataObj = new PlayerDataObject(visibility: PlayerDataObject.VisibilityOptions.Member, value: dataNew.Value); if (dataCurr.ContainsKey(dataNew.Key)) dataCurr[dataNew.Key] = dataObj; else dataCurr.Add(dataNew.Key, dataObj); } UpdatePlayerOptions updateOptions = new UpdatePlayerOptions { Data = dataCurr, AllocationId = null, ConnectionInfo = null }; await LobbyService.Instance.UpdatePlayerAsync(m_RemoteLobby.Id, playerId, updateOptions); } /// /// Lobby can be provided info about Relay (or any other remote allocation) so it can add automatic disconnect handling. /// public async Task UpdatePlayerRelayInfoAsync(string allocationId, string connectionInfo) { await m_rateLimitQuery.WaitUntilCooldown(); await AwaitRemoteLobby(); string playerId = Locator.Get.Identity.GetSubIdentity(Auth.IIdentityType.Auth).GetContent("id"); UpdatePlayerOptions updateOptions = new UpdatePlayerOptions { Data = new Dictionary(), AllocationId = allocationId, ConnectionInfo = connectionInfo }; await LobbyService.Instance.UpdatePlayerAsync(m_RemoteLobby.Id, playerId, updateOptions); } /// Key-value pairs, which will overwrite any existing data for these keys. Presumed to be available to all lobby members but not publicly. public async Task UpdateLobbyDataAsync(Dictionary data) { await m_rateLimitQuery.WaitUntilCooldown(); Dictionary dataCurr = m_RemoteLobby.Data ?? new Dictionary(); var shouldLock = false; foreach (var dataNew in data) { // Special case: We want to be able to filter on our color data, so we need to supply an arbitrary index to retrieve later. Uses N# for numerics, instead of S# for strings. DataObject.IndexOptions index = dataNew.Key == "Color" ? DataObject.IndexOptions.N1 : 0; DataObject dataObj = new DataObject(DataObject.VisibilityOptions.Public, dataNew.Value, index); // Public so that when we request the list of lobbies, we can get info about them for filtering. if (dataCurr.ContainsKey(dataNew.Key)) dataCurr[dataNew.Key] = dataObj; else dataCurr.Add(dataNew.Key, dataObj); //Special Use: Get the state of the Local lobby so we can lock it from appearing in queries if it's not in the "Lobby" State if (dataNew.Key == "State") { Enum.TryParse(dataNew.Value, out LobbyState lobbyState); shouldLock = lobbyState != LobbyState.Lobby; } } UpdateLobbyOptions updateOptions = new UpdateLobbyOptions { Data = dataCurr, IsLocked = shouldLock }; var result = await LobbyService.Instance.UpdateLobbyAsync(m_RemoteLobby.Id, updateOptions); if (result != null) m_RemoteLobby = result; } private float m_heartbeatTime = 0; private const int k_heartbeatPeriodMS = 8000; // The heartbeat must be rate-limited to 5 calls per 30 seconds. We'll aim for longer in case periods don't align. /// /// Lobby requires a periodic ping to detect rooms that are still active, in order to mitigate "zombie" lobbies. /// async Task LobbyHeartBeatLoop() { while (m_RemoteLobby!=null) { #pragma warning disable 4014 LobbyService.Instance.SendHeartbeatPingAsync(m_RemoteLobby.Id); #pragma warning restore 4014 await Task.Delay(k_heartbeatPeriodMS); } } async Task AwaitRemoteLobby() { while (m_RemoteLobby == null) await Task.Delay(100); } public void Dispose() { } public class RateLimitCooldown { public Action onCooldownChange; public readonly float m_CooldownSeconds; public readonly int m_CoolDownMS; Queue m_TaskQueue = new Queue(); Task m_DequeuedTask; private bool m_IsInCooldown = false; public bool IsInCooldown { get => m_IsInCooldown; private set { if (m_IsInCooldown != value) { m_IsInCooldown = value; onCooldownChange?.Invoke(m_IsInCooldown); } } } public RateLimitCooldown(float cooldownSeconds) { m_CooldownSeconds = cooldownSeconds; m_CoolDownMS = Mathf.FloorToInt(m_CooldownSeconds * 1000); } public async Task WaitUntilCooldown() //TODO YAGNI Handle Multiple commands? Return bool if already waiting? { //No Queue! if (CanCall()) return; while (m_IsInCooldown) { await Task.Delay(100); } } bool CanCall() { if (!IsInCooldown) { #pragma warning disable 4014 CoolDownAsync(); #pragma warning restore 4014 return true; } else return false; } async Task CoolDownAsync() { if (m_IsInCooldown) return; IsInCooldown = true; await Task.Delay(m_CoolDownMS); if (m_TaskQueue.Count > 0) { m_DequeuedTask = m_TaskQueue.Dequeue(); await CoolDownAsync(); } IsInCooldown = false; } } } }