diff --git a/.github/workflows/run-tests-and-package.yml b/.github/workflows/run-tests-and-package.yml index 9eb0f9b78..70183e4bf 100644 --- a/.github/workflows/run-tests-and-package.yml +++ b/.github/workflows/run-tests-and-package.yml @@ -465,15 +465,15 @@ jobs: - name: Enable beta features run: | sed -i -e 's/1: LOOTLOCKER_COMMANDLINE_SETTINGS/1: LOOTLOCKER_COMMANDLINE_SETTINGS;${{ VARS.CURRENT_BETA_FEATURES }}/g' TestProject/ProjectSettings/ProjectSettings.asset + - name: Enable Presence Compile flag but disable runtime presence usage by default + run: | + sed -i -e 's/1: LOOTLOCKER_COMMANDLINE_SETTINGS/1: LOOTLOCKER_COMMANDLINE_SETTINGS;LOOTLOCKER_ENABLE_PRESENCE/g' TestProject/ProjectSettings/ProjectSettings.asset + echo "PRESENCE_CONFIG=-enablepresence false -enablepresenceautoconnect false -enablepresenceautodisconnectonfocuschange false" >> $GITHUB_ENV - name: Set the project to use Newtonsoft json if: ${{ ENV.JSON_LIBRARY == 'newtonsoft' }} run: | sed -i -e 's/1: LOOTLOCKER_COMMANDLINE_SETTINGS/1: LOOTLOCKER_COMMANDLINE_SETTINGS;LOOTLOCKER_USE_NEWTONSOFTJSON/g' TestProject/ProjectSettings/ProjectSettings.asset sed -i -e 's/"nunit.framework.dll"/"nunit.framework.dll",\n\t\t"Newtonsoft.Json.dll"/g' sdk/Tests/LootLockerTests/PlayMode/PlayModeTests.asmdef - - name: Use Legacy HTTP Stack - if: ${{ ENV.USE_HTTP_EXECUTION_QUEUE == 'false' }} - run: | - sed -i -e 's/1: LOOTLOCKER_COMMANDLINE_SETTINGS/1: LOOTLOCKER_COMMANDLINE_SETTINGS;LOOTLOCKER_LEGACY_HTTP_STACK/g' TestProject/ProjectSettings/ProjectSettings.asset - name: Set LootLocker to target stage environment if: ${{ ENV.TARGET_ENVIRONMENT == 'STAGE' }} run: | @@ -520,7 +520,7 @@ jobs: checkName: Integration tests (${{ matrix.unityVersion }}-${{ ENV.JSON_LIBRARY }}) Test Results artifactsPath: ${{ matrix.unityVersion }}-${{ ENV.JSON_LIBRARY }}-artifacts githubToken: ${{ secrets.GITHUB_TOKEN }} - customParameters: -lootlockerurl ${{ ENV.LOOTLOCKER_URL }} ${{ ENV.USER_COMMANDLINE_ARGUMENTS }} ${{ ENV.TEST_CATEGORY }} + customParameters: -lootlockerurl ${{ ENV.LOOTLOCKER_URL }} ${{ ENV.USER_COMMANDLINE_ARGUMENTS }} ${{ ENV.TEST_CATEGORY }} ${{ ENV.PRESENCE_CONFIG }} useHostNetwork: true ####### CLEANUP ########### - name: Bring down Go Backend diff --git a/Runtime/Client/ILootLockerService.cs b/Runtime/Client/ILootLockerService.cs new file mode 100644 index 000000000..a4dc65792 --- /dev/null +++ b/Runtime/Client/ILootLockerService.cs @@ -0,0 +1,43 @@ +namespace LootLocker +{ + /// + /// Interface that all LootLocker services must implement to be managed by the LifecycleManager + /// + public interface ILootLockerService + { + /// + /// Initialize the service + /// + void Initialize(); + + /// + /// Reset/cleanup the service state + /// + void Reset(); + + /// + /// Handle application pause events (optional - default implementation does nothing) + /// + void HandleApplicationPause(bool pauseStatus); + + /// + /// Handle application focus events (optional - default implementation does nothing) + /// + void HandleApplicationFocus(bool hasFocus); + + /// + /// Handle application quit events + /// + void HandleApplicationQuit(); + + /// + /// Whether the service has been initialized + /// + bool IsInitialized { get; } + + /// + /// Service name for logging and identification + /// + string ServiceName { get; } + } +} \ No newline at end of file diff --git a/Runtime/Client/LootLockerServerApi.cs.meta b/Runtime/Client/ILootLockerService.cs.meta similarity index 83% rename from Runtime/Client/LootLockerServerApi.cs.meta rename to Runtime/Client/ILootLockerService.cs.meta index 7123fe2b6..09251ecba 100644 --- a/Runtime/Client/LootLockerServerApi.cs.meta +++ b/Runtime/Client/ILootLockerService.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: b6b4735df3c936946a538c8a2acc6e43 +guid: a4444c443da40fa4fb63253b5299702c MonoImporter: externalObjects: {} serializedVersion: 2 diff --git a/Runtime/Client/LootLockerEndPoints.cs b/Runtime/Client/LootLockerEndPoints.cs index c1f61ee0b..d0d0071d8 100644 --- a/Runtime/Client/LootLockerEndPoints.cs +++ b/Runtime/Client/LootLockerEndPoints.cs @@ -336,6 +336,10 @@ public class LootLockerEndPoints // Broadcasts [Header("Broadcasts")] public static EndPointClass ListBroadcasts = new EndPointClass("broadcasts/v1", LootLockerHTTPMethod.GET); + + // Presence (WebSocket) + [Header("Presence")] + public static EndPointClass presenceWebSocket = new EndPointClass("presence/v1", LootLockerHTTPMethod.GET); } [Serializable] diff --git a/Runtime/Client/LootLockerEventSystem.cs b/Runtime/Client/LootLockerEventSystem.cs new file mode 100644 index 000000000..8cde4acbf --- /dev/null +++ b/Runtime/Client/LootLockerEventSystem.cs @@ -0,0 +1,580 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace LootLocker +{ + #region Event Data Classes + + /// + /// Base class for all LootLocker event data + /// + [Serializable] + public abstract class LootLockerEventData + { + public DateTime timestamp { get; private set; } + public LootLockerEventType eventType { get; private set; } + + protected LootLockerEventData(LootLockerEventType eventType) + { + this.eventType = eventType; + this.timestamp = DateTime.UtcNow; + } + } + + /// + /// Event data for session started events + /// + [Serializable] + public class LootLockerSessionStartedEventData : LootLockerEventData + { + /// + /// The complete player data for the player whose session started + /// + public LootLockerPlayerData playerData { get; set; } + + public LootLockerSessionStartedEventData(LootLockerPlayerData playerData) + : base(LootLockerEventType.SessionStarted) + { + this.playerData = playerData; + } + } + + /// + /// Event data for session refreshed events + /// + [Serializable] + public class LootLockerSessionRefreshedEventData : LootLockerEventData + { + /// + /// The complete player data for the player whose session was refreshed + /// + public LootLockerPlayerData playerData { get; set; } + + public LootLockerSessionRefreshedEventData(LootLockerPlayerData playerData) + : base(LootLockerEventType.SessionRefreshed) + { + this.playerData = playerData; + } + } + + /// + /// Event data for session ended events + /// + [Serializable] + public class LootLockerSessionEndedEventData : LootLockerEventData + { + /// + /// The ULID of the player whose session ended + /// + public string playerUlid { get; set; } + + /// + /// Whether local state should be cleared for this player + /// + public bool clearLocalState { get; set; } + + public LootLockerSessionEndedEventData(string playerUlid, bool clearLocalState = false) + : base(LootLockerEventType.SessionEnded) + { + this.playerUlid = playerUlid; + this.clearLocalState = clearLocalState; + } + } + + /// + /// Event data for session expired events + /// + [Serializable] + public class LootLockerSessionExpiredEventData : LootLockerEventData + { + /// + /// The ULID of the player whose session expired + /// + public string playerUlid { get; set; } + + public LootLockerSessionExpiredEventData(string playerUlid) + : base(LootLockerEventType.SessionExpired) + { + this.playerUlid = playerUlid; + } + } + + /// + /// Event data for local session deactivated events + /// + [Serializable] + public class LootLockerLocalSessionDeactivatedEventData : LootLockerEventData + { + /// + /// The ULID of the player whose local session was deactivated (null if all sessions were deactivated) + /// + public string playerUlid { get; set; } + + public LootLockerLocalSessionDeactivatedEventData(string playerUlid) + : base(LootLockerEventType.LocalSessionDeactivated) + { + this.playerUlid = playerUlid; + } + } + + /// + /// Event data for local session activated events + /// + [Serializable] + public class LootLockerLocalSessionActivatedEventData : LootLockerEventData + { + /// + /// The complete player data for the player whose session was activated + /// + public LootLockerPlayerData playerData { get; set; } + + public LootLockerLocalSessionActivatedEventData(LootLockerPlayerData playerData) + : base(LootLockerEventType.LocalSessionActivated) + { + this.playerData = playerData; + } + } + + + /// + /// Event data for presence connection state changed events + /// + [Serializable] + public class LootLockerPresenceConnectionStateChangedEventData : LootLockerEventData + { + /// + /// The ULID of the player whose presence connection state changed + /// + public string playerUlid { get; set; } + + /// + /// The previous connection state + /// + public LootLockerPresenceConnectionState previousState { get; set; } + + /// + /// The new connection state + /// + public LootLockerPresenceConnectionState newState { get; set; } + + /// + /// Error message if the state change was due to an error + /// + public string errorMessage { get; set; } + + public LootLockerPresenceConnectionStateChangedEventData(string playerUlid, LootLockerPresenceConnectionState previousState, LootLockerPresenceConnectionState newState, string errorMessage = null) + : base(LootLockerEventType.PresenceConnectionStateChanged) + { + this.playerUlid = playerUlid; + this.previousState = previousState; + this.newState = newState; + this.errorMessage = errorMessage; + } + } + + /// + /// Event data for event system reset events + /// + [Serializable] + public class LootLockerEventSystemResetEventData : LootLockerEventData + { + string message { get; set; } = "The LootLocker Event System has been reset and all subscribers have been cleared. If you were subscribed to events, you will need to re-subscribe."; + public LootLockerEventSystemResetEventData() + : base(LootLockerEventType.EventSystemReset) + { + } + } + + #endregion + + #region Event Delegates + + /// + /// Delegate for LootLocker events + /// + public delegate void LootLockerEventHandler(T eventData) where T : LootLockerEventData; + + #endregion + + #region Event Types + + /// + /// Predefined event types for the LootLocker SDK + /// + public enum LootLockerEventType + { + // Session Events + SessionStarted, + SessionRefreshed, + SessionEnded, + SessionExpired, + LocalSessionDeactivated, + LocalSessionActivated, + // Presence Events + PresenceConnectionStateChanged, + // Meta Events + EventSystemReset + } + + #endregion + + /// + /// Centralized event system for the LootLocker SDK + /// Manages event subscriptions, event firing, and event data + /// + public class LootLockerEventSystem : MonoBehaviour, ILootLockerService + { + #region ILootLockerService Implementation + + public bool IsInitialized { get; private set; } = false; + public string ServiceName => "EventSystem"; + + void ILootLockerService.Initialize() + { + if (IsInitialized) return; + + // Initialize event system configuration + logEvents = false; + IsInitialized = true; + } + + void ILootLockerService.Reset() + { + ClearAllSubscribersInternal(); + logEvents = false; + IsInitialized = false; + } + + void ILootLockerService.HandleApplicationPause(bool pauseStatus) + { + // Event system doesn't need to handle pause events + } + + void ILootLockerService.HandleApplicationFocus(bool hasFocus) + { + // Event system doesn't need to handle focus events + } + + void ILootLockerService.HandleApplicationQuit() + { + ClearAllSubscribersInternal(); + } + + #endregion + + #region Singleton Management + + private static LootLockerEventSystem _instance; + private static readonly object _instanceLock = new object(); + + /// + /// Get the EventSystem service instance through the LifecycleManager. + /// Services are automatically registered and initialized on first access if needed. + /// + private static LootLockerEventSystem GetInstance() + { + if (_instance != null) + { + return _instance; + } + + lock (_instanceLock) + { + if (_instance == null) + { + // Register with LifecycleManager (will auto-initialize if needed) + _instance = LootLockerLifecycleManager.GetService(); + } + return _instance; + } + } + + #endregion + + #region Private Fields + + // Event storage with strong references to prevent premature GC + // Using regular List instead of WeakReference to avoid delegate GC issues + private Dictionary> eventSubscribers = new Dictionary>(); + private readonly object eventSubscribersLock = new object(); // Thread safety for event subscribers + + // Configuration + private bool logEvents = false; + + #endregion + + #region Public Properties + + + + /// + /// Whether to log events to the console for debugging + /// + public static bool LogEvents + { + get => GetInstance()?.logEvents ?? false; + set { var instance = GetInstance(); if (instance != null) instance.logEvents = value; } + } + + #endregion + + #region Public Methods + + /// + /// Subscribe to a specific event type with typed event data + /// + public static void Subscribe(LootLockerEventType eventType, LootLockerEventHandler handler) where T : LootLockerEventData + { + GetInstance()?.SubscribeInstance(eventType, handler); + } + + /// + /// Unsubscribe from a specific event type with typed handler + /// + public static void Unsubscribe(LootLockerEventType eventType, LootLockerEventHandler handler) where T : LootLockerEventData + { + GetInstance()?.UnsubscribeInstance(eventType, handler); + } + + /// + /// Instance method to subscribe to events without triggering circular dependency through GetInstance() + /// Used during initialization when we already have the EventSystem instance + /// + public void SubscribeInstance(LootLockerEventType eventType, LootLockerEventHandler handler) where T : LootLockerEventData + { + if (handler == null) + return; + + lock (eventSubscribersLock) + { + if (!eventSubscribers.ContainsKey(eventType)) + { + eventSubscribers[eventType] = new List(); + } + + // Add new subscription with strong reference to prevent GC issues + eventSubscribers[eventType].Add(handler); + + if (logEvents) + { + LootLockerLogger.Log($"SubscribeInstance to {eventType}, total subscribers: {eventSubscribers[eventType].Count}", LootLockerLogger.LogLevel.Debug); + } + } + } + + /// + /// Unsubscribe from a specific event type with typed handler using this instance + /// + public void UnsubscribeInstance(LootLockerEventType eventType, LootLockerEventHandler handler) where T : LootLockerEventData + { + if (!eventSubscribers.ContainsKey(eventType)) + return; + + lock (eventSubscribersLock) + { + // Find and remove the matching handler + var subscribers = eventSubscribers[eventType]; + for (int i = subscribers.Count - 1; i >= 0; i--) + { + if (subscribers[i].Equals(handler)) + { + subscribers.RemoveAt(i); + break; + } + } + + // Clean up empty lists + if (subscribers.Count == 0) + { + eventSubscribers.Remove(eventType); + } + } + } + + /// + /// Fire an event with specific event data + /// + public static void TriggerEvent(T eventData) where T : LootLockerEventData + { + var instance = GetInstance(); + if (instance == null || eventData == null) + return; + + LootLockerEventType eventType = eventData.eventType; + + if (!instance.eventSubscribers.ContainsKey(eventType)) + return; + + // Get a copy of subscribers to avoid lock contention during event handling + List subscribers; + lock (instance.eventSubscribersLock) + { + subscribers = new List(instance.eventSubscribers[eventType]); + } + + // Trigger event handlers outside the lock + foreach (var subscriber in subscribers) + { + try + { + if (subscriber is LootLockerEventHandler typedHandler) + { + typedHandler.Invoke(eventData); + } + } + catch (Exception ex) + { + LootLockerLogger.Log($"Error in event handler for {eventType}: {ex.Message}", LootLockerLogger.LogLevel.Error); + } + } + + if (instance.logEvents) + { + LootLockerLogger.Log($"LootLocker Event: {eventType} at {eventData.timestamp}. Notified {subscribers.Count} subscribers", LootLockerLogger.LogLevel.Debug); + } + } + + /// + /// Clear all subscribers for a specific event type + /// WARNING: This is for internal SDK use only. It will clear ALL subscribers including internal SDK subscriptions. + /// External code should use explicit Unsubscribe() calls instead. + /// + public static void ClearSubscribers(LootLockerEventType eventType) + { + var instance = GetInstance(); + if (instance == null) return; + + lock (instance.eventSubscribersLock) + { + instance.eventSubscribers.Remove(eventType); + } + } + + /// + /// Internal method to clear all subscribers without accessing service registry + /// Used during shutdown to avoid service lookup issues + /// + private void ClearAllSubscribersInternal() + { + var resetEventData = new LootLockerEventSystemResetEventData(); + try { + TriggerEvent(resetEventData); + } catch (Exception ex) { + LootLockerLogger.Log($"Error in subscriber to event system reset event: {ex.Message}. Ignored as it is up to subscribers to handle", LootLockerLogger.LogLevel.Debug); + } + lock (eventSubscribersLock) + { + eventSubscribers?.Clear(); + } + } + + /// + /// Clear all event subscribers + /// WARNING: This is for internal SDK use only. It will clear ALL subscribers including internal SDK subscriptions. + /// External code should use explicit Unsubscribe() calls instead. + /// + public static void ClearAllSubscribers() + { + GetInstance()?.ClearAllSubscribersInternal(); + } + + /// + /// Get the number of subscribers for a specific event type + /// + public static int GetSubscriberCount(LootLockerEventType eventType) + { + var instance = GetInstance(); + if (instance == null) return 0; + + lock (instance.eventSubscribersLock) + { + if (instance.eventSubscribers.ContainsKey(eventType)) + return instance.eventSubscribers[eventType].Count; + + return 0; + } + } + + #endregion + + #region Unity Lifecycle + + private void OnDestroy() + { + ClearAllSubscribersInternal(); + } + + #endregion + + #region Helper Methods for Session Events + + /// + /// Helper method to trigger session started event + /// + public static void TriggerSessionStarted(LootLockerPlayerData playerData) + { + var eventData = new LootLockerSessionStartedEventData(playerData); + TriggerEvent(eventData); + } + + /// + /// Helper method to trigger session ended event + /// + /// The player whose session ended + /// Whether to clear local state for this player + public static void TriggerSessionEnded(string playerUlid, bool clearLocalState = false) + { + var eventData = new LootLockerSessionEndedEventData(playerUlid, clearLocalState); + TriggerEvent(eventData); + } + + /// + /// Helper method to trigger session refreshed event + /// + public static void TriggerSessionRefreshed(LootLockerPlayerData playerData) + { + var eventData = new LootLockerSessionRefreshedEventData(playerData); + TriggerEvent(eventData); + } + + /// + /// Helper method to trigger session expired event + /// + public static void TriggerSessionExpired(string playerUlid) + { + var eventData = new LootLockerSessionExpiredEventData(playerUlid); + TriggerEvent(eventData); + } + + /// + /// Helper method to trigger local session cleared event for a specific player + /// + public static void TriggerLocalSessionDeactivated(string playerUlid) + { + var eventData = new LootLockerLocalSessionDeactivatedEventData(playerUlid); + TriggerEvent(eventData); + } + + /// + /// Helper method to trigger session activated event + /// + public static void TriggerLocalSessionActivated(LootLockerPlayerData playerData) + { + var eventData = new LootLockerLocalSessionActivatedEventData(playerData); + TriggerEvent(eventData); + } + /// + /// Helper method to trigger presence connection state changed event + /// + public static void TriggerPresenceConnectionStateChanged(string playerUlid, LootLockerPresenceConnectionState previousState, LootLockerPresenceConnectionState newState, string errorMessage = null) + { + var eventData = new LootLockerPresenceConnectionStateChangedEventData(playerUlid, previousState, newState, errorMessage); + TriggerEvent(eventData); + } + + #endregion + + } +} diff --git a/Runtime/Client/LootLockerEventSystem.cs.meta b/Runtime/Client/LootLockerEventSystem.cs.meta new file mode 100644 index 000000000..b8eb76e9b --- /dev/null +++ b/Runtime/Client/LootLockerEventSystem.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 90a3b9b1ff28078439dd7b4c2a8e745a \ No newline at end of file diff --git a/Runtime/Client/LootLockerHTTPClient.cs b/Runtime/Client/LootLockerHTTPClient.cs index d7ebd77f2..7ea7f7620 100644 --- a/Runtime/Client/LootLockerHTTPClient.cs +++ b/Runtime/Client/LootLockerHTTPClient.cs @@ -1,4 +1,3 @@ -#if !LOOTLOCKER_LEGACY_HTTP_STACK using System.Collections.Generic; using UnityEngine; using System; @@ -30,11 +29,11 @@ public static void CallAPI(string forPlayerWithUlid, string endPoint, LootLocker { LootLockerLogger.Log("Payloads can not be sent in GET, HEAD, or OPTIONS requests. Attempted to send a body to: " + httpMethod.ToString() + " " + endPoint, LootLockerLogger.LogLevel.Warning); } - LootLockerHTTPClient.Get().ScheduleRequest(LootLockerHTTPRequestData.MakeNoContentRequest(forPlayerWithUlid, endPoint, httpMethod, onComplete, useAuthToken, callerRole, additionalHeaders, null)); + LootLockerHTTPClient.Get()?.ScheduleRequest(LootLockerHTTPRequestData.MakeNoContentRequest(forPlayerWithUlid, endPoint, httpMethod, onComplete, useAuthToken, callerRole, additionalHeaders, null)); } else { - LootLockerHTTPClient.Get().ScheduleRequest(LootLockerHTTPRequestData.MakeJsonRequest(forPlayerWithUlid, endPoint, httpMethod, body, onComplete, useAuthToken, callerRole, additionalHeaders, null)); + LootLockerHTTPClient.Get()?.ScheduleRequest(LootLockerHTTPRequestData.MakeJsonRequest(forPlayerWithUlid, endPoint, httpMethod, body, onComplete, useAuthToken, callerRole, additionalHeaders, null)); } } @@ -47,7 +46,7 @@ public static void UploadFile(string forPlayerWithUlid, string endPoint, LootLoc return; } - LootLockerHTTPClient.Get().ScheduleRequest(LootLockerHTTPRequestData.MakeFileRequest(forPlayerWithUlid, endPoint, httpMethod, file, fileName, fileContentType, body, onComplete, useAuthToken, callerRole, additionalHeaders, null)); + LootLockerHTTPClient.Get()?.ScheduleRequest(LootLockerHTTPRequestData.MakeFileRequest(forPlayerWithUlid, endPoint, httpMethod, file, fileName, fileContentType, body, onComplete, useAuthToken, callerRole, additionalHeaders, null)); } public static void UploadFile(string forPlayerWithUlid, EndPointClass endPoint, byte[] file, string fileName = "file", string fileContentType = "text/plain", Dictionary body = null, Action onComplete = null, bool useAuthToken = true, LootLockerCallerRole callerRole = LootLocker.LootLockerEnums.LootLockerCallerRole.User, Dictionary additionalHeaders = null) @@ -76,6 +75,10 @@ public class LootLockerHTTPClientConfiguration * The maximum number of requests allowed to be in progress at the same time */ public int MaxOngoingRequests = 50; + /* + * The maximum size of the request queue before new requests are rejected + */ + public int MaxQueueSize = 5000; /* * The threshold of number of requests outstanding to use for warning about the building queue */ @@ -84,6 +87,15 @@ public class LootLockerHTTPClientConfiguration * Whether to deny incoming requests when the HTTP client is already handling too many requests */ public bool DenyIncomingRequestsWhenBackedUp = true; + /* + * Whether to log warnings when requests are denied due to queue limits + */ + public bool LogQueueRejections = +#if UNITY_EDITOR + true; +#else + false; +#endif public LootLockerHTTPClientConfiguration() { @@ -91,8 +103,15 @@ public LootLockerHTTPClientConfiguration() IncrementalBackoffFactor = 2; InitialRetryWaitTimeInMs = 50; MaxOngoingRequests = 50; + MaxQueueSize = 5000; ChokeWarningThreshold = 500; DenyIncomingRequestsWhenBackedUp = true; + LogQueueRejections = +#if UNITY_EDITOR + true; +#else + false; +#endif } public LootLockerHTTPClientConfiguration(int maxRetries, int incrementalBackoffFactor, int initialRetryWaitTime) @@ -101,104 +120,223 @@ public LootLockerHTTPClientConfiguration(int maxRetries, int incrementalBackoffF IncrementalBackoffFactor = incrementalBackoffFactor; InitialRetryWaitTimeInMs = initialRetryWaitTime; MaxOngoingRequests = 50; + MaxQueueSize = 5000; ChokeWarningThreshold = 500; DenyIncomingRequestsWhenBackedUp = true; + LogQueueRejections = +#if UNITY_EDITOR + true; +#else + false; +#endif } } #if UNITY_EDITOR [ExecuteInEditMode] #endif - public class LootLockerHTTPClient : MonoBehaviour + public class LootLockerHTTPClient : MonoBehaviour, ILootLockerService { - #region Configuration + #region ILootLockerService Implementation - private static LootLockerHTTPClientConfiguration configuration = new LootLockerHTTPClientConfiguration(); - private static CertificateHandler certificateHandler = null; + public bool IsInitialized { get; private set; } = false; + public string ServiceName => "HTTPClient"; - private Dictionary CurrentlyOngoingRequests = new Dictionary(); + void ILootLockerService.Initialize() + { + if (IsInitialized) return; - private static readonly Dictionary BaseHeaders = new Dictionary + lock (_instanceLock) + { + // Initialize HTTP client configuration + if (configuration == null) + { + configuration = new LootLockerHTTPClientConfiguration(); + } + + // Initialize request tracking + CurrentlyOngoingRequests = new Dictionary(); + HTTPExecutionQueue = new Dictionary(); + CompletedRequestIDs = new List(); + ExecutionItemsNeedingRefresh = new UniqueList(); + OngoingIdsToCleanUp = new List(); + + // RateLimiter will be set via SetRateLimiter() if available + + IsInitialized = true; + _instance = this; + } + LootLockerLogger.Log("LootLockerHTTPClient initialized", LootLockerLogger.LogLevel.Verbose); + } + + /// + /// Set the RateLimiter dependency for this HTTPClient + /// + public void SetRateLimiter(RateLimiter rateLimiter) { - { "Accept", "application/json; charset=UTF-8" }, - { "Content-Type", "application/json; charset=UTF-8" }, - { "Access-Control-Allow-Credentials", "true" }, - { "Access-Control-Allow-Headers", "Accept, X-Access-Token, X-Application-Name, X-Request-Sent-Time" }, - { "Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS, HEAD" }, - { "Access-Control-Allow-Origin", "*" }, - { "LL-Instance-Identifier", System.Guid.NewGuid().ToString() } - }; + _cachedRateLimiter = rateLimiter; + if (rateLimiter != null) + { + LootLockerLogger.Log("HTTPClient rate limiting enabled", LootLockerLogger.LogLevel.Verbose); + } + else + { + LootLockerLogger.Log("HTTPClient rate limiting disabled", LootLockerLogger.LogLevel.Verbose); + } + } + + void ILootLockerService.Reset() + { + Cleanup("Request was aborted due to HTTP client reset"); + } + + void ILootLockerService.HandleApplicationPause(bool pauseStatus) + { + // HTTP client doesn't need special pause handling + } + + void ILootLockerService.HandleApplicationFocus(bool hasFocus) + { + // HTTP client doesn't need special focus handling + } + + void ILootLockerService.HandleApplicationQuit() + { + Cleanup("Request was aborted due to HTTP client destruction"); + } + #endregion - #region Instance Handling - private static LootLockerHTTPClient _instance; - private static int _instanceId = 0; - private GameObject _hostingGameObject = null; + #region Private Cleanup Methods - public static void Instantiate() + private void Cleanup(string reason) { - if (!_instance) + if (!IsInitialized || _instance == null) + { + return; + } + + // Abort all ongoing requests and notify callbacks + if (HTTPExecutionQueue != null) { - var gameObject = new GameObject("LootLockerHTTPClient"); + AbortAllOngoingRequestsWithCallback("Request was aborted due to HTTP client reset"); + } + + // Clear all collections + ClearAllCollections(); + + // Clear cached references + _cachedRateLimiter = null; + + IsInitialized = false; - _instance = gameObject.AddComponent(); - _instanceId = _instance.GetInstanceID(); - _instance._hostingGameObject = gameObject; - _instance.StartCoroutine(CleanUpOldInstances()); - if (Application.isPlaying) - DontDestroyOnLoad(_instance.gameObject); + lock (_instanceLock) + { + _instance = null; } + } - public static IEnumerator CleanUpOldInstances() + /// + /// Aborts all ongoing requests, disposes resources, and notifies callbacks with the given reason + /// + private void AbortAllOngoingRequestsWithCallback(string abortReason) { -#if UNITY_2020_1_OR_NEWER - LootLockerHTTPClient[] serverApis = GameObject.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); -#else - LootLockerHTTPClient[] serverApis = GameObject.FindObjectsOfType(); -#endif - foreach (LootLockerHTTPClient serverApi in serverApis) + if (HTTPExecutionQueue != null) { - if (serverApi && _instanceId != serverApi.GetInstanceID() && serverApi._hostingGameObject) + foreach (var kvp in HTTPExecutionQueue) { -#if UNITY_EDITOR - DestroyImmediate(serverApi._hostingGameObject); -#else - Destroy(serverApi._hostingGameObject); -#endif + var executionItem = kvp.Value; + if (executionItem != null && !executionItem.Done && !executionItem.RequestData.HaveListenersBeenInvoked) + { + // Abort the web request if it's active + if (executionItem.WebRequest != null) + { + executionItem.WebRequest.Abort(); + executionItem.WebRequest.Dispose(); + } + + // Notify callbacks that the request was aborted + var abortedResponse = LootLockerResponseFactory.ClientError( + abortReason, + executionItem.RequestData.ForPlayerWithUlid, + executionItem.RequestData.RequestStartTime + ); + + executionItem.RequestData.CallListenersWithResult(abortedResponse); + } + else if (executionItem?.WebRequest != null) + { + // Even if done, still dispose the web request to prevent memory leaks + executionItem.WebRequest.Dispose(); + } } } - yield return null; } - public static void ResetInstance() + /// + /// Clears all internal collections and tracking data + /// + private void ClearAllCollections() { - if (!_instance) return; -#if UNITY_EDITOR - DestroyImmediate(_instance.gameObject); -#else - Destroy(_instance.gameObject); -#endif - _instance = null; - _instanceId = 0; + CurrentlyOngoingRequests?.Clear(); + HTTPExecutionQueue?.Clear(); + CompletedRequestIDs?.Clear(); + ExecutionItemsNeedingRefresh?.Clear(); + OngoingIdsToCleanUp?.Clear(); } -#if UNITY_EDITOR - [InitializeOnEnterPlayMode] - static void OnEnterPlaymodeInEditor(EnterPlayModeOptions options) + #endregion + + #region Configuration + + private static LootLockerHTTPClientConfiguration configuration = new LootLockerHTTPClientConfiguration(); + private static CertificateHandler certificateHandler = null; + + private Dictionary CurrentlyOngoingRequests = new Dictionary(); + + private static readonly Dictionary BaseHeaders = new Dictionary { - ResetInstance(); - } -#endif + { "Accept", "application/json; charset=UTF-8" }, + { "Content-Type", "application/json; charset=UTF-8" }, + { "Access-Control-Allow-Credentials", "true" }, + { "Access-Control-Allow-Headers", "Accept, X-Access-Token, X-Application-Name, X-Request-Sent-Time" }, + { "Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS, HEAD" }, + { "Access-Control-Allow-Origin", "*" }, + { "LL-Instance-Identifier", System.Guid.NewGuid().ToString() } + }; + #endregion + + #region Singleton Management + + private static LootLockerHTTPClient _instance; + private static readonly object _instanceLock = new object(); + /// + /// Get the HTTPClient service instance through the LifecycleManager. + /// Services are automatically registered and initialized on first access if needed. + /// public static LootLockerHTTPClient Get() { - if (!_instance) + if (_instance != null) { - Instantiate(); + return _instance; + } + + lock (_instanceLock) + { + if (_instance == null) + { + // Register with LifecycleManager (will auto-initialize if needed) + _instance = LootLockerLifecycleManager.GetService(); + } + return _instance; } - return _instance; } + + #endregion + + #region Configuration and Properties public void OverrideConfiguration(LootLockerHTTPClientConfiguration configuration) { @@ -214,24 +352,32 @@ public void OverrideCertificateHandler(CertificateHandler certificateHandler) } #endregion + #region Private Fields private Dictionary HTTPExecutionQueue = new Dictionary(); private List CompletedRequestIDs = new List(); private UniqueList ExecutionItemsNeedingRefresh = new UniqueList(); private List OngoingIdsToCleanUp = new List(); + private RateLimiter _cachedRateLimiter; // Optional RateLimiter - if null, rate limiting is disabled + + // Memory management constants + private const int MAX_COMPLETED_REQUEST_HISTORY = 100; + private const int CLEANUP_THRESHOLD = 500; + private DateTime _lastCleanupTime = DateTime.MinValue; + private const int CLEANUP_INTERVAL_SECONDS = 30; + #endregion + + #region Class Logic private void OnDestroy() { - foreach(var executionItem in HTTPExecutionQueue.Values) - { - if(executionItem != null && executionItem.WebRequest != null) - { - executionItem.Dispose(); - } - } + Cleanup("Request was aborted due to HTTP client destruction"); } void Update() { + // Periodic cleanup to prevent memory leaks + PerformPeriodicCleanup(); + // Process the execution queue foreach (var executionItem in HTTPExecutionQueue.Values) { @@ -396,10 +542,28 @@ private IEnumerator _ScheduleRequest(LootLockerHTTPRequestData request) //Always wait 1 frame before starting any request to the server to make sure the requester code has exited the main thread. yield return null; + // Check if queue has reached maximum size + if (configuration.DenyIncomingRequestsWhenBackedUp && HTTPExecutionQueue.Count >= configuration.MaxQueueSize) + { + string errorMessage = $"Request was denied because the queue has reached its maximum size ({configuration.MaxQueueSize})"; + if (configuration.LogQueueRejections) + { + LootLockerLogger.Log($"HTTP queue full: {HTTPExecutionQueue.Count}/{configuration.MaxQueueSize} requests queued", LootLockerLogger.LogLevel.Warning); + } + request.CallListenersWithResult(LootLockerResponseFactory.ClientError(errorMessage, request.ForPlayerWithUlid, request.RequestStartTime)); + yield break; + } + + // Check for choke warning threshold if (configuration.DenyIncomingRequestsWhenBackedUp && (HTTPExecutionQueue.Count - CurrentlyOngoingRequests.Count) > configuration.ChokeWarningThreshold) { // Execution queue is backed up, deny request - request.CallListenersWithResult(LootLockerResponseFactory.ClientError("Request was denied because there are currently too many requests in queue", request.ForPlayerWithUlid, request.RequestStartTime)); + string errorMessage = $"Request was denied because there are currently too many requests in queue ({HTTPExecutionQueue.Count - CurrentlyOngoingRequests.Count} queued, threshold: {configuration.ChokeWarningThreshold})"; + if (configuration.LogQueueRejections) + { + LootLockerLogger.Log($"HTTP queue backed up: {HTTPExecutionQueue.Count - CurrentlyOngoingRequests.Count} requests queued", LootLockerLogger.LogLevel.Warning); + } + request.CallListenersWithResult(LootLockerResponseFactory.ClientError(errorMessage, request.ForPlayerWithUlid, request.RequestStartTime)); yield break; } @@ -413,9 +577,10 @@ private IEnumerator _ScheduleRequest(LootLockerHTTPRequestData request) private bool CreateAndSendRequest(LootLockerHTTPExecutionQueueItem executionItem) { - if (RateLimiter.Get().AddRequestAndCheckIfRateLimitHit()) + // Rate limiting is optional - if no RateLimiter is set, requests proceed without rate limiting + if (_cachedRateLimiter?.AddRequestAndCheckIfRateLimitHit() == true) { - CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.RateLimitExceeded(executionItem.RequestData.Endpoint, RateLimiter.Get().GetSecondsLeftOfRateLimit(), executionItem.RequestData.ForPlayerWithUlid)); + CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.RateLimitExceeded(executionItem.RequestData.Endpoint, _cachedRateLimiter.GetSecondsLeftOfRateLimit(), executionItem.RequestData.ForPlayerWithUlid)); return false; } @@ -579,6 +744,7 @@ private IEnumerator RefreshSession(string refreshForPlayerUlid, string forExecut if (playerData == null) { LootLockerLogger.Log($"No stored player data for player with ulid {refreshForPlayerUlid}. Can't refresh session.", LootLockerLogger.LogLevel.Warning); + LootLockerEventSystem.TriggerSessionExpired(refreshForPlayerUlid); onSessionRefreshedCallback?.Invoke(LootLockerResponseFactory.Failure(401, $"No stored player data for player with ulid {refreshForPlayerUlid}. Can't refresh session.", refreshForPlayerUlid), refreshForPlayerUlid, forExecutionItemId); yield break; } @@ -665,6 +831,7 @@ private IEnumerator RefreshSession(string refreshForPlayerUlid, string forExecut case LL_AuthPlatforms.Steam: { LootLockerLogger.Log($"Token has expired and token refresh is not supported for {playerData.CurrentPlatform.PlatformFriendlyString}", LootLockerLogger.LogLevel.Warning); + LootLockerEventSystem.TriggerSessionExpired(refreshForPlayerUlid); newSessionResponse = LootLockerResponseFactory .TokenExpiredError(refreshForPlayerUlid); @@ -675,6 +842,7 @@ private IEnumerator RefreshSession(string refreshForPlayerUlid, string forExecut default: { LootLockerLogger.Log($"Token refresh for platform {playerData.CurrentPlatform.PlatformFriendlyString} not supported", LootLockerLogger.LogLevel.Error); + LootLockerEventSystem.TriggerSessionExpired(refreshForPlayerUlid); newSessionResponse = LootLockerResponseFactory .TokenExpiredError(refreshForPlayerUlid); @@ -702,6 +870,7 @@ private void HandleSessionRefreshResult(LootLockerResponse newSessionResponse, s if (string.IsNullOrEmpty(tokenAfterRefresh) || tokenBeforeRefresh.Equals(playerData.SessionToken)) { // Session refresh failed so abort call chain + LootLockerEventSystem.TriggerSessionExpired(executionItem.RequestData.ForPlayerWithUlid); CallListenersAndMarkDone(executionItem, LootLockerResponseFactory.TokenExpiredError(executionItem.RequestData.ForPlayerWithUlid)); return; } @@ -724,6 +893,8 @@ private void HandleSessionRefreshResult(LootLockerResponse newSessionResponse, s } } + #endregion + #region Session Refresh Helper Methods private static bool ShouldRetryRequest(long statusCode, int timesRetried) @@ -954,7 +1125,59 @@ private static LootLockerErrorData ExtractErrorData(LootLockerResponse response) } return errorData; } + + /// + /// Performs periodic cleanup to prevent memory leaks from completed requests + /// + private void PerformPeriodicCleanup() + { + var now = DateTime.UtcNow; + + // Only cleanup if enough time has passed or if we're over the threshold + if ((now - _lastCleanupTime).TotalSeconds < CLEANUP_INTERVAL_SECONDS && + HTTPExecutionQueue.Count < CLEANUP_THRESHOLD) + { + return; + } + + _lastCleanupTime = now; + CleanupCompletedRequests(); + } + + /// + /// Removes completed requests from the execution queue to free memory + /// + private void CleanupCompletedRequests() + { + var requestsToRemove = new List(); + + // Find all completed requests + foreach (var kvp in HTTPExecutionQueue) + { + if (kvp.Value.Done) + { + requestsToRemove.Add(kvp.Key); + } + } + + // Remove completed requests + foreach (var requestId in requestsToRemove) + { + HTTPExecutionQueue.Remove(requestId); + } + + // Trim completed request history if it gets too large + while (CompletedRequestIDs.Count > MAX_COMPLETED_REQUEST_HISTORY) + { + CompletedRequestIDs.RemoveAt(0); + } + + if (requestsToRemove.Count > 0) + { + LootLockerLogger.Log($"Cleaned up {requestsToRemove.Count} completed HTTP requests. Queue size: {HTTPExecutionQueue.Count}", + LootLockerLogger.LogLevel.Verbose); + } + } #endregion } } -#endif diff --git a/Runtime/Client/LootLockerLifecycleManager.cs b/Runtime/Client/LootLockerLifecycleManager.cs new file mode 100644 index 000000000..a7b4347a8 --- /dev/null +++ b/Runtime/Client/LootLockerLifecycleManager.cs @@ -0,0 +1,672 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace LootLocker +{ + /// + /// Lifecycle state of the LifecycleManager + /// + public enum LifecycleManagerState + { + /// + /// Normal operation - services can be accessed and managed + /// + Ready, + + /// + /// Currently initializing services - prevent circular GetService calls + /// + Initializing, + + /// + /// Currently resetting services - prevent circular reset calls + /// + Resetting, + + /// + /// Application is shutting down - prevent new service access + /// + Quitting + } + + /// + /// Centralized lifecycle manager for all LootLocker services that need Unity GameObject management. + /// Handles the creation of a single GameObject and coordinates Unity lifecycle events across all services. + /// + public class LootLockerLifecycleManager : MonoBehaviour + { + #region Instance Handling + + private static LootLockerLifecycleManager _instance; + private static int _instanceId = 0; + private static GameObject _hostingGameObject = null; + private static readonly object _instanceLock = new object(); + + /// + /// Automatically initialize the lifecycle manager when the application starts. + /// This ensures all services are ready before any game code runs. + /// + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)] + private static void AutoInitialize() + { + if (_instance == null && Application.isPlaying) + { + LootLockerLogger.Log("Auto-initializing LootLocker LifecycleManager on application start", LootLockerLogger.LogLevel.Debug); + Instantiate(); + } + } + + /// + /// Get or create the lifecycle manager instance + /// + public static LootLockerLifecycleManager Instance + { + get + { + if (_state == LifecycleManagerState.Quitting) + { + return null; + } + + if (_instance == null) + { + Instantiate(); + } + return _instance; + } + } + + /// + /// Check if the lifecycle manager is ready and initialized + /// + public static bool IsReady => _instance != null && _instance._isInitialized; + + private static void Instantiate() + { + if (_instance != null) return; + + _state = LifecycleManagerState.Initializing; + + lock (_instanceLock) + { + var gameObject = new GameObject("LootLockerLifecycleManager"); + _instance = gameObject.AddComponent(); + _instanceId = _instance.GetInstanceID(); + _hostingGameObject = gameObject; + + if (Application.isPlaying) + { + DontDestroyOnLoad(gameObject); + } + + _instance.StartCoroutine(CleanUpOldInstances()); + _instance._RegisterAndInitializeAllServices(); + } + _state = LifecycleManagerState.Ready; + } + + private static void TeardownInstance() + { + if(_instance == null) return; + if(_state == LifecycleManagerState.Quitting) return; + lock (_instanceLock) + { + _state = LifecycleManagerState.Quitting; + + if (_instance != null) + { + _instance.ResetAllServices(); + +#if UNITY_EDITOR + if (_instance.gameObject != null) + DestroyImmediate(_instance.gameObject); +#else + if (_instance.gameObject != null) + Destroy(_instance.gameObject); +#endif + + _instance = null; + _instanceId = 0; + _hostingGameObject = null; + } + } + } + + public static IEnumerator CleanUpOldInstances() + { +#if UNITY_2020_1_OR_NEWER + LootLockerLifecycleManager[] managers = GameObject.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); +#else + LootLockerLifecycleManager[] managers = GameObject.FindObjectsOfType(); +#endif + foreach (LootLockerLifecycleManager manager in managers) + { + if (manager != null && _instanceId != manager.GetInstanceID() && manager.gameObject != null) + { +#if UNITY_EDITOR + DestroyImmediate(manager.gameObject); +#else + Destroy(manager.gameObject); +#endif + } + } + yield return null; + } + + public static void ResetInstance() + { + TeardownInstance(); + + Instantiate(); + } + +#if UNITY_EDITOR + [UnityEditor.InitializeOnEnterPlayMode] + static void OnEnterPlaymodeInEditor(UnityEditor.EnterPlayModeOptions options) + { + TeardownInstance(); + } +#endif + + #endregion + + #region Service Management + + private readonly Dictionary _services = new Dictionary(); + private readonly List _initializationOrder = new List(); + private bool _isInitialized = false; + private bool _serviceHealthMonitoringEnabled = true; + private Coroutine _healthMonitorCoroutine = null; + private static LifecycleManagerState _state = LifecycleManagerState.Ready; + private readonly object _serviceLock = new object(); + + /// + /// Create and register a MonoBehaviour service component to be managed by the lifecycle manager. + /// Service is immediately initialized upon registration. + /// + public static T RegisterService() where T : MonoBehaviour, ILootLockerService + { + var instance = Instance; + if (instance == null) + { + return null; + } + return instance._RegisterAndInitializeService(); + } + + /// + /// Get a service. The LifecycleManager auto-initializes on first access if needed. + /// + public static T GetService() where T : class, ILootLockerService + { + if (_state == LifecycleManagerState.Quitting || _state == LifecycleManagerState.Resetting) + { + LootLockerLogger.Log($"Access of service {typeof(T).Name} during {_state.ToString().ToLower()} was requested but denied", LootLockerLogger.LogLevel.Debug); + return null; + } + + // CRITICAL: Prevent circular dependency during initialization + if (_state == LifecycleManagerState.Initializing) + { + LootLockerLogger.Log($"Service {typeof(T).Name} requested during LifecycleManager initialization - this could cause deadlock. Returning null.", LootLockerLogger.LogLevel.Info); + return null; + } + + var instance = Instance; + if (instance == null) + { + LootLockerLogger.Log($"Cannot access service {typeof(T).Name} - LifecycleManager is not available", LootLockerLogger.LogLevel.Warning); + return null; + } + + var service = instance._GetService(); + if (service == null) + { + LootLockerLogger.Log($"Service {typeof(T).Name} is not registered. This indicates a bug in service registration.", LootLockerLogger.LogLevel.Warning); + return null; + } + return service; + } + + /// + /// Check if a service is registered + /// + public static bool HasService() where T : class, ILootLockerService + { + if (_state != LifecycleManagerState.Ready || _instance == null) + { + return false; + } + + return _instance._HasService(); + } + + /// + /// Unregister and cleanup a service from the lifecycle manager + /// + public static void UnregisterService() where T : class, ILootLockerService + { + if (_state != LifecycleManagerState.Ready || _instance == null) + { + LootLockerLogger.Log($"Ignoring unregister request for {typeof(T).Name} during {_state.ToString().ToLower()}", LootLockerLogger.LogLevel.Debug); + return; + } + + _instance._UnregisterService(); + } + + /// + /// Register all services and initialize them immediately in the defined order. + /// This replaces the previous split approach of separate register and initialize phases. + /// + private void _RegisterAndInitializeAllServices() + { + lock (_serviceLock) + { + if (_isInitialized) + { + return; + } + + _state = LifecycleManagerState.Initializing; // Set state to prevent circular GetService calls + + try + { + + // Register and initialize core services in defined order with dependency injection + + // 1. Initialize RateLimiter first (no dependencies) + var rateLimiter = _RegisterAndInitializeService(); + + // 2. Initialize EventSystem (no dependencies) + var eventSystem = _RegisterAndInitializeService(); + + // 3. Initialize StateData (no dependencies) + var stateData = _RegisterAndInitializeService(); + if (eventSystem != null) + { + stateData.SetEventSystem(eventSystem); + } + + // 4. Initialize HTTPClient and set RateLimiter dependency + var httpClient = _RegisterAndInitializeService(); + httpClient.SetRateLimiter(rateLimiter); + +#if LOOTLOCKER_ENABLE_PRESENCE + // 5. Initialize PresenceManager (no special dependencies) + var presenceManager = _RegisterAndInitializeService(); + if (eventSystem != null) + { + presenceManager.SetEventSystem(eventSystem); + } +#endif + + _isInitialized = true; + + // Change state to Ready before finishing initialization + _state = LifecycleManagerState.Ready; + + // Start service health monitoring + if (_serviceHealthMonitoringEnabled && Application.isPlaying) + { + _healthMonitorCoroutine = StartCoroutine(ServiceHealthMonitor()); + } + + LootLockerLogger.Log($"LifecycleManager initialization complete. Services registered: {string.Join(", ", _initializationOrder.Select(s => s.ServiceName))}", LootLockerLogger.LogLevel.Debug); + } + finally + { + // State is already set to Ready above, only set to Error if we had an exception + if (_state == LifecycleManagerState.Initializing) + { + _state = LifecycleManagerState.Ready; // Fallback in case of unexpected path + } + } + } + } + + /// + /// Register and immediately initialize a specific MonoBehaviour service + /// + private T _RegisterAndInitializeService() where T : MonoBehaviour, ILootLockerService + { + if (_HasService()) + { + return _GetService(); + } + + T service = null; + + lock (_serviceLock) + { + service = gameObject.AddComponent(); + + if (service == null) + { + return null; + } + + _services[typeof(T)] = service; + + try + { + service.Initialize(); + _initializationOrder.Add(service); + } + catch (Exception ex) + { + LootLockerLogger.Log($"Failed to initialize service {service.ServiceName}: {ex.Message}", LootLockerLogger.LogLevel.Warning); + } + } + return service; + } + + private T _GetService() where T : class, ILootLockerService + { + lock (_serviceLock) + { + _services.TryGetValue(typeof(T), out var service); + return service as T; + } + } + + private bool _HasService() where T : class, ILootLockerService + { + lock (_serviceLock) + { + return _services.ContainsKey(typeof(T)); + } + } + + private void _UnregisterService() where T : class, ILootLockerService + { + if(!_HasService()) + { + return; + } + T service = null; + lock (_serviceLock) + { + _services.TryGetValue(typeof(T), out var svc); + if(svc == null) + { + return; + } + service = svc as T; + + // Remove from initialization order if present + _initializationOrder.Remove(service); + + // Remove from services dictionary + _services.Remove(typeof(T)); + } + + _ResetService(service); + } + + private void _ResetService(ILootLockerService service) + { + if (service == null) return; + + try + { + service.Reset(); + + // Destroy the component if it's a MonoBehaviour + if (service is MonoBehaviour component) + { +#if UNITY_EDITOR + DestroyImmediate(component); +#else + Destroy(component); +#endif + } + } + catch (Exception ex) + { + LootLockerLogger.Log($"Error resetting service {service.ServiceName}: {ex.Message}", LootLockerLogger.LogLevel.Warning); + } + } + + #endregion + + #region Unity Lifecycle Events + + private void OnApplicationPause(bool pauseStatus) + { + lock (_serviceLock) + { + foreach (var service in _services.Values) + { + if (service == null) continue; + try + { + service.HandleApplicationPause(pauseStatus); + } + catch (Exception ex) + { + LootLockerLogger.Log($"Error in OnApplicationPause for service {service.ServiceName}: {ex.Message}", LootLockerLogger.LogLevel.Warning); + } + } + } + } + + private void OnApplicationFocus(bool hasFocus) + { + lock (_serviceLock) + { + foreach (var service in _services.Values) + { + if (service == null) continue; + try + { + service.HandleApplicationFocus(hasFocus); + } + catch (Exception ex) + { + LootLockerLogger.Log($"Error in OnApplicationFocus for service {service.ServiceName}: {ex.Message}", LootLockerLogger.LogLevel.Warning); + } + } + } + } + + private void OnApplicationQuit() + { + if (_state == LifecycleManagerState.Quitting) return; // Prevent multiple calls + + TeardownInstance(); + + ILootLockerService[] serviceSnapshot; + lock (_serviceLock) + { + serviceSnapshot = new ILootLockerService[_services.Values.Count]; + _services.Values.CopyTo(serviceSnapshot, 0); + } + + foreach (var service in serviceSnapshot) + { + if (service == null) continue; // Defensive null check + try + { + service.HandleApplicationQuit(); + } + catch (Exception ex) + { + LootLockerLogger.Log($"Error notifying service {service.ServiceName} of application quit: {ex.Message}", LootLockerLogger.LogLevel.Warning); + } + } + } + + private void OnDestroy() + { + TeardownInstance(); + } + + private void ResetAllServices() + { + // Stop health monitoring during reset + if (_healthMonitorCoroutine != null) + { + StopCoroutine(_healthMonitorCoroutine); + _healthMonitorCoroutine = null; + } + + // Reset services in reverse order of initialization + // This ensures dependencies are torn down in the correct order + ILootLockerService[] servicesSnapshot; + // Create a snapshot of services to avoid collection modification during iteration + lock (_serviceLock) + { + servicesSnapshot = new ILootLockerService[_initializationOrder.Count]; + _initializationOrder.CopyTo(servicesSnapshot, 0); + Array.Reverse(servicesSnapshot); + } + + foreach (var service in servicesSnapshot) + { + if (service == null) continue; + + _ResetService(service); + } + + // Clear the service collections after all resets are complete + _services.Clear(); + _initializationOrder.Clear(); + _isInitialized = false; + } + + /// + /// Service health monitoring coroutine - checks service health and restarts failed services + /// + private IEnumerator ServiceHealthMonitor() + { + const float healthCheckInterval = 30.0f; // Check every 30 seconds + + while (_serviceHealthMonitoringEnabled && Application.isPlaying) + { + yield return new WaitForSeconds(healthCheckInterval); + + if (_state != LifecycleManagerState.Ready) + { + continue; // Skip health checks during initialization/reset + } + + lock (_serviceLock) + { + // Check each service health + var servicesToRestart = new List(); + + foreach (var serviceEntry in _services) + { + var serviceType = serviceEntry.Key; + var service = serviceEntry.Value; + + if (service == null) + { + servicesToRestart.Add(serviceType); + continue; + } + + try + { + // Check if service is still initialized + if (!service.IsInitialized) + { + servicesToRestart.Add(serviceType); + } + } + catch (Exception) + { + servicesToRestart.Add(serviceType); + } + } + + // Restart failed services + foreach (var serviceType in servicesToRestart) + { + _RestartService(serviceType); + } + } + } + } + + /// + /// Restart a specific service that has failed + /// + private void _RestartService(Type serviceType) + { + if (_state != LifecycleManagerState.Ready) + { + return; + } + + if (!_services.ContainsKey(serviceType)) + { + return; // Service not registered + } + + _ResetService(_services[serviceType]); + + try + { + // Recreate and reinitialize the service based on its type + if (serviceType == typeof(RateLimiter)) + { + _RegisterAndInitializeService(); + } + else if (serviceType == typeof(LootLockerHTTPClient)) + { + var httpClient = _RegisterAndInitializeService(); + httpClient.SetRateLimiter(_GetService()); + } + else if (serviceType == typeof(LootLockerEventSystem)) + { + var eventSystem = _RegisterAndInitializeService(); + // Re-establish StateData event subscriptions if both services exist + var stateData = _GetService(); + if (stateData != null) + { + stateData.SetEventSystem(eventSystem); + } + var presenceManager = _GetService(); + if (presenceManager != null) + { + presenceManager.SetEventSystem(eventSystem); + } + } + else if (serviceType == typeof(LootLockerStateData)) + { + var stateData = _RegisterAndInitializeService(); + // Set up event subscriptions if EventSystem exists + var eventSystem = _GetService(); + if (eventSystem != null) + { + stateData.SetEventSystem(eventSystem); + } + } +#if LOOTLOCKER_ENABLE_PRESENCE + else if (serviceType == typeof(LootLockerPresenceManager)) + { + var presenceManager = _RegisterAndInitializeService(); + var eventSystem = _GetService(); + if (eventSystem != null) + { + presenceManager.SetEventSystem(eventSystem); + } + } +#endif + + LootLockerLogger.Log($"Successfully restarted service: {serviceType.Name}", LootLockerLogger.LogLevel.Info); + } + catch (Exception ex) + { + LootLockerLogger.Log($"Failed to restart service {serviceType.Name}: {ex.Message}", LootLockerLogger.LogLevel.Warning); + } + } + + #endregion + } +} diff --git a/Runtime/Client/LootLockerServerRequest.cs.meta b/Runtime/Client/LootLockerLifecycleManager.cs.meta similarity index 74% rename from Runtime/Client/LootLockerServerRequest.cs.meta rename to Runtime/Client/LootLockerLifecycleManager.cs.meta index 759ce1b7a..0c2f8ff42 100644 --- a/Runtime/Client/LootLockerServerRequest.cs.meta +++ b/Runtime/Client/LootLockerLifecycleManager.cs.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: ea1e587542df7fd4a969deb59a5fe972 +guid: b8c7d92e4f8d4e4b8a5c2d1e9f6a3b7c MonoImporter: externalObjects: {} serializedVersion: 2 @@ -8,4 +8,4 @@ MonoImporter: icon: {instanceID: 0} userData: assetBundleName: - assetBundleVariant: + assetBundleVariant: \ No newline at end of file diff --git a/Runtime/Client/LootLockerPresenceClient.cs b/Runtime/Client/LootLockerPresenceClient.cs new file mode 100644 index 000000000..7fda76a10 --- /dev/null +++ b/Runtime/Client/LootLockerPresenceClient.cs @@ -0,0 +1,1137 @@ +using System; +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; +using LootLocker.Requests; + +namespace LootLocker +{ + #region Enums and Data Types + + /// + /// Possible WebSocket connection states + /// + public enum LootLockerPresenceConnectionState + { + Disconnected, + Initializing, + Connecting, + Connected, + Authenticating, + Active, + Reconnecting, + Failed + } + + #endregion + + #region Request and Response Models + + /// + /// Authentication request sent to the Presence WebSocket + /// + [Serializable] + public class LootLockerPresenceAuthRequest + { + public string token { get; set; } + + public LootLockerPresenceAuthRequest(string sessionToken) + { + token = sessionToken; + } + } + + /// + /// Status update request for Presence + /// + [Serializable] + public class LootLockerPresenceStatusRequest + { + public string status { get; set; } + public Dictionary metadata { get; set; } + + public LootLockerPresenceStatusRequest(string status, Dictionary metadata = null) + { + this.status = status; + this.metadata = metadata; + } + } + + /// + /// Ping message to keep the WebSocket connection alive + /// + [Serializable] + public class LootLockerPresencePingRequest + { + public string type { get; set; } = "ping"; + public DateTime timestamp { get; set; } + + public LootLockerPresencePingRequest() + { + timestamp = DateTime.UtcNow; + } + } + + /// + /// Ping response from the server + /// + [Serializable] + public class LootLockerPresencePingResponse + { + public DateTime timestamp { get; set; } + } + + /// + /// Statistics about the presence connection to LootLocker + /// + [Serializable] + public class LootLockerPresenceConnectionStats + { + /// + /// The player ULID this connection belongs to + /// + public string playerUlid { get; set; } + + /// + /// Current connection state + /// + public LootLockerPresenceConnectionState connectionState { get; set; } + + /// + /// The last status that was sent to the server (e.g., "online", "in_game", "away") + /// + public string lastSentStatus { get; set; } + + /// + /// Current one-way latency to LootLocker in milliseconds + /// + public float currentLatencyMs { get; set; } + + /// + /// Average one-way latency over the last few pings in milliseconds + /// + public float averageLatencyMs { get; set; } + + /// + /// Minimum recorded one-way latency in milliseconds + /// + public float minLatencyMs { get; set; } + + /// + /// Maximum recorded one-way latency in milliseconds + /// + public float maxLatencyMs { get; set; } + + /// + /// Total number of pings sent + /// + public int totalPingsSent { get; set; } + + /// + /// Total number of pongs received + /// + public int totalPongsReceived { get; set; } + + /// + /// When the connection was established + /// + public DateTime connectionStartTime { get; set; } + + /// + /// How long the connection has been active + /// + public TimeSpan connectionDuration => DateTime.UtcNow - connectionStartTime; + + /// + /// Returns a formatted string representation of the connection statistics + /// + public override string ToString() + { + return $"LootLocker Presence Connection Statistics\n" + + $" Player ID: {playerUlid}\n" + + $" Connection State: {connectionState}\n" + + $" Last Status: {lastSentStatus}\n" + + $" Current Latency: {currentLatencyMs:F1} ms\n" + + $" Average Latency: {averageLatencyMs:F1} ms\n" + + $" Min/Max Latency: {minLatencyMs:F1} ms / {maxLatencyMs:F1} ms\n" + + $" Pings Sent/Received: {totalPingsSent}/{totalPongsReceived}\n" + + $" Connection Duration: {connectionDuration:hh\\:mm\\:ss}"; + } + } + + #endregion + + #region Event Delegates + + /// + /// Callback for presence operations + /// + public delegate void LootLockerPresenceCallback(bool success, string error = null); + + #endregion + + /// + /// Individual WebSocket client for LootLocker Presence feature + /// Managed internally by LootLockerPresenceManager + /// + public class LootLockerPresenceClient : MonoBehaviour, IDisposable + { + #region Private Fields + + // Configuration Constants + private const float PING_INTERVAL = 20f; + private const float RECONNECT_DELAY = 5f; + private const int MAX_RECONNECT_ATTEMPTS = 5; + private const int MAX_LATENCY_SAMPLES = 10; + + // WebSocket and Connection + private ClientWebSocket webSocket; + private CancellationTokenSource cancellationTokenSource; + private readonly ConcurrentQueue receivedMessages = new ConcurrentQueue(); + private LootLockerPresenceConnectionState connectionState = LootLockerPresenceConnectionState.Disconnected; + private string playerUlid; + private string sessionToken; + private static string webSocketUrl; + + // State tracking + private bool shouldReconnect = true; + private int reconnectAttempts = 0; + private Coroutine pingCoroutine; + private Coroutine statusUpdateCoroutine; + private Coroutine webSocketListenerCoroutine; + private bool isDestroying = false; + private bool isDisposed = false; + private bool isClientInitiatedDisconnect = false; // Track if disconnect is expected (due to session end) + private LootLockerPresenceCallback pendingConnectionCallback; // Store callback until authentication completes + + // Latency tracking + private readonly Queue pendingPingTimestamps = new Queue(); + private readonly Queue recentLatencies = new Queue(); + private LootLockerPresenceConnectionStats connectionStats = new LootLockerPresenceConnectionStats + { + minLatencyMs = float.MaxValue, + maxLatencyMs = 0f + }; + + #endregion + + #region Public Properties + + /// + /// Current connection state + /// + public LootLockerPresenceConnectionState ConnectionState => connectionState; + + /// + /// Whether the client is connected and active (authenticated and operational) + /// + public bool IsConnectedAndAuthenticated => connectionState == LootLockerPresenceConnectionState.Active; + + /// + /// Whether the client is currently connecting or reconnecting + /// + public bool IsConnecting => connectionState == LootLockerPresenceConnectionState.Connecting || + connectionState == LootLockerPresenceConnectionState.Reconnecting; + + /// + /// Whether the client is currently authenticating + /// + public bool IsAuthenticating => connectionState == LootLockerPresenceConnectionState.Authenticating; + + /// + /// The player ULID this client is associated with + /// + public string PlayerUlid => playerUlid; + + /// + /// The last status that was sent to the server (e.g., "online", "in_game", "away") + /// + public string LastSentStatus => ConnectionStats.lastSentStatus; + + /// + /// Get connection statistics including latency to LootLocker + /// + public LootLockerPresenceConnectionStats ConnectionStats => connectionStats; + + #endregion + + #region Unity Lifecycle + + private void Update() + { + // Process any messages that have been received in the main Unity thread + while (receivedMessages.TryDequeue(out string message)) + { + ProcessReceivedMessage(message); + } + } + + private void OnDestroy() + { + isDestroying = true; + Dispose(); + } + + /// + /// Dispose of the presence client and release resources without syncing state to the server. + /// Required by IDisposable interface, this method performs immediate cleanup. If you want to close the client due to runtime control flow, use Disconnect() instead. + /// + public void Dispose() + { + if (isDisposed) return; + + isDisposed = true; + shouldReconnect = false; + + StopCoroutines(); + + // Use synchronous cleanup for dispose to ensure immediate resource release + CleanupWebsocket(); + + // Clear all queues + while (receivedMessages.TryDequeue(out _)) { } + pendingPingTimestamps.Clear(); + recentLatencies.Clear(); + + ChangeConnectionState(LootLockerPresenceConnectionState.Disconnected); + } + + /// + /// Synchronous cleanup for disposal scenarios + /// + private void CleanupWebsocket() + { + try + { + // Cancel any ongoing operations + cancellationTokenSource?.Cancel(); + + // Close WebSocket if open + if (webSocket?.State == WebSocketState.Open) + { + try + { + // Close with a short timeout for disposal + var closeTask = webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, + "Client disposing", CancellationToken.None); + + // Don't wait indefinitely during disposal + if (!closeTask.Wait(TimeSpan.FromSeconds(2))) + { + LootLockerLogger.Log("WebSocket close timed out during disposal", LootLockerLogger.LogLevel.Debug); + } + } + catch (Exception ex) + { + LootLockerLogger.Log($"Error closing WebSocket during disposal: {ex.Message}", LootLockerLogger.LogLevel.Warning); + } + } + + // Always dispose resources + webSocket?.Dispose(); + webSocket = null; + + cancellationTokenSource?.Dispose(); + cancellationTokenSource = null; + } + catch (Exception ex) + { + LootLockerLogger.Log($"Error during synchronous cleanup: {ex.Message}", LootLockerLogger.LogLevel.Warning); + } + } + + private void StopCoroutines() + { + if (pingCoroutine != null) + { + StopCoroutine(pingCoroutine); + pingCoroutine = null; + } + + if (statusUpdateCoroutine != null) + { + StopCoroutine(statusUpdateCoroutine); + statusUpdateCoroutine = null; + } + + if(webSocketListenerCoroutine != null) + { + StopCoroutine(webSocketListenerCoroutine); + webSocketListenerCoroutine = null; + } + } + + #endregion + + #region Internal Methods + + /// + /// Initialize the presence client with player information + /// + internal void Initialize(string playerUlid, string sessionToken) + { + this.playerUlid = playerUlid; + this.sessionToken = sessionToken; + ChangeConnectionState(LootLockerPresenceConnectionState.Initializing); + } + + /// + /// Connect to the Presence WebSocket + /// + internal void Connect(LootLockerPresenceCallback onComplete = null) + { + if (isDisposed) + { + onComplete?.Invoke(false, "Client has been disposed"); + return; + } + + if (IsConnecting || IsConnectedAndAuthenticated) + { + onComplete?.Invoke(false, "Already connected or connecting"); + return; + } + + if (string.IsNullOrEmpty(sessionToken)) + { + ChangeConnectionState(LootLockerPresenceConnectionState.Failed, "No session token provided"); + onComplete?.Invoke(false, "No session token provided"); + return; + } + + shouldReconnect = true; + reconnectAttempts = 0; + pendingConnectionCallback = onComplete; + + StartCoroutine(ConnectCoroutine()); + } + + /// + /// Disconnect from the Presence WebSocket + /// + internal void Disconnect(LootLockerPresenceCallback onComplete = null) + { + // Prevent multiple disconnect attempts + if (isDestroying || isDisposed) + { + onComplete?.Invoke(true, null); + return; + } + + // Check if already disconnected + if (connectionState == LootLockerPresenceConnectionState.Disconnected || + connectionState == LootLockerPresenceConnectionState.Failed) + { + LootLockerLogger.Log($"Presence client already in disconnected state: {connectionState}", LootLockerLogger.LogLevel.Debug); + onComplete?.Invoke(true, null); + return; + } + + StartCoroutine(DisconnectCoroutine(onComplete)); + } + + /// + /// Send a status update to the Presence service + /// + internal void UpdateStatus(string status, Dictionary metadata = null, LootLockerPresenceCallback onComplete = null) + { + if (!IsConnectedAndAuthenticated) + { + // Stop any existing status update coroutine before starting a new one + if (statusUpdateCoroutine != null) + { + StopCoroutine(statusUpdateCoroutine); + statusUpdateCoroutine = null; + } + + statusUpdateCoroutine = StartCoroutine(WaitForConnectionAndUpdateStatus(status, metadata, onComplete)); + return; + } + + // Track the status being sent + connectionStats.lastSentStatus = status; + + var statusRequest = new LootLockerPresenceStatusRequest(status, metadata); + StartCoroutine(SendMessageCoroutine(LootLockerJson.SerializeObject(statusRequest), onComplete)); + } + + private IEnumerator WaitForConnectionAndUpdateStatus(string status, Dictionary metadata = null, LootLockerPresenceCallback onComplete = null) + { + int maxWaitTimes = 10; + int waitCount = 0; + while(!IsConnectedAndAuthenticated && waitCount < maxWaitTimes) + { + yield return new WaitForSeconds(0.1f); + waitCount++; + } + + // Clear the tracked coroutine reference when we're done + statusUpdateCoroutine = null; + + if (IsConnectedAndAuthenticated) + { + UpdateStatus(status, metadata, onComplete); + } + else + { + onComplete?.Invoke(false, "Not connected and authenticated after wait"); + } + } + + /// + /// Send a ping to maintain connection and measure latency + /// + internal void SendPing(LootLockerPresenceCallback onComplete = null) + { + if (!IsConnectedAndAuthenticated) + { + onComplete?.Invoke(false, "Not connected and authenticated"); + return; + } + + var pingRequest = new LootLockerPresencePingRequest(); + + // Track the ping timestamp for latency calculation + pendingPingTimestamps.Enqueue(pingRequest.timestamp); + + // Clean up old pending pings (in case pongs are lost) + while (pendingPingTimestamps.Count > 10) + { + pendingPingTimestamps.Dequeue(); + } + + StartCoroutine(SendMessageCoroutine(LootLockerJson.SerializeObject(pingRequest), (success, error) => { + if (success) + { + // Only count the ping as sent if it was actually sent successfully + connectionStats.totalPingsSent++; + } + else + { + // Remove the timestamp since the ping failed to send + if (pendingPingTimestamps.Count > 0) + { + // Remove the most recent timestamp (the one we just added) + var tempQueue = new Queue(); + while (pendingPingTimestamps.Count > 1) + { + tempQueue.Enqueue(pendingPingTimestamps.Dequeue()); + } + if (pendingPingTimestamps.Count > 0) pendingPingTimestamps.Dequeue(); // Remove the failed ping + while (tempQueue.Count > 0) + { + pendingPingTimestamps.Enqueue(tempQueue.Dequeue()); + } + } + } + onComplete?.Invoke(success, error); + })); + } + + #endregion + + #region Private Methods + + private IEnumerator ConnectCoroutine() + { + if (isDestroying || isDisposed) + { + HandleConnectionError("Presence client is destroying or disposed"); + yield break; + } + if (string.IsNullOrEmpty(sessionToken)) + { + HandleConnectionError("Invalid session token"); + yield break; + } + + // Set state + ChangeConnectionState(reconnectAttempts > 0 ? + LootLockerPresenceConnectionState.Reconnecting : + LootLockerPresenceConnectionState.Connecting); + + // Cleanup any existing connections + CleanupWebsocket(); + + // Initialize WebSocket + try + { + webSocket = new ClientWebSocket(); + cancellationTokenSource = new CancellationTokenSource(); + + // Cache base URL on first use to avoid repeated string operations + if (string.IsNullOrEmpty(webSocketUrl)) + { + webSocketUrl = LootLockerConfig.current.webSocketBaseUrl + "/presence/v1"; + } + } + catch (Exception ex) + { + HandleConnectionError("Failed to initialize WebSocket with exception: " + ex.Message); + } + + var uri = new Uri(webSocketUrl); + + // Start WebSocket connection in background + var connectTask = webSocket.ConnectAsync(uri, cancellationTokenSource.Token); + + // Wait for connection with timeout + float timeoutSeconds = 10f; + float elapsed = 0f; + + while (!connectTask.IsCompleted && elapsed < timeoutSeconds) + { + elapsed += Time.deltaTime; + yield return null; + } + + if (!connectTask.IsCompleted || connectTask.IsFaulted) + { + string error = connectTask.Exception?.Message ?? "Connection timeout"; + HandleConnectionError(error); + yield break; + } + + ChangeConnectionState(LootLockerPresenceConnectionState.Connected); + reconnectAttempts = 0; + + InitializeConnectionStats(); + + // Start listening for messages + webSocketListenerCoroutine = StartCoroutine(ListenForMessagesCoroutine()); + + // Send authentication + yield return StartCoroutine(AuthenticateCoroutine()); + } + + private void InitializeConnectionStats() + { + connectionStats.playerUlid = this.playerUlid; + connectionStats.connectionState = this.connectionState; + connectionStats.lastSentStatus = this.ConnectionStats.lastSentStatus; + connectionStats.connectionStartTime = DateTime.UtcNow; + connectionStats.totalPingsSent = 0; + connectionStats.totalPongsReceived = 0; + connectionStats.currentLatencyMs = 0f; + connectionStats.averageLatencyMs = 0f; + connectionStats.minLatencyMs = float.MaxValue; + connectionStats.maxLatencyMs = 0f; + recentLatencies.Clear(); + pendingPingTimestamps.Clear(); + } + + private void HandleConnectionError(string errorMessage) + { + LootLockerLogger.Log($"Failed to connect to Presence WebSocket: {errorMessage}", LootLockerLogger.LogLevel.Warning); + ChangeConnectionState(LootLockerPresenceConnectionState.Failed, errorMessage); + + // Invoke pending callback on error + pendingConnectionCallback?.Invoke(false, errorMessage); + pendingConnectionCallback = null; + } + + private void HandleAuthenticationError(string errorMessage) + { + LootLockerLogger.Log($"Failed to authenticate Presence WebSocket: {errorMessage}", LootLockerLogger.LogLevel.Warning); + ChangeConnectionState(LootLockerPresenceConnectionState.Failed, errorMessage); + + // Invoke pending callback on error + pendingConnectionCallback?.Invoke(false, errorMessage); + pendingConnectionCallback = null; + } + + private IEnumerator DisconnectCoroutine(LootLockerPresenceCallback onComplete = null) + { + // Don't attempt disconnect if already destroyed + if (isDestroying || isDisposed) + { + onComplete?.Invoke(true, null); + yield break; + } + + isClientInitiatedDisconnect = true; + shouldReconnect = false; + + StopCoroutines(); + + // Close WebSocket connection + bool closeSuccess = true; + string closeErrorMessage = null; + if (webSocket != null) + { + yield return StartCoroutine(CloseWebSocketCoroutine((success, errorMessage) => { + closeSuccess = success; + closeErrorMessage = errorMessage; + })); + } + + // Always cleanup regardless of close success + CleanupWebsocket(); + + ChangeConnectionState(LootLockerPresenceConnectionState.Disconnected); + + isClientInitiatedDisconnect = false; + + onComplete?.Invoke(closeSuccess, closeSuccess ? null : closeErrorMessage); + } + + private IEnumerator CloseWebSocketCoroutine(System.Action onComplete) + { + bool closeSuccess = true; + System.Threading.Tasks.Task closeTask = null; + + try + { + // Check if WebSocket is already closed/aborted by server + if (webSocket.State == WebSocketState.Aborted || + webSocket.State == WebSocketState.Closed) + { + LootLockerLogger.Log($"WebSocket already closed by server (state: {webSocket.State}), cleanup complete", LootLockerLogger.LogLevel.Debug); + onComplete?.Invoke(true, "WebSeocket already closed by server"); + yield break; + } + + // Only attempt to close if the WebSocket is in a valid state for closing + if (webSocket.State == WebSocketState.Open || + webSocket.State == WebSocketState.CloseReceived || + webSocket.State == WebSocketState.CloseSent) + { + // Don't cancel the token before close - let the close complete normally + closeTask = webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, + "Client disconnecting", CancellationToken.None); + } + else + { + LootLockerLogger.Log($"WebSocket in unexpected state {webSocket.State}, treating as already closed", LootLockerLogger.LogLevel.Debug); + onComplete?.Invoke(true, "WebSocket in unexpected state, treated as closed"); + yield break; + } + } + catch (Exception ex) + { + // If we get an exception during close (like WebSocket aborted), treat it as already closed + if (ex.Message.Contains("invalid state") || ex.Message.Contains("Aborted")) + { + if (isClientInitiatedDisconnect) + { + LootLockerLogger.Log($"WebSocket was closed by server during session end - this is normal", LootLockerLogger.LogLevel.Debug); + } + else + { + LootLockerLogger.Log($"WebSocket was aborted by server unexpectedly: {ex.Message}", LootLockerLogger.LogLevel.Debug); + } + closeSuccess = true; // Treat server-side abort as successful close + } + else + { + closeSuccess = false; + LootLockerLogger.Log($"Error during WebSocket disconnect: {ex.Message}", LootLockerLogger.LogLevel.Warning); + } + + onComplete?.Invoke(closeSuccess, closeSuccess ? null : "Error during disconnect"); + yield break; + } + + // Wait for close task completion outside of try-catch to allow yield + if (closeTask != null) + { + float timeoutSeconds = 5f; + float elapsed = 0f; + + while (!closeTask.IsCompleted && elapsed < timeoutSeconds) + { + elapsed += Time.deltaTime; + yield return null; + } + + try + { + if (closeTask.IsFaulted) + { + var exception = closeTask.Exception?.InnerException ?? closeTask.Exception; + if (exception?.Message.Contains("invalid state") == true || + exception?.Message.Contains("Aborted") == true) + { + if (isClientInitiatedDisconnect) + { + LootLockerLogger.Log("WebSocket close completed - session ended as expected", LootLockerLogger.LogLevel.Debug); + } + else + { + LootLockerLogger.Log($"WebSocket was aborted during close task: {exception.Message}", LootLockerLogger.LogLevel.Debug); + } + closeSuccess = true; // Treat server-side abort during close as successful + } + else + { + closeSuccess = false; + if (isClientInitiatedDisconnect) + { + LootLockerLogger.Log($"Error during expected disconnect: {exception?.Message}", LootLockerLogger.LogLevel.Debug); + } + else + { + LootLockerLogger.Log($"Error during disconnect: {exception?.Message}", LootLockerLogger.LogLevel.Warning); + } + } + } + } + catch (Exception ex) + { + // Catch any exceptions that occur while checking the task result + if (isClientInitiatedDisconnect) + { + LootLockerLogger.Log($"Exception during expected disconnect task check: {ex.Message}", LootLockerLogger.LogLevel.Debug); + } + else + { + LootLockerLogger.Log($"Exception during disconnect task check: {ex.Message}", LootLockerLogger.LogLevel.Debug); + } + closeSuccess = true; // Treat exceptions during expected disconnect as success + } + } + + // Cancel operations after close is complete + try + { + cancellationTokenSource?.Cancel(); + } + catch (Exception ex) + { + LootLockerLogger.Log($"Error cancelling token source: {ex.Message}", LootLockerLogger.LogLevel.Debug); + } + + onComplete?.Invoke(closeSuccess, closeSuccess ? null : "Error during disconnect"); + } + + private IEnumerator AuthenticateCoroutine() + { + if (webSocket?.State != WebSocketState.Open) + { + HandleAuthenticationError("WebSocket not open for authentication"); + yield break; + } + + ChangeConnectionState(LootLockerPresenceConnectionState.Authenticating); + + var authRequest = new LootLockerPresenceAuthRequest(sessionToken); + string jsonPayload = LootLockerJson.SerializeObject(authRequest); + + yield return StartCoroutine(SendMessageCoroutine(jsonPayload, (bool success, string error) => { + if (!success) { + HandleAuthenticationError(error ?? "Failed to send authentication message"); + return; + } + })); + } + + private IEnumerator SendMessageCoroutine(string message, LootLockerPresenceCallback onComplete = null) + { + if (webSocket?.State != WebSocketState.Open || cancellationTokenSource?.Token.IsCancellationRequested == true) + { + onComplete?.Invoke(false, "WebSocket not connected"); + yield break; + } + + byte[] buffer = Encoding.UTF8.GetBytes(message); + var sendTask = webSocket.SendAsync(new ArraySegment(buffer), + WebSocketMessageType.Text, true, cancellationTokenSource.Token); + + // Wait for send with timeout + float timeoutSeconds = 5f; + float elapsed = 0f; + + while (!sendTask.IsCompleted && elapsed < timeoutSeconds) + { + elapsed += Time.deltaTime; + yield return null; + } + + if (sendTask.IsCompleted && !sendTask.IsFaulted) + { + LootLockerLogger.Log($"Sent Presence message: {message}", LootLockerLogger.LogLevel.Debug); + onComplete?.Invoke(true); + } + else + { + string error = sendTask.Exception?.GetBaseException()?.Message ?? "Send timeout"; + LootLockerLogger.Log($"Failed to send Presence message: {error}", LootLockerLogger.LogLevel.Warning); + onComplete?.Invoke(false, error); + } + } + + private IEnumerator ListenForMessagesCoroutine() + { + var buffer = new byte[4096]; + + while (webSocket?.State == WebSocketState.Open && + cancellationTokenSource?.Token.IsCancellationRequested == false) + { + var receiveTask = webSocket.ReceiveAsync(new ArraySegment(buffer), + cancellationTokenSource.Token); + + yield return new WaitUntil(() => receiveTask.IsCompleted || receiveTask.IsFaulted || isDestroying || isDisposed); + + if(isDestroying || isDisposed) + { + yield break; + } + + if (receiveTask.IsFaulted) + { + // Handle receive error + var exception = receiveTask.Exception?.GetBaseException(); + if (exception is OperationCanceledException || exception is TaskCanceledException) + { + if (!isClientInitiatedDisconnect) + { + LootLockerLogger.Log("Presence WebSocket listening cancelled", LootLockerLogger.LogLevel.Debug); + } + } + else + { + string errorMessage = exception?.Message ?? "Unknown error"; + LootLockerLogger.Log($"Error listening for Presence messages: {errorMessage}", LootLockerLogger.LogLevel.Warning); + + // Only attempt reconnect for unexpected disconnects + if (shouldReconnect && reconnectAttempts < MAX_RECONNECT_ATTEMPTS && !isClientInitiatedDisconnect) + { + // Use longer delay for server-side connection termination + bool isServerSideClose = errorMessage.Contains("remote party closed the WebSocket connection without completing the close handshake"); + float reconnectDelay = isServerSideClose ? RECONNECT_DELAY * 2f : RECONNECT_DELAY; + + StartCoroutine(ScheduleReconnectCoroutine(reconnectDelay)); + } + } + break; + } + + var result = receiveTask.Result; + + if (result.MessageType == WebSocketMessageType.Text) + { + string message = Encoding.UTF8.GetString(buffer, 0, result.Count); + receivedMessages.Enqueue(message); + } + else if (result.MessageType == WebSocketMessageType.Close) + { + if (!isClientInitiatedDisconnect) + { + LootLockerLogger.Log("Presence WebSocket closed by server", LootLockerLogger.LogLevel.Debug); + } + + + isClientInitiatedDisconnect = true; + shouldReconnect = false; + + StopCoroutines(); + + // No need to close websocket here, as server initiated close has already happened + + CleanupWebsocket(); + + // Notify manager that this client is disconnected so it can clean up + ChangeConnectionState(LootLockerPresenceConnectionState.Disconnected); + break; + } + } + } + + private void ProcessReceivedMessage(string message) + { + try + { + if (message.Contains("authenticated")) + { + HandleAuthenticationResponse(message); + } + else if (message.Contains("pong")) + { + HandlePongResponse(message); + } + else if (message.Contains("error")) + { + HandleErrorResponse(message); + } + else + { + HandleGeneralMessage(message); + } + } + catch (Exception ex) + { + LootLockerLogger.Log($"Error processing Presence message: {ex.Message}", LootLockerLogger.LogLevel.Warning); + } + } + + private void HandleAuthenticationResponse(string message) + { + try + { + ChangeConnectionState(LootLockerPresenceConnectionState.Active); + + if (pingCoroutine != null) + { + StopCoroutine(pingCoroutine); + } + + pingCoroutine = StartCoroutine(PingCoroutine()); + + // Reset reconnect attempts on successful authentication + reconnectAttempts = 0; + } + catch (Exception ex) + { + string errorMessage = $"Error handling authentication response: {ex.Message}"; + LootLockerLogger.Log(errorMessage, LootLockerLogger.LogLevel.Warning); + + // Invoke pending callback on exception + pendingConnectionCallback?.Invoke(false, errorMessage); + pendingConnectionCallback = null; + } + + try { + // Invoke pending connection callback on successful authentication + pendingConnectionCallback?.Invoke(true, null); + pendingConnectionCallback = null; + } + catch (Exception ex) { + LootLockerLogger.Log($"Error invoking connection callback: {ex.Message}", LootLockerLogger.LogLevel.Warning); + } + } + + private void HandlePongResponse(string message) + { + try + { + var pongResponse = LootLockerJson.DeserializeObject(message); + + // Calculate latency if we have matching ping timestamp + if (pendingPingTimestamps.Count > 0 && pongResponse?.timestamp != default(DateTime)) + { + var pongReceivedTime = DateTime.UtcNow; + var pingTimestamp = pendingPingTimestamps.Dequeue(); + + // Calculate round-trip time in milliseconds + var latencyMs = (long)(pongReceivedTime - pingTimestamp).TotalMilliseconds; + + if (latencyMs >= 0) // Sanity check + { + UpdateLatencyStats(latencyMs); + } + + // Only count the pong if we had a matching ping timestamp + connectionStats.totalPongsReceived++; + } + } + catch (Exception ex) + { + LootLockerLogger.Log($"Error handling pong response: {ex.Message}", LootLockerLogger.LogLevel.Warning); + } + } + + private void UpdateLatencyStats(long roundTripMs) + { + // Convert round-trip time to one-way latency (industry standard) + var latency = (float)roundTripMs / 2.0f; + + // Update current latency + connectionStats.currentLatencyMs = latency; + + // Update min/max + if (latency < connectionStats.minLatencyMs) + connectionStats.minLatencyMs = latency; + if (latency > connectionStats.maxLatencyMs) + connectionStats.maxLatencyMs = latency; + + // Add to recent latencies for average calculation + recentLatencies.Enqueue(latency); + if (recentLatencies.Count > MAX_LATENCY_SAMPLES) + { + recentLatencies.Dequeue(); + } + + // Calculate average from recent samples + var sum = 0f; + foreach (var sample in recentLatencies) + { + sum += sample; + } + connectionStats.averageLatencyMs = sum / recentLatencies.Count; + } + + private void HandleErrorResponse(string message) + { + LootLockerLogger.Log($"Received presence error: {message}", LootLockerLogger.LogLevel.Warning); + } + + private void HandleGeneralMessage(string message) + { + // This method can be extended for other specific message types + LootLockerLogger.Log($"Received general presence message: {message}", LootLockerLogger.LogLevel.Debug); + } + + private void ChangeConnectionState(LootLockerPresenceConnectionState newState, string error = null) + { + if (connectionState != newState) + { + var previousState = connectionState; + connectionState = newState; + + // Update connection stats with new state + connectionStats.connectionState = newState; + + LootLockerLogger.Log($"Presence connection state changed: {previousState} -> {newState}", LootLockerLogger.LogLevel.Debug); + + // Stop ping routine if we're no longer active + if (newState != LootLockerPresenceConnectionState.Active && pingCoroutine != null) + { + LootLockerLogger.Log("Stopping ping routine due to connection state change", LootLockerLogger.LogLevel.Debug); + StopCoroutine(pingCoroutine); + pingCoroutine = null; + } + + // Then notify external systems via the unified event system + LootLockerEventSystem.TriggerPresenceConnectionStateChanged(playerUlid, previousState, newState, error); + } + } + + private IEnumerator PingCoroutine() + { + + while (IsConnectedAndAuthenticated && !isDestroying) + { + SendPing(); + yield return new WaitForSeconds(PING_INTERVAL); + } + + LootLockerLogger.Log($"Ping routine ended. Connected: {IsConnectedAndAuthenticated}, Destroying: {isDestroying}", LootLockerLogger.LogLevel.Debug); + } + + private IEnumerator ScheduleReconnectCoroutine(float customDelay = -1f) + { + if (!shouldReconnect || isDestroying || reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) + { + yield break; + } + + reconnectAttempts++; + float delayToUse = customDelay > 0 ? customDelay : RECONNECT_DELAY; + LootLockerLogger.Log($"Scheduling Presence reconnect attempt {reconnectAttempts}/{MAX_RECONNECT_ATTEMPTS} in {delayToUse} seconds", LootLockerLogger.LogLevel.Debug); + + yield return new WaitForSeconds(delayToUse); + + if (shouldReconnect && !isDestroying) + { + StartCoroutine(ConnectCoroutine()); + } + } + + #endregion + } +} \ No newline at end of file diff --git a/Runtime/Client/LootLockerPresenceClient.cs.meta b/Runtime/Client/LootLockerPresenceClient.cs.meta new file mode 100644 index 000000000..d8b239458 --- /dev/null +++ b/Runtime/Client/LootLockerPresenceClient.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9183b7165d1ddb24591c5bd533338712 \ No newline at end of file diff --git a/Runtime/Client/LootLockerPresenceManager.cs b/Runtime/Client/LootLockerPresenceManager.cs new file mode 100644 index 000000000..b84f37739 --- /dev/null +++ b/Runtime/Client/LootLockerPresenceManager.cs @@ -0,0 +1,1107 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using UnityEngine; +using LootLocker.Requests; +#if UNITY_EDITOR +using UnityEditor; +#endif + +namespace LootLocker +{ + /// + /// Manager for all LootLocker Presence functionality + /// Automatically manages presence connections for all active sessions + /// + public class LootLockerPresenceManager : MonoBehaviour, ILootLockerService + { + + #region Private Fields + + /// + /// Track connected sessions for proper cleanup + /// + private readonly HashSet _connectedSessions = new HashSet(); + + // Instance fields + private readonly Dictionary _activeClients = new Dictionary(); + private readonly HashSet _connectingClients = new HashSet(); // Track clients that are in the process of connecting + private readonly object _activeClientsLock = new object(); // Thread safety for _activeClients dictionary + private bool _isEnabled = true; + private bool _autoConnectEnabled = true; + private bool _autoDisconnectOnFocusChange = false; // Developer-configurable setting for focus-based disconnection + private bool _isShuttingDown = false; // Track if we're shutting down to prevent double disconnect + + #endregion + + #region Public Fields + /// + /// Whether the presence system is enabled + /// + public static bool IsEnabled + { + get => Get()?._isEnabled ?? false; + set + { + var instance = Get(); + if(!instance) + return; + instance._SetPresenceEnabled(value); + } + } + + /// + /// Whether presence should automatically connect when sessions are started + /// + public static bool AutoConnectEnabled + { + get => Get()?._autoConnectEnabled ?? false; + set { + var instance = Get(); + if (instance != null) + { + instance._SetAutoConnectEnabled(value); + } + } + } + + /// + /// Whether presence should automatically disconnect when the application loses focus or is paused. + /// When enabled, presence will disconnect when the app goes to background and reconnect when it returns to foreground. + /// Useful for saving battery on mobile or managing resources. + /// + public static bool AutoDisconnectOnFocusChange + { + get => Get()?._autoDisconnectOnFocusChange ?? false; + set { var instance = Get(); if (instance != null) instance._autoDisconnectOnFocusChange = value; } + } + + /// + /// Get all active presence client ULIDs + /// + public static IEnumerable ActiveClientUlids + { + get + { + var instance = Get(); + if (instance == null) return new List(); + + lock (instance._activeClientsLock) + { + return new List(instance._activeClients.Keys); + } + } + } + + #endregion + + #region Singleton Management + + private static LootLockerPresenceManager _instance; + private static readonly object _instanceLock = new object(); + + /// + /// Get the PresenceManager service instance + /// Services are automatically registered and initialized on first access if needed. + /// + public static LootLockerPresenceManager Get() + { + if (_instance != null) + { + return _instance; + } + + lock (_instanceLock) + { + if (_instance == null) + { + _instance = LootLockerLifecycleManager.GetService(); + } + return _instance; + } + } + + #endregion + + #region ILootLockerService Implementation + + public bool IsInitialized { get; private set; } = false; + public string ServiceName => "PresenceManager"; + + void ILootLockerService.Initialize() + { + if (IsInitialized) return; + #if LOOTLOCKER_ENABLE_PRESENCE + _isEnabled = LootLockerConfig.current.enablePresence; + _autoConnectEnabled = LootLockerConfig.current.enablePresenceAutoConnect; + _autoDisconnectOnFocusChange = LootLockerConfig.current.enablePresenceAutoDisconnectOnFocusChange; + #else + _isEnabled = false; + _autoConnectEnabled = false; + _autoDisconnectOnFocusChange = false; + #endif + + IsInitialized = true; + } + + /// + /// Perform deferred initialization after services are fully ready + /// + public void SetEventSystem(LootLockerEventSystem eventSystemInstance) + { + + if (!_isEnabled || !IsInitialized) + { + return; + } + + // Subscribe to session events (handle errors separately) + try + { + _SubscribeToEvents(eventSystemInstance); + } + catch (Exception ex) + { + LootLockerLogger.Log($"Error subscribing to session events: {ex.Message}", LootLockerLogger.LogLevel.Warning); + } + + // Auto-connect existing active sessions if enabled + StartCoroutine(_AutoConnectExistingSessions()); + } + + void ILootLockerService.Reset() + { + _DisconnectAll(); + + _UnsubscribeFromEvents(); + + _connectedSessions?.Clear(); + + IsInitialized = false; + lock(_instanceLock) { + _instance = null; + } + } + + // TODO: Handle pause/focus better to avoid concurrency issues + void ILootLockerService.HandleApplicationPause(bool pauseStatus) + { + if(!IsInitialized || !_autoDisconnectOnFocusChange || !_isEnabled) + { + return; + } + + if (pauseStatus) + { + LootLockerLogger.Log("Application paused - disconnecting all presence connections (auto-disconnect enabled)", LootLockerLogger.LogLevel.Debug); + DisconnectAll(); + } + else + { + LootLockerLogger.Log("Application resumed - will reconnect presence connections", LootLockerLogger.LogLevel.Debug); + StartCoroutine(_AutoConnectExistingSessions()); + } + } + + void ILootLockerService.HandleApplicationFocus(bool hasFocus) + { + if(!IsInitialized || !_autoDisconnectOnFocusChange || !_isEnabled) + return; + + if (hasFocus) + { + // App gained focus - ensure presence is reconnected + LootLockerLogger.Log("Application gained focus - ensuring presence connections (auto-disconnect enabled)", LootLockerLogger.LogLevel.Debug); + StartCoroutine(_AutoConnectExistingSessions()); + } + else + { + // App lost focus - disconnect presence to save resources + LootLockerLogger.Log("Application lost focus - disconnecting presence (auto-disconnect enabled)", LootLockerLogger.LogLevel.Debug); + DisconnectAll(); + } + } + + void ILootLockerService.HandleApplicationQuit() + { + _isShuttingDown = true; + + _UnsubscribeFromEvents(); + _DisconnectAll(); + _connectedSessions?.Clear(); + } + + #endregion + + #region Event Subscription Handling + + /// + /// Subscribe to session lifecycle events + /// + private void _SubscribeToEvents(LootLockerEventSystem eventSystemInstance) + { + if (!_isEnabled || _isShuttingDown) + { + return; + } + + if (eventSystemInstance == null) + { + eventSystemInstance = LootLockerLifecycleManager.GetService(); + if (eventSystemInstance == null) + { + LootLockerLogger.Log("Cannot subscribe to session events: EventSystem service not available", LootLockerLogger.LogLevel.Warning); + return; + } + } + + try { + // Subscribe to session started events + eventSystemInstance.SubscribeInstance( + LootLockerEventType.SessionStarted, + _HandleSessionStartedEvent + ); + + // Subscribe to session refreshed events + eventSystemInstance.SubscribeInstance( + LootLockerEventType.SessionRefreshed, + _HandleSessionRefreshedEvent + ); + + // Subscribe to session ended events + eventSystemInstance.SubscribeInstance( + LootLockerEventType.SessionEnded, + _HandleSessionEndedEvent + ); + + // Subscribe to session expired events + eventSystemInstance.SubscribeInstance( + LootLockerEventType.SessionExpired, + _HandleSessionExpiredEvent + ); + + // Subscribe to local session deactivated events + eventSystemInstance.SubscribeInstance( + LootLockerEventType.LocalSessionDeactivated, + _HandleLocalSessionDeactivatedEvent + ); + + // Subscribe to local session activated events + eventSystemInstance.SubscribeInstance( + LootLockerEventType.LocalSessionActivated, + _HandleLocalSessionActivatedEvent + ); + + // Subscribe to presence client connection change events + eventSystemInstance.SubscribeInstance( + LootLockerEventType.PresenceConnectionStateChanged, + _HandleClientConnectionStateChanged + ); + } + catch (Exception ex) + { + LootLockerLogger.Log($"Error subscribing to session events: {ex.Message}", LootLockerLogger.LogLevel.Warning); + } + } + + /// + /// Unsubscribe from session lifecycle events + /// + private void _UnsubscribeFromEvents() + { + if (!LootLockerLifecycleManager.HasService() || _isShuttingDown) + { + return; + } + LootLockerEventSystem.Unsubscribe( + LootLockerEventType.SessionStarted, + _HandleSessionStartedEvent + ); + + LootLockerEventSystem.Unsubscribe( + LootLockerEventType.SessionRefreshed, + _HandleSessionRefreshedEvent + ); + + LootLockerEventSystem.Unsubscribe( + LootLockerEventType.SessionEnded, + _HandleSessionEndedEvent + ); + + LootLockerEventSystem.Unsubscribe( + LootLockerEventType.SessionExpired, + _HandleSessionExpiredEvent + ); + + LootLockerEventSystem.Unsubscribe( + LootLockerEventType.LocalSessionDeactivated, + _HandleLocalSessionDeactivatedEvent + ); + + LootLockerEventSystem.Unsubscribe( + LootLockerEventType.LocalSessionActivated, + _HandleLocalSessionActivatedEvent + ); + + LootLockerEventSystem.Unsubscribe( + LootLockerEventType.PresenceConnectionStateChanged, + _HandleClientConnectionStateChanged + ); + } + + /// + /// Handle session started events + /// + private void _HandleSessionStartedEvent(LootLockerSessionStartedEventData eventData) + { + if (!_isEnabled || !_autoConnectEnabled || _isShuttingDown) + { + return; + } + + var playerData = eventData.playerData; + if (playerData != null && !string.IsNullOrEmpty(playerData.ULID)) + { + LootLockerLogger.Log($"Session started event received for {playerData.ULID}, auto-connecting presence", LootLockerLogger.LogLevel.Debug); + + // Create and initialize client immediately, but defer connection + var client = _CreatePresenceClientWithoutConnecting(playerData); + if (client == null) + { + return; + } + + // Start auto-connect in a coroutine to avoid blocking the event thread + StartCoroutine(_DelayPresenceClientConnection(playerData)); + } + } + + /// + /// Handle session refreshed events + /// + private void _HandleSessionRefreshedEvent(LootLockerSessionRefreshedEventData eventData) + { + if (!_isEnabled || !_autoConnectEnabled || _isShuttingDown) + { + return; + } + + var playerData = eventData.playerData; + if (playerData != null && !string.IsNullOrEmpty(playerData.ULID)) + { + LootLockerLogger.Log($"Session refreshed event received for {playerData.ULID}, reconnecting presence with new token", LootLockerLogger.LogLevel.Debug); + + // Disconnect existing connection first, then reconnect with new session token + DisconnectPresence(playerData.ULID, (disconnectSuccess, disconnectError) => { + if (disconnectSuccess) + { + // Only reconnect if auto-connect is enabled + if (_autoConnectEnabled) + { + ConnectPresence(playerData.ULID); + } + } + }); + } + } + + /// + /// Handle session ended events + /// + private void _HandleSessionEndedEvent(LootLockerSessionEndedEventData eventData) + { + if(!_isEnabled || _isShuttingDown) + { + return; + } + if (!string.IsNullOrEmpty(eventData.playerUlid)) + { + LootLockerLogger.Log($"Session ended event received for {eventData.playerUlid}, disconnecting presence", LootLockerLogger.LogLevel.Debug); + _DisconnectPresenceForUlid(eventData.playerUlid); + } + } + + /// + /// Handle session expired events + /// + private void _HandleSessionExpiredEvent(LootLockerSessionExpiredEventData eventData) + { + if(!_isEnabled || _isShuttingDown) + { + return; + } + if (!string.IsNullOrEmpty(eventData.playerUlid)) + { + LootLockerLogger.Log($"Session expired event received for {eventData.playerUlid}, disconnecting presence", LootLockerLogger.LogLevel.Debug); + _DisconnectPresenceForUlid(eventData.playerUlid); + } + } + + /// + /// Handle local session deactivated events + /// Note: If this is part of a session end flow, presence will already be disconnected by _HandleSessionEndedEvent + /// This handler only disconnects presence for local state management scenarios + /// + private void _HandleLocalSessionDeactivatedEvent(LootLockerLocalSessionDeactivatedEventData eventData) + { + if(!_isEnabled || _isShuttingDown) + { + return; + } + if (!string.IsNullOrEmpty(eventData.playerUlid)) + { + LootLockerLogger.Log($"Local session deactivated event received for {eventData.playerUlid}, disconnecting presence", LootLockerLogger.LogLevel.Debug); + _DisconnectPresenceForUlid(eventData.playerUlid); + } + } + + /// + /// Handles local session activation by checking if presence and auto-connect are enabled, + /// and, if so, automatically connects presence for the activated player session. + /// + private void _HandleLocalSessionActivatedEvent(LootLockerLocalSessionActivatedEventData eventData) + { + if (!_isEnabled || !_autoConnectEnabled || _isShuttingDown) + { + return; + } + + var playerData = eventData.playerData; + if (playerData != null && !string.IsNullOrEmpty(playerData.ULID)) + { + LootLockerLogger.Log($"Session activated event received for {playerData.ULID}, auto-connecting presence", LootLockerLogger.LogLevel.Debug); + ConnectPresence(playerData.ULID); + } + } + + /// + /// Handle connection state changed events from individual presence clients + /// + private void _HandleClientConnectionStateChanged(LootLockerPresenceConnectionStateChangedEventData eventData) + { + if (eventData.newState == LootLockerPresenceConnectionState.Disconnected || + eventData.newState == LootLockerPresenceConnectionState.Failed) + { + LootLockerLogger.Log($"Auto-cleaning up presence client for {eventData.playerUlid} due to state change: {eventData.newState}", LootLockerLogger.LogLevel.Debug); + + // Clean up the client from our tracking + LootLockerPresenceClient clientToCleanup = null; + lock (_activeClientsLock) + { + if (_activeClients.TryGetValue(eventData.playerUlid, out clientToCleanup)) + { + _activeClients.Remove(eventData.playerUlid); + } + } + + // Destroy the GameObject to fully clean up resources + if (clientToCleanup != null) + { + UnityEngine.Object.Destroy(clientToCleanup.gameObject); + } + } + } + + #endregion + + #region Public Methods + + /// + /// Connect presence for a specific player session + /// + public static void ConnectPresence(string playerUlid = null, LootLockerPresenceCallback onComplete = null) + { + var instance = Get(); + if (instance == null) + { + onComplete?.Invoke(false, "PresenceManager not available"); + return; + } + + if (!instance._isEnabled) + { + #if LOOTLOCKER_ENABLE_PRESENCE + string errorMessage = "Presence is disabled. Enable it in Project Settings > LootLocker SDK > Presence Settings or use _SetPresenceEnabled(true)."; + #else + string errorMessage = "Presence is disabled in this build. Please enable LOOTLOCKER_ENABLE_PRESENCE to use presence features."; + #endif + LootLockerLogger.Log(errorMessage, LootLockerLogger.LogLevel.Debug); + onComplete?.Invoke(false, errorMessage); + return; + } + + // Get player data + var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(playerUlid); + if (playerData == null || string.IsNullOrEmpty(playerData.SessionToken)) + { + LootLockerLogger.Log("Cannot connect presence: No valid session token found", LootLockerLogger.LogLevel.Warning); + onComplete?.Invoke(false, "No valid session token found"); + return; + } + + string ulid = playerData.ULID; + if (string.IsNullOrEmpty(ulid)) + { + LootLockerLogger.Log("Cannot connect presence: No valid player ULID found", LootLockerLogger.LogLevel.Warning); + onComplete?.Invoke(false, "No valid player ULID found"); + return; + } + + lock (instance._activeClientsLock) + { + // Check if already connecting + if (instance._connectingClients.Contains(ulid)) + { + LootLockerLogger.Log($"Presence client for {ulid} is already being connected, skipping new connection attempt", LootLockerLogger.LogLevel.Debug); + onComplete?.Invoke(false, "Already connecting"); + return; + } + + if (instance._activeClients.ContainsKey(ulid)) + { + var existingClient = instance._activeClients[ulid]; + var state = existingClient.ConnectionState; + + if (existingClient.IsConnectedAndAuthenticated) + { + onComplete?.Invoke(true); + return; + } + + // If client is in any active state (connecting, authenticating), don't interrupt it + if (existingClient.IsConnecting || + existingClient.IsAuthenticating) + { + LootLockerLogger.Log($"Presence client for {ulid} is already in progress (state: {state}), skipping new connection attempt", LootLockerLogger.LogLevel.Debug); + onComplete?.Invoke(false, $"Already in progress (state: {state})"); + return; + } + + // Clean up existing client that's failed or disconnected + DisconnectPresence(ulid, (success, error) => { + if (success) + { + // Try connecting again after cleanup + ConnectPresence(playerUlid, onComplete); + } + else + { + onComplete?.Invoke(false, "Failed to cleanup existing connection"); + } + }); + return; + } + + // Mark as connecting to prevent race conditions + instance._connectingClients.Add(ulid); + } + + // Create and connect client outside the lock + LootLockerPresenceClient client = null; + try + { + client = instance.gameObject.AddComponent(); + client.Initialize(ulid, playerData.SessionToken); + } + catch (Exception ex) + { + // Clean up on creation failure + lock (instance._activeClientsLock) + { + instance._connectingClients.Remove(ulid); + } + if (client != null) + { + UnityEngine.Object.Destroy(client); + } + LootLockerLogger.Log($"Failed to create presence client for {ulid}: {ex.Message}", LootLockerLogger.LogLevel.Warning); + onComplete?.Invoke(false, $"Failed to create presence client: {ex.Message}"); + return; + } + + // Start connection + instance._ConnectPresenceClient(ulid, client, onComplete); + instance._activeClients[ulid] = client; + } + + /// + /// Disconnect presence for a specific player session + /// + public static void DisconnectPresence(string playerUlid = null, LootLockerPresenceCallback onComplete = null) + { + var instance = Get(); + if (instance == null) + { + onComplete?.Invoke(false, "PresenceManager not available"); + return; + } + + if (!instance._isEnabled) + { + onComplete?.Invoke(false, "Presence is disabled"); + return; + } + + string ulid = playerUlid; + if (string.IsNullOrEmpty(ulid)) + { + var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(playerUlid); + ulid = playerData?.ULID; + } + + // Use shared internal disconnect logic + instance._DisconnectPresenceForUlid(ulid, onComplete); + } + + /// + /// Disconnect all presence connections + /// + public static void DisconnectAll() + { + Get()?._DisconnectAll(); + } + + /// + /// Update presence status for a specific player + /// + public static void UpdatePresenceStatus(string status, Dictionary metadata = null, string playerUlid = null, LootLockerPresenceCallback onComplete = null) + { + var instance = Get(); + if (instance == null) + { + onComplete?.Invoke(false, "PresenceManager not available"); + return; + } + + if (!instance._isEnabled) + { + onComplete?.Invoke(false, "Presence system is disabled"); + return; + } + + LootLockerPresenceClient client = instance._GetPresenceClientForUlid(playerUlid); + if(client == null) + { + onComplete?.Invoke(false, "No active presence client found for the specified player"); + return; + } + + client.UpdateStatus(status, metadata, onComplete); + } + + /// + /// Get presence connection state for a specific player + /// + public static LootLockerPresenceConnectionState GetPresenceConnectionState(string playerUlid = null) + { + var instance = Get(); + if (instance == null) return LootLockerPresenceConnectionState.Disconnected; + + LootLockerPresenceClient client = instance._GetPresenceClientForUlid(playerUlid); + return client?.ConnectionState ?? LootLockerPresenceConnectionState.Disconnected; + } + + /// + /// Check if presence is connected for a specific player + /// + public static bool IsPresenceConnected(string playerUlid = null) + { + return GetPresenceConnectionState(playerUlid) == LootLockerPresenceConnectionState.Active; + } + + /// + /// Get connection statistics including latency to LootLocker for a specific player + /// + public static LootLockerPresenceConnectionStats GetPresenceConnectionStats(string playerUlid = null) + { + var instance = Get(); + if (instance == null) return new LootLockerPresenceConnectionStats(); + + LootLockerPresenceClient client = instance._GetPresenceClientForUlid(playerUlid); + + if(client == null) + { + return new LootLockerPresenceConnectionStats(); + } + return client.ConnectionStats; + } + + /// + /// Get the last status that was sent for a specific player + /// + /// Optional: The player's ULID. If not provided, uses the default player + /// The last sent status string, or null if no client is found or no status has been sent + public static string GetLastSentStatus(string playerUlid = null) + { + var instance = Get(); + if (instance == null) return string.Empty; + + LootLockerPresenceClient client = instance._GetPresenceClientForUlid(playerUlid); + + if(client == null) + { + return string.Empty; + } + + return client.LastSentStatus; + } + + #endregion + + #region Private Helper Methods + + private void _SetPresenceEnabled(bool enabled) + { + #if !LOOTLOCKER_ENABLE_PRESENCE + LootLockerLogger.Log("Cannot enable Presence: LOOTLOCKER_ENABLE_PRESENCE is not defined in this build.", LootLockerLogger.LogLevel.Warning); + return; + #pragma warning disable CS0162 // Unreachable code detected + #endif + bool changingState = _isEnabled != enabled; + _isEnabled = enabled; + if(changingState && enabled && _autoConnectEnabled) + { + _SubscribeToEvents(null); + StartCoroutine(_AutoConnectExistingSessions()); + } + else if (changingState && !enabled) + { + _UnsubscribeFromEvents(); + _DisconnectAll(); + } + #if !LOOTLOCKER_ENABLE_PRESENCE + #pragma warning restore CS0162 // Unreachable code detected + #endif + } + + private void _SetAutoConnectEnabled(bool enabled) + { + #if !LOOTLOCKER_ENABLE_PRESENCE + LootLockerLogger.Log("Cannot enable Presence auto connect: LOOTLOCKER_ENABLE_PRESENCE is not defined in this build.", LootLockerLogger.LogLevel.Warning); + return; + #pragma warning disable CS0162 // Unreachable code detected + #endif + bool changingState = _autoConnectEnabled != enabled; + _autoConnectEnabled = enabled; + if(changingState && _isEnabled && enabled) + { + _SubscribeToEvents(null); + StartCoroutine(_AutoConnectExistingSessions()); + } + else if (changingState && !enabled) + { + _UnsubscribeFromEvents(); + _DisconnectAll(); + } + #if !LOOTLOCKER_ENABLE_PRESENCE + #pragma warning restore CS0162 // Unreachable code detected + #endif + } + + private IEnumerator _AutoConnectExistingSessions() + { + // Wait a frame to ensure everything is initialized + yield return null; + + if (!_isEnabled || !_autoConnectEnabled || _isShuttingDown) + { + yield break; + } + + // Get all active sessions from state data and auto-connect + var activePlayerUlids = LootLockerStateData.GetActivePlayerULIDs(); + if (activePlayerUlids == null) + { + yield break; + } + + foreach (var ulid in activePlayerUlids) + { + if (!string.IsNullOrEmpty(ulid)) + { + var state = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(ulid); + if (state == null) + { + continue; + } + + // Check if we already have an active or in-progress presence client for this ULID + bool shouldConnect = false; + lock (_activeClientsLock) + { + // Check if already connecting + if (_connectingClients.Contains(state.ULID)) + { + shouldConnect = false; + } + else if (!_activeClients.ContainsKey(state.ULID)) + { + shouldConnect = true; + } + else + { + // Check if existing client is in a failed or disconnected state + var existingClient = _activeClients[state.ULID]; + var clientState = existingClient.ConnectionState; + + if (clientState == LootLockerPresenceConnectionState.Failed || + clientState == LootLockerPresenceConnectionState.Disconnected) + { + shouldConnect = true; + } + } + } + + if (shouldConnect) + { + LootLockerLogger.Log($"Auto-connecting presence for existing session: {state.ULID}", LootLockerLogger.LogLevel.Debug); + ConnectPresence(state.ULID); + + // Small delay between connections to avoid overwhelming the system + yield return new WaitForSeconds(0.1f); + } + } + } + } + + /// + /// Shared internal method for disconnecting a presence client by ULID + /// + private void _DisconnectPresenceForUlid(string playerUlid, LootLockerPresenceCallback onComplete = null) + { + if(!_isEnabled) + { + onComplete?.Invoke(false, "Presence is disabled"); + return; + } + else if(_isShuttingDown) + { + onComplete?.Invoke(true); + return; + } + + if (string.IsNullOrEmpty(playerUlid)) + { + onComplete?.Invoke(true); + return; + } + + LootLockerPresenceClient client = null; + bool alreadyDisconnectedOrFailed = false; + + lock (_activeClientsLock) + { + if (!_activeClients.TryGetValue(playerUlid, out client)) + { + onComplete?.Invoke(true); + return; + } + + // Check connection state to prevent multiple disconnect attempts + var connectionState = client.ConnectionState; + if (connectionState == LootLockerPresenceConnectionState.Disconnected || + connectionState == LootLockerPresenceConnectionState.Failed) + { + alreadyDisconnectedOrFailed = true; + } + + // Remove from _activeClients immediately to prevent other events from trying to disconnect + _activeClients.Remove(playerUlid); + } + + // Disconnect outside the lock to avoid blocking other operations + if (client != null) + { + if (alreadyDisconnectedOrFailed) + { + UnityEngine.Object.Destroy(client); + onComplete?.Invoke(true); + } + else + { + client.Disconnect((success, error) => { + if (!success) + { + LootLockerLogger.Log($"Error disconnecting presence for {playerUlid}: {error}", LootLockerLogger.LogLevel.Debug); + } + UnityEngine.Object.Destroy(client); + onComplete?.Invoke(success, error); + }); + } + } + else + { + onComplete?.Invoke(true); + } + } + + /// + /// Disconnect all presence connections + /// + private void _DisconnectAll() + { + List ulidsToDisconnect; + lock (_activeClientsLock) + { + ulidsToDisconnect = new List(_activeClients.Keys); + // Clear connecting clients as we're disconnecting everything + _connectingClients.Clear(); + } + + foreach (var ulid in ulidsToDisconnect) + { + _DisconnectPresenceForUlid(ulid); + } + } + + /// + /// Creates and initializes a presence client without connecting it + /// + private LootLockerPresenceClient _CreatePresenceClientWithoutConnecting(LootLockerPlayerData playerData) + { + var instance = Get(); + if (instance == null) return null; + + if (!instance._isEnabled) + { + return null; + } + + // Use the provided player data directly + if (playerData == null || string.IsNullOrEmpty(playerData.ULID) || string.IsNullOrEmpty(playerData.SessionToken)) + { + return null; + } + + lock (instance._activeClientsLock) + { + // Check if already connected for this player + if (instance._activeClients.ContainsKey(playerData.ULID)) + { + LootLockerLogger.Log($"Presence already connected for player {playerData.ULID}", LootLockerLogger.LogLevel.Debug); + return instance._activeClients[playerData.ULID]; + } + + // Create new presence client as a GameObject component + var clientGameObject = new GameObject($"PresenceClient_{playerData.ULID}"); + clientGameObject.transform.SetParent(instance.transform); + var client = clientGameObject.AddComponent(); + + client.Initialize(playerData.ULID, playerData.SessionToken); + + // Add to active clients immediately + instance._activeClients[playerData.ULID] = client; + + return client; + } + } + + /// + /// Connects an existing presence client + /// + private void _ConnectPresenceClient(string ulid, LootLockerPresenceClient client, LootLockerPresenceCallback onComplete = null) + { + if (client == null) + { + onComplete?.Invoke(false, "Client is null"); + return; + } + + client.Connect((success, error) => + { + if (!success) + { + DisconnectPresence(ulid); + } + + onComplete?.Invoke(success, error); + }); + } + + /// + /// Coroutine to handle auto-connecting presence after session events + /// + private System.Collections.IEnumerator _DelayPresenceClientConnection(LootLockerPlayerData playerData) + { + // Yield one frame to let the session event complete fully + yield return null; + + var instance = Get(); + if (instance == null) + { + yield break; + } + + LootLockerPresenceClient existingClient = null; + + lock (instance._activeClientsLock) + { + // Check if already connected for this player + if (instance._activeClients.ContainsKey(playerData.ULID)) + { + existingClient = instance._activeClients[playerData.ULID]; + } + } + + // Now attempt to connect the pre-created client + _ConnectPresenceClient(playerData.ULID, existingClient); + } + + private LootLockerPresenceClient _GetPresenceClientForUlid(string playerUlid) + { + string ulid = playerUlid; + if (string.IsNullOrEmpty(ulid)) + { + var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(playerUlid); + if(playerData == null) + { + return null; + } + ulid = playerData?.ULID; + } + + lock (_activeClientsLock) + { + if (!_activeClients.ContainsKey(ulid)) + { + return null; + } + + return _activeClients[ulid]; + } + } + + #endregion + + #region Unity Lifecycle Events + + private void OnDestroy() + { + if (!_isShuttingDown) + { + _isShuttingDown = true; + _UnsubscribeFromEvents(); + + _DisconnectAll(); + } + + // Only unregister if the LifecycleManager exists and we're actually registered + // During application shutdown, services may already be reset + try + { + if (LootLockerLifecycleManager.Instance != null && + LootLockerLifecycleManager.HasService()) + { + LootLockerLifecycleManager.UnregisterService(); + } + } + catch (System.Exception ex) + { + // Ignore unregistration errors during shutdown + LootLockerLogger.Log($"Error unregistering PresenceManager during shutdown (this is expected): {ex.Message}", LootLockerLogger.LogLevel.Debug); + } + } + + #endregion + } +} diff --git a/Runtime/Client/LootLockerPresenceManager.cs.meta b/Runtime/Client/LootLockerPresenceManager.cs.meta new file mode 100644 index 000000000..40613bd20 --- /dev/null +++ b/Runtime/Client/LootLockerPresenceManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fc96f66f8c7592343a026d27340b3f7d \ No newline at end of file diff --git a/Runtime/Client/LootLockerRateLimiter.cs b/Runtime/Client/LootLockerRateLimiter.cs index de64cdd6f..ce0d4676a 100644 --- a/Runtime/Client/LootLockerRateLimiter.cs +++ b/Runtime/Client/LootLockerRateLimiter.cs @@ -1,18 +1,84 @@  using System; +using UnityEngine; #if UNITY_EDITOR using UnityEditor; #endif namespace LootLocker { - #region Rate Limiting Support - - public class RateLimiter + /// + /// Rate limiter service for managing HTTP request rate limiting + /// + public class RateLimiter : MonoBehaviour, ILootLockerService { - protected bool EnableRateLimiter = true; + #region ILootLockerService Implementation + + public string ServiceName => "RateLimiter"; + public bool IsInitialized { get; private set; } = true; // Rate limiter is always ready to use + + /// + /// Initialize the rate limiter service. + /// + public void Initialize() + { + // Rate limiter doesn't need special initialization, but mark as initialized for consistency + IsInitialized = true; + } + + /// + /// Reset all rate limiting state to initial values. + /// This clears all request buckets, counters, and rate limiting flags. + /// Call this when you want to start fresh with rate limiting tracking. + /// + public void Reset() + { + LootLockerLogger.Log("Resetting RateLimiter service", LootLockerLogger.LogLevel.Verbose); + + // Reset all rate limiting state with null safety + if (buckets != null) + Array.Clear(buckets, 0, buckets.Length); + lastBucket = -1; + _lastBucketChangeTime = DateTime.MinValue; + _totalRequestsInBuckets = 0; + _totalRequestsInBucketsInTripWireTimeFrame = 0; + isRateLimited = false; + _rateLimitResolvesAt = DateTime.MinValue; + FirstRequestSent = false; + } + + /// + /// Handle application quit events by resetting all rate limiting state. + /// This ensures clean shutdown and prevents any lingering state issues. + /// + public void HandleApplicationQuit() + { + Reset(); + } + + /// + /// Handle application pause events. Rate limiter doesn't need special handling. + /// + public void HandleApplicationPause(bool pauseStatus) + { + // Rate limiter doesn't need special pause handling + } + /// + /// Handle application focus events. Rate limiter doesn't need special handling. + /// + public void HandleApplicationFocus(bool hasFocus) + { + // Rate limiter doesn't need special focus handling + } + + #endregion + + #region Rate Limiting Implementation + + protected bool EnableRateLimiter = true; protected bool FirstRequestSent = false; + /* -- Configurable constants -- */ // Tripwire settings, allow for a max total of n requests per x seconds protected const int TripWireTimeFrameSeconds = 60; @@ -28,21 +94,28 @@ public class RateLimiter protected const int RateLimitMovingAverageBucketCount = CountMovingAverageAcrossNTripWireTimeFrames * BucketsPerTimeFrame; private const int MaxRequestsPerBucketOnMovingAverage = (int)((MaxRequestsPerTripWireTimeFrame * AllowXPercentOfTripWireMaxForMovingAverage) / (BucketsPerTimeFrame)); + protected int GetMaxRequestsInSingleBucket() + { + int maxRequests = 0; + foreach (var t in buckets) + { + maxRequests = Math.Max(maxRequests, t); + } - /* -- Functionality -- */ - protected readonly int[] buckets = new int[RateLimitMovingAverageBucketCount]; + return maxRequests; + } + protected readonly int[] buckets = new int[RateLimitMovingAverageBucketCount]; protected int lastBucket = -1; private DateTime _lastBucketChangeTime = DateTime.MinValue; private int _totalRequestsInBuckets; private int _totalRequestsInBucketsInTripWireTimeFrame; - protected bool isRateLimited = false; private DateTime _rateLimitResolvesAt = DateTime.MinValue; protected virtual DateTime GetTimeNow() { - return DateTime.Now; + return DateTime.UtcNow; } public int GetSecondsLeftOfRateLimit() @@ -53,6 +126,7 @@ public int GetSecondsLeftOfRateLimit() } return (int)Math.Ceiling((_rateLimitResolvesAt - GetTimeNow()).TotalSeconds); } + private int MoveCurrentBucket(DateTime now) { int moveOverXBuckets = _lastBucketChangeTime == DateTime.MinValue ? 1 : (int)Math.Floor((now - _lastBucketChangeTime).TotalSeconds / SecondsPerBucket); @@ -116,9 +190,10 @@ public virtual bool AddRequestAndCheckIfRateLimitHit() #endif if (isRateLimited) { - _rateLimitResolvesAt = (now - TimeSpan.FromSeconds(now.Second % SecondsPerBucket)) + TimeSpan.FromSeconds(buckets.Length*SecondsPerBucket); + _rateLimitResolvesAt = (now - TimeSpan.FromSeconds(now.Second % SecondsPerBucket)) + TimeSpan.FromSeconds(buckets.Length * SecondsPerBucket); } } + if (currentBucket != lastBucket) { _lastBucketChangeTime = now; @@ -126,41 +201,8 @@ public virtual bool AddRequestAndCheckIfRateLimitHit() } return isRateLimited; } - - protected int GetMaxRequestsInSingleBucket() - { - int maxRequests = 0; - foreach (var t in buckets) - { - maxRequests = Math.Max(maxRequests, t); - } - - return maxRequests; - } - - private static RateLimiter _rateLimiter = null; - - public static RateLimiter Get() - { - if (_rateLimiter == null) - { - Reset(); - } - return _rateLimiter; - } - - public static void Reset() - { - _rateLimiter = new RateLimiter(); - } - -#if UNITY_EDITOR - [InitializeOnEnterPlayMode] - static void OnEnterPlaymodeInEditor(EnterPlayModeOptions options) - { - Reset(); - } -#endif - } + #endregion + } + } diff --git a/Runtime/Client/LootLockerServerApi.cs b/Runtime/Client/LootLockerServerApi.cs deleted file mode 100644 index b37597712..000000000 --- a/Runtime/Client/LootLockerServerApi.cs +++ /dev/null @@ -1,531 +0,0 @@ -#if LOOTLOCKER_LEGACY_HTTP_STACK -using System.Collections; -using System.Collections.Generic; -using UnityEngine; -using UnityEngine.Networking; -using System; -using System.Text; -using LootLocker.LootLockerEnums; -using UnityEditor; -using LootLocker.Requests; - -namespace LootLocker -{ - public class LootLockerHTTPClient : MonoBehaviour - { - private static bool _bTaggedGameObjects = false; - private static LootLockerHTTPClient _instance; - private static int _instanceId = 0; - private const int MaxRetries = 3; - private int _tries; - public GameObject HostingGameObject = null; - - public static void Instantiate() - { - if (_instance == null) - { - var gameObject = new GameObject("LootLockerHTTPClient"); - if (_bTaggedGameObjects) - { - gameObject.tag = "LootLockerHTTPClientGameObject"; - } - - _instance = gameObject.AddComponent(); - _instanceId = _instance.GetInstanceID(); - _instance.HostingGameObject = gameObject; - _instance.StartCoroutine(CleanUpOldInstances()); - if (Application.isPlaying) - DontDestroyOnLoad(_instance.gameObject); - } - } - - public static IEnumerator CleanUpOldInstances() - { -#if UNITY_2020_1_OR_NEWER - LootLockerHTTPClient[] serverApis = GameObject.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None); -#else - LootLockerHTTPClient[] serverApis = GameObject.FindObjectsOfType(); -#endif - foreach (LootLockerHTTPClient serverApi in serverApis) - { - if (serverApi != null && _instanceId != serverApi.GetInstanceID() && serverApi.HostingGameObject != null) - { -#if UNITY_EDITOR - DestroyImmediate(serverApi.HostingGameObject); -#else - Destroy(serverApi.HostingGameObject); -#endif - } - } - yield return null; - } - - public static void ResetInstance() - { - if (_instance == null) return; -#if UNITY_EDITOR - DestroyImmediate(_instance.gameObject); -#else - Destroy(_instance.gameObject); -#endif - _instance = null; - _instanceId = 0; - } - -#if UNITY_EDITOR - [InitializeOnEnterPlayMode] - private static void OnEnterPlaymodeInEditor(EnterPlayModeOptions options) - { - ResetInstance(); - } -#endif - - void Update() - { - } - - public static void SendRequest(LootLockerServerRequest request, Action OnServerResponse = null) - { - if (_instance == null) - { - Instantiate(); - } - - _instance._SendRequest(request, OnServerResponse); - } - - private void _SendRequest(LootLockerServerRequest request, Action OnServerResponse = null) - { - StartCoroutine(coroutine()); - IEnumerator coroutine() - { - //Always wait 1 frame before starting any request to the server to make sure the requester code has exited the main thread. - yield return null; - - //Build the URL that we will hit based on the specified endpoint, query params, etc - string url = BuildUrl(request.endpoint, request.queryParams, request.callerRole); - LootLockerLogger.Log("LL Request " + request.httpMethod + " URL: " + url, LootLockerLogger.LogLevel.Verbose); - using (UnityWebRequest webRequest = CreateWebRequest(url, request)) - { - webRequest.downloadHandler = new DownloadHandlerBuffer(); - - float startTime = Time.time; - bool timedOut = false; - - UnityWebRequestAsyncOperation unityWebRequestAsyncOperation = webRequest.SendWebRequest(); - yield return new WaitUntil(() => - { - if (unityWebRequestAsyncOperation == null) - { - return true; - } - - timedOut = !unityWebRequestAsyncOperation.isDone && Time.time - startTime >= LootLockerConfig.current.clientSideRequestTimeOut; - - return timedOut || unityWebRequestAsyncOperation.isDone; - - }); - - if (!webRequest.isDone && timedOut) - { - LootLockerLogger.Log("Exceeded maxTimeOut waiting for a response from " + request.httpMethod + " " + url, LootLockerLogger.LogLevel.Warning); - OnServerResponse?.Invoke(LootLockerResponseFactory.ClientError(request.endpoint + " timed out.", request.forPlayerWithUlid, request.requestStartTime)); - yield break; - } - - - LogResponse(request, webRequest.responseCode, webRequest.downloadHandler.text, startTime, webRequest.error); - - if (WebRequestSucceeded(webRequest)) - { - OnServerResponse?.Invoke(new LootLockerResponse - { - statusCode = (int)webRequest.responseCode, - success = true, - text = webRequest.downloadHandler.text, - errorData = null, - requestContext = new LootLockerRequestContext(request.forPlayerWithUlid, request.requestStartTime) - }); - yield break; - } - - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(request.forPlayerWithUlid); - if (ShouldRetryRequest(webRequest.responseCode, _tries, playerData == null ? LL_AuthPlatforms.None : playerData.CurrentPlatform.Platform)) - { - _tries++; - RefreshTokenAndCompleteCall(request, playerData == null ? LL_AuthPlatforms.None : playerData.CurrentPlatform.Platform, (value) => { _tries = 0; OnServerResponse?.Invoke(value); }); - yield break; - } - - _tries = 0; - LootLockerResponse response = new LootLockerResponse - { - statusCode = (int)webRequest.responseCode, - success = false, - text = webRequest.downloadHandler.text, - errorData = null, - requestContext = new LootLockerRequestContext(request.forPlayerWithUlid, request.requestStartTime) - }; - - try - { - response.errorData = LootLockerJson.DeserializeObject(webRequest.downloadHandler.text); - } - catch (Exception) - { - if (webRequest.downloadHandler.text.StartsWith("<")) - { - LootLockerLogger.Log("JSON Starts with <, info: \n statusCode: " + response.statusCode + "\n body: " + response.text, LootLockerLogger.LogLevel.Warning); - } - response.errorData = null; - } - // Error data was not parseable, populate with what we know - if (response.errorData == null) - { - response.errorData = new LootLockerErrorData((int)webRequest.responseCode, webRequest.downloadHandler.text); - } - - string RetryAfterHeader = webRequest.GetResponseHeader("Retry-After"); - if (!string.IsNullOrEmpty(RetryAfterHeader)) - { - response.errorData.retry_after_seconds = Int32.Parse(RetryAfterHeader); - } - - LootLockerLogger.Log(response.errorData?.ToString(), LootLockerLogger.LogLevel.Error); - OnServerResponse?.Invoke(response); - } - } - } - -#region Private Methods - - private static bool ShouldRetryRequest(long statusCode, int timesRetried, LL_AuthPlatforms platform) - { - return (statusCode == 401 || statusCode == 403 || statusCode == 502 || statusCode == 500 || statusCode == 503) && LootLockerConfig.current.allowTokenRefresh && platform != LL_AuthPlatforms.Steam && timesRetried < MaxRetries; - } - - private static void LogResponse(LootLockerServerRequest request, long statusCode, string responseBody, float startTime, string unityWebRequestError) - { - if (statusCode == 0 && string.IsNullOrEmpty(responseBody) && !string.IsNullOrEmpty(unityWebRequestError)) - { - LootLockerLogger.Log("Unity Web request failed, request to " + - request.endpoint + " completed in " + - (Time.time - startTime).ToString("n4") + - " secs.\nWeb Request Error: " + unityWebRequestError, LootLockerLogger.LogLevel.Verbose); - return; - } - - try - { - LootLockerLogger.Log("LL Response: " + - statusCode + " " + - request.endpoint + " completed in " + - (Time.time - startTime).ToString("n4") + - " secs.\nResponse: " + - LootLockerObfuscator - .ObfuscateJsonStringForLogging(responseBody), LootLockerLogger.LogLevel.Verbose); - } - catch - { - LootLockerLogger.Log(request.httpMethod.ToString(), LootLockerLogger.LogLevel.Error); - LootLockerLogger.Log(request.endpoint, LootLockerLogger.LogLevel.Error); - LootLockerLogger.Log(LootLockerObfuscator.ObfuscateJsonStringForLogging(responseBody), LootLockerLogger.LogLevel.Error); - } - } - - private static string GetUrl(LootLockerCallerRole callerRole) - { - switch (callerRole) - { - case LootLockerCallerRole.Admin: - return LootLockerConfig.current.adminUrl; - case LootLockerCallerRole.User: - return LootLockerConfig.current.userUrl; - case LootLockerCallerRole.Player: - return LootLockerConfig.current.playerUrl; - case LootLockerCallerRole.Base: - return LootLockerConfig.current.baseUrl; - default: - return LootLockerConfig.current.url; - } - } - - private bool WebRequestSucceeded(UnityWebRequest webRequest) - { - return ! -#if UNITY_2020_1_OR_NEWER - (webRequest.result == UnityWebRequest.Result.ProtocolError || webRequest.result == UnityWebRequest.Result.ConnectionError || !string.IsNullOrEmpty(webRequest.error)); -#else - (webRequest.isHttpError || webRequest.isNetworkError || !string.IsNullOrEmpty(webRequest.error)); -#endif - } - - private static readonly Dictionary BaseHeaders = new Dictionary - { - { "Accept", "application/json; charset=UTF-8" }, - { "Content-Type", "application/json; charset=UTF-8" }, - { "Access-Control-Allow-Credentials", "true" }, - { "Access-Control-Allow-Headers", "Accept, X-Access-Token, X-Application-Name, X-Request-Sent-Time" }, - { "Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS, HEAD" }, - { "Access-Control-Allow-Origin", "*" }, - { "LL-Instance-Identifier", System.Guid.NewGuid().ToString() } - }; - - private void RefreshTokenAndCompleteCall(LootLockerServerRequest cachedRequest, LL_AuthPlatforms platform, Action onComplete) - { - switch (platform) - { - case LL_AuthPlatforms.Guest: - { - LootLockerSDKManager.StartGuestSessionForPlayer(cachedRequest.forPlayerWithUlid, response => - { - CompleteCall(cachedRequest, response, onComplete); - }); - return; - } - case LL_AuthPlatforms.WhiteLabel: - { - LootLockerSDKManager.StartWhiteLabelSession(response => - { - CompleteCall(cachedRequest, response, onComplete); - }, cachedRequest.forPlayerWithUlid); - return; - } - case LL_AuthPlatforms.AppleGameCenter: - { - if (ShouldRefreshUsingRefreshToken(cachedRequest)) - { - LootLockerSDKManager.RefreshAppleGameCenterSession(response => - { - CompleteCall(cachedRequest, response, onComplete); - }, cachedRequest.forPlayerWithUlid); - return; - } - LootLockerLogger.Log($"Token has expired, please refresh it", LootLockerLogger.LogLevel.Warning); - onComplete?.Invoke(LootLockerResponseFactory.TokenExpiredError(cachedRequest.forPlayerWithUlid, cachedRequest.requestStartTime)); - return; - } - case LL_AuthPlatforms.AppleSignIn: - { - if (ShouldRefreshUsingRefreshToken(cachedRequest)) - { - LootLockerSDKManager.RefreshAppleSession(response => - { - CompleteCall(cachedRequest, response, onComplete); - }, cachedRequest.forPlayerWithUlid); - return; - } - LootLockerLogger.Log($"Token has expired, please refresh it", LootLockerLogger.LogLevel.Warning); - onComplete?.Invoke(LootLockerResponseFactory.TokenExpiredError(cachedRequest.forPlayerWithUlid, cachedRequest.requestStartTime)); - return; - } - case LL_AuthPlatforms.Epic: - { - if (ShouldRefreshUsingRefreshToken(cachedRequest)) - { - LootLockerSDKManager.RefreshEpicSession(response => - { - CompleteCall(cachedRequest, response, onComplete); - }, cachedRequest.forPlayerWithUlid); - return; - } - LootLockerLogger.Log($"Token has expired, please refresh it", LootLockerLogger.LogLevel.Warning); - onComplete?.Invoke(LootLockerResponseFactory.TokenExpiredError(cachedRequest.forPlayerWithUlid, cachedRequest.requestStartTime)); - return; - } - case LL_AuthPlatforms.Google: - { - if (ShouldRefreshUsingRefreshToken(cachedRequest)) - { - LootLockerSDKManager.RefreshGoogleSession(response => - { - CompleteCall(cachedRequest, response, onComplete); - }, cachedRequest.forPlayerWithUlid); - return; - } - LootLockerLogger.Log($"Token has expired, please refresh it", LootLockerLogger.LogLevel.Warning); - onComplete?.Invoke(LootLockerResponseFactory.TokenExpiredError(cachedRequest.forPlayerWithUlid, cachedRequest.requestStartTime)); - return; - } - case LL_AuthPlatforms.Remote: - { - if (ShouldRefreshUsingRefreshToken(cachedRequest)) - { - LootLockerSDKManager.RefreshRemoteSession(response => - { - CompleteCall(cachedRequest, response, onComplete); - }, cachedRequest.forPlayerWithUlid); - return; - } - LootLockerLogger.Log($"Token has expired, please refresh it", LootLockerLogger.LogLevel.Warning); - onComplete?.Invoke(LootLockerResponseFactory.TokenExpiredError(cachedRequest.forPlayerWithUlid, cachedRequest.requestStartTime)); - return; - } - case LL_AuthPlatforms.NintendoSwitch: - case LL_AuthPlatforms.Steam: - { - LootLockerLogger.Log($"Token has expired and token refresh is not supported for {platform}", LootLockerLogger.LogLevel.Warning); - onComplete?.Invoke(LootLockerResponseFactory.TokenExpiredError(cachedRequest.forPlayerWithUlid, cachedRequest.requestStartTime)); - return; - } - case LL_AuthPlatforms.PlayStationNetwork: - case LL_AuthPlatforms.XboxOne: - case LL_AuthPlatforms.AmazonLuna: - { - LootLockerServerRequest.CallAPI(null, - LootLockerEndPoints.authenticationRequest.endPoint, LootLockerEndPoints.authenticationRequest.httpMethod, - LootLockerJson.SerializeObject(new LootLockerSessionRequest(LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(cachedRequest.forPlayerWithUlid)?.Identifier, LL_AuthPlatforms.AmazonLuna)), - (serverResponse) => - { - CompleteCall(cachedRequest, LootLockerResponse.Deserialize(serverResponse), onComplete); - }, - false - ); - return; - } - case LL_AuthPlatforms.None: - default: - { - LootLockerLogger.Log($"Token refresh for platform {platform} not supported", LootLockerLogger.LogLevel.Error); - onComplete?.Invoke(LootLockerResponseFactory.NetworkError($"Token refresh for platform {platform} not supported", 401, cachedRequest.forPlayerWithUlid, cachedRequest.requestStartTime)); - return; - } - } - } - - private static bool ShouldRefreshUsingRefreshToken(LootLockerServerRequest cachedRequest) - { - // The failed request isn't a refresh session request but we have a refresh token stored, so try to refresh the session automatically before failing - return (string.IsNullOrEmpty(cachedRequest.jsonPayload) || !cachedRequest.jsonPayload.Contains("refresh_token")) && !string.IsNullOrEmpty(LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(cachedRequest.forPlayerWithUlid)?.RefreshToken); - } - - private void CompleteCall(LootLockerServerRequest cachedRequest, LootLockerSessionResponse sessionRefreshResponse, Action onComplete) - { - if (!sessionRefreshResponse.success) - { - LootLockerLogger.Log("Session refresh failed"); - onComplete?.Invoke(LootLockerResponseFactory.TokenExpiredError(cachedRequest.forPlayerWithUlid, cachedRequest.requestStartTime)); - return; - } - - if (cachedRequest.retryCount >= 4) - { - LootLockerLogger.Log("Session refresh failed"); - onComplete?.Invoke(LootLockerResponseFactory.TokenExpiredError(cachedRequest.forPlayerWithUlid, cachedRequest.requestStartTime)); - return; - } - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(cachedRequest.forPlayerWithUlid); - if (playerData != null && !string.IsNullOrEmpty(playerData.SessionToken)) - { - cachedRequest.extraHeaders["x-session-token"] = playerData.SessionToken; - } - SendRequest(cachedRequest, onComplete); - cachedRequest.retryCount++; - } - - private UnityWebRequest CreateWebRequest(string url, LootLockerServerRequest request) - { - UnityWebRequest webRequest; - switch (request.httpMethod) - { - case LootLockerHTTPMethod.UPLOAD_FILE: - webRequest = UnityWebRequest.Post(url, request.form); - break; - case LootLockerHTTPMethod.UPDATE_FILE: - // Workaround for UnityWebRequest with PUT HTTP verb not having form fields - webRequest = UnityWebRequest.Post(url, request.form); - webRequest.method = UnityWebRequest.kHttpVerbPUT; - break; - case LootLockerHTTPMethod.POST: - case LootLockerHTTPMethod.PATCH: - // Defaults are fine for PUT - case LootLockerHTTPMethod.PUT: - - if (request.payload == null && request.upload != null) - { - List form = new List - { - new MultipartFormFileSection(request.uploadName, request.upload, System.DateTime.Now.ToString(), request.uploadType) - }; - - // generate a boundary then convert the form to byte[] - byte[] boundary = UnityWebRequest.GenerateBoundary(); - byte[] formSections = UnityWebRequest.SerializeFormSections(form, boundary); - // Set the content type - NO QUOTES around the boundary - string contentType = String.Concat("multipart/form-data; boundary=--", Encoding.UTF8.GetString(boundary)); - - // Make my request object and add the raw text. Set anything else you need here - webRequest = new UnityWebRequest(); - webRequest.SetRequestHeader("Content-Type", "multipart/form-data; boundary=--"); - webRequest.uri = new Uri(url); - //LootLockerLogger.Log(url); // The url is wrong in some cases - webRequest.uploadHandler = new UploadHandlerRaw(formSections); - webRequest.uploadHandler.contentType = contentType; - webRequest.useHttpContinue = false; - - // webRequest.method = "POST"; - webRequest.method = UnityWebRequest.kHttpVerbPOST; - } - else - { - string json = (request.payload != null && request.payload.Count > 0) ? LootLockerJson.SerializeObject(request.payload) : request.jsonPayload; - LootLockerLogger.Log("REQUEST BODY = " + LootLockerObfuscator.ObfuscateJsonStringForLogging(json), LootLockerLogger.LogLevel.Verbose); - byte[] bytes = System.Text.Encoding.UTF8.GetBytes(string.IsNullOrEmpty(json) ? "{}" : json); - webRequest = UnityWebRequest.Put(url, bytes); - webRequest.method = request.httpMethod.ToString(); - } - - break; - - case LootLockerHTTPMethod.OPTIONS: - case LootLockerHTTPMethod.HEAD: - case LootLockerHTTPMethod.GET: - // Defaults are fine for GET - webRequest = UnityWebRequest.Get(url); - webRequest.method = request.httpMethod.ToString(); - break; - - case LootLockerHTTPMethod.DELETE: - // Defaults are fine for DELETE - webRequest = UnityWebRequest.Delete(url); - break; - default: - throw new System.Exception("Invalid HTTP Method"); - } - - if (BaseHeaders != null) - { - foreach (KeyValuePair pair in BaseHeaders) - { - if (pair.Key == "Content-Type" && request.upload != null) continue; - - webRequest.SetRequestHeader(pair.Key, pair.Value); - } - } - - if (!string.IsNullOrEmpty(LootLockerConfig.current?.sdk_version)) - { - webRequest.SetRequestHeader("LL-SDK-Version", LootLockerConfig.current.sdk_version); - } - - if (request.extraHeaders != null) - { - foreach (KeyValuePair pair in request.extraHeaders) - { - webRequest.SetRequestHeader(pair.Key, pair.Value); - } - } - - return webRequest; - } - - private string BuildUrl(string endpoint, Dictionary queryParams = null, LootLockerCallerRole callerRole = LootLockerCallerRole.User) - { - string ep = endpoint.StartsWith("/") ? endpoint.Trim() : "/" + endpoint.Trim(); - - return (GetUrl(callerRole) + ep + new LootLocker.Utilities.HTTP.QueryParamaterBuilder(queryParams).ToString()).Trim(); - } -#endregion - } -} -#endif diff --git a/Runtime/Client/LootLockerServerRequest.cs b/Runtime/Client/LootLockerServerRequest.cs deleted file mode 100644 index 988dc2bd4..000000000 --- a/Runtime/Client/LootLockerServerRequest.cs +++ /dev/null @@ -1,213 +0,0 @@ -#if LOOTLOCKER_LEGACY_HTTP_STACK -using System.Collections.Generic; -using UnityEngine; -using System; -using LootLocker.LootLockerEnums; - -namespace LootLocker -{ - /// - /// Construct a request to send to the server. - /// - [Serializable] - public struct LootLockerServerRequest - { - public string endpoint { get; set; } - public LootLockerHTTPMethod httpMethod { get; set; } - public Dictionary payload { get; set; } - public string jsonPayload { get; set; } - public byte[] upload { get; set; } - public string uploadName { get; set; } - public string uploadType { get; set; } - public LootLockerCallerRole callerRole { get; set; } - public WWWForm form { get; set; } - public string forPlayerWithUlid { get; set; } - public DateTime requestStartTime { get; set; } - - /// - /// Leave this null if you don't need custom headers - /// - public Dictionary extraHeaders; - - /// - /// Query parameters to append to the end of the request URI - /// Example: If you include a dictionary with a key of "page" and a value of "42" (as a string) then the url would become "https://mydomain.com/endpoint?page=42" - /// - public Dictionary queryParams; - - public int retryCount { get; set; } - - #region Make ServerRequest and call send (3 functions) - - public static void CallAPI(string forPlayerWithUlid, string endPoint, LootLockerHTTPMethod httpMethod, - string body = null, Action onComplete = null, bool useAuthToken = true, - LootLockerCallerRole callerRole = LootLocker.LootLockerEnums.LootLockerCallerRole.User, - Dictionary additionalHeaders = null) - { - if (RateLimiter.Get().AddRequestAndCheckIfRateLimitHit()) - { - onComplete?.Invoke(LootLockerResponseFactory.RateLimitExceeded(endPoint, RateLimiter.Get().GetSecondsLeftOfRateLimit(), forPlayerWithUlid, DateTime.Now)); - return; - } - - if (useAuthToken && string.IsNullOrEmpty(forPlayerWithUlid)) - { - forPlayerWithUlid = LootLockerStateData.GetDefaultPlayerULID(); - } - - LootLockerLogger.Log("Caller Type: " + callerRole, LootLockerLogger.LogLevel.Debug); - - Dictionary headers = new Dictionary(); - - if (useAuthToken) - { - if (callerRole == LootLockerCallerRole.Admin) - { -#if UNITY_EDITOR - if (!string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) - { - headers.Add("x-auth-token", LootLockerConfig.current.adminToken); - } -#endif - } - else - { - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); - if (playerData != null && !string.IsNullOrEmpty(playerData.SessionToken)) - { - headers.Add("x-session-token", playerData.SessionToken); - } - } - } - - if (LootLockerConfig.current != null) - headers.Add(LootLockerConfig.current.dateVersion.key, LootLockerConfig.current.dateVersion.value); - - if (additionalHeaders != null) - { - foreach (var additionalHeader in additionalHeaders) - { - headers.Add(additionalHeader.Key, additionalHeader.Value); - } - } - - new LootLockerServerRequest(forPlayerWithUlid, endPoint, httpMethod, body, headers, callerRole: callerRole).Send((response) => { onComplete?.Invoke(response); }); - } - - public static void UploadFile(string forPlayerWithUlid, string endPoint, LootLockerHTTPMethod httpMethod, byte[] file, string fileName = "file", string fileContentType = "text/plain", Dictionary body = null, Action onComplete = null, bool useAuthToken = true, LootLocker.LootLockerEnums.LootLockerCallerRole callerRole = LootLocker.LootLockerEnums.LootLockerCallerRole.User) - { - if (RateLimiter.Get().AddRequestAndCheckIfRateLimitHit()) - { - onComplete?.Invoke(LootLockerResponseFactory.RateLimitExceeded(endPoint, RateLimiter.Get().GetSecondsLeftOfRateLimit(), forPlayerWithUlid, DateTime.Now)); - return; - } - Dictionary headers = new Dictionary(); - if (file.Length == 0) - { - LootLockerLogger.Log("File content is empty, not allowed.", LootLockerLogger.LogLevel.Error); - onComplete?.Invoke(LootLockerResponseFactory.ClientError("File content is empty, not allowed.", forPlayerWithUlid)); - return; - } - if (useAuthToken) - { - if (callerRole == LootLockerCallerRole.Admin) - { -#if UNITY_EDITOR - if (!string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) - { - headers.Add("x-auth-token", LootLockerConfig.current.adminToken); - } -#endif - } - else - { - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); - if (playerData != null && !string.IsNullOrEmpty(playerData.SessionToken)) - { - headers.Add("x-session-token", playerData.SessionToken); - } - } - } - - new LootLockerServerRequest(forPlayerWithUlid, endPoint, httpMethod, file, fileName, fileContentType, body, headers, callerRole: callerRole).Send((response) => { onComplete?.Invoke(response); }); - } - - public static void UploadFile(string forPlayerWithUlid, EndPointClass endPoint, byte[] file, string fileName = "file", string fileContentType = "text/plain", Dictionary body = null, Action onComplete = null, - bool useAuthToken = true, LootLocker.LootLockerEnums.LootLockerCallerRole callerRole = LootLocker.LootLockerEnums.LootLockerCallerRole.User) - { - UploadFile(forPlayerWithUlid, endPoint.endPoint, endPoint.httpMethod, file, fileName, fileContentType, body, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }, useAuthToken, callerRole); - } - - #endregion - - #region ServerRequest constructor - - public LootLockerServerRequest(string forPlayerWithUlid, string endpoint, LootLockerHTTPMethod httpMethod = LootLockerHTTPMethod.GET, byte[] upload = null, string uploadName = null, string uploadType = null, Dictionary body = null, - Dictionary extraHeaders = null, bool useAuthToken = true, LootLocker.LootLockerEnums.LootLockerCallerRole callerRole = LootLocker.LootLockerEnums.LootLockerCallerRole.User, bool isFileUpload = true) - { - this.retryCount = 0; - this.endpoint = endpoint; - this.httpMethod = httpMethod; - this.payload = null; - this.upload = upload; - this.uploadName = uploadName; - this.uploadType = uploadType; - this.jsonPayload = null; - this.extraHeaders = extraHeaders != null && extraHeaders.Count == 0 ? null : extraHeaders; // Force extra headers to null if empty dictionary was supplied - this.queryParams = null; - this.callerRole = callerRole; - this.form = new WWWForm(); - this.forPlayerWithUlid = forPlayerWithUlid; - this.requestStartTime = DateTime.Now; - - foreach (var kvp in body) - { - this.form.AddField(kvp.Key, kvp.Value); - } - - this.form.AddBinaryData("file", upload, uploadName); - - bool isNonPayloadMethod = (this.httpMethod == LootLockerHTTPMethod.GET || this.httpMethod == LootLockerHTTPMethod.HEAD || this.httpMethod == LootLockerHTTPMethod.OPTIONS); - - if (this.payload != null && isNonPayloadMethod) - { - LootLockerLogger.Log("Payloads should not be sent in GET, HEAD, OPTIONS, requests. Attempted to send a payload to: " + this.httpMethod.ToString() + " " + this.endpoint, LootLockerLogger.LogLevel.Warning); - } - } - - public LootLockerServerRequest(string forPlayerWithUlid, string endpoint, LootLockerHTTPMethod httpMethod = LootLockerHTTPMethod.GET, string payload = null, Dictionary extraHeaders = null, Dictionary queryParams = null, bool useAuthToken = true, - LootLocker.LootLockerEnums.LootLockerCallerRole callerRole = LootLocker.LootLockerEnums.LootLockerCallerRole.User) - { - this.retryCount = 0; - this.endpoint = endpoint; - this.httpMethod = httpMethod; - this.jsonPayload = payload; - this.upload = null; - this.uploadName = null; - this.uploadType = null; - this.payload = null; - this.extraHeaders = extraHeaders != null && extraHeaders.Count == 0 ? null : extraHeaders; // Force extra headers to null if empty dictionary was supplied - this.queryParams = queryParams != null && queryParams.Count == 0 ? null : queryParams; - this.callerRole = callerRole; - bool isNonPayloadMethod = (this.httpMethod == LootLockerHTTPMethod.GET || this.httpMethod == LootLockerHTTPMethod.HEAD || this.httpMethod == LootLockerHTTPMethod.OPTIONS); - this.form = null; - this.forPlayerWithUlid = forPlayerWithUlid; - this.requestStartTime = DateTime.Now; - if (!string.IsNullOrEmpty(jsonPayload) && isNonPayloadMethod) - { - LootLockerLogger.Log("Payloads should not be sent in GET, HEAD, OPTIONS, requests. Attempted to send a payload to: " + this.httpMethod.ToString() + " " + this.endpoint, LootLockerLogger.LogLevel.Warning); - } - } - - #endregion - - /// - /// just debug and call ServerAPI.SendRequest which takes the current ServerRequest and pass this response - /// - public void Send(System.Action OnServerResponse) - { - LootLockerHTTPClient.SendRequest(this, (response) => { OnServerResponse?.Invoke(response); }); - } - } -} -#endif diff --git a/Runtime/Client/LootLockerStateData.cs b/Runtime/Client/LootLockerStateData.cs index e81168150..6e9d2a0d0 100644 --- a/Runtime/Client/LootLockerStateData.cs +++ b/Runtime/Client/LootLockerStateData.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using JetBrains.Annotations; +using UnityEngine; namespace LootLocker { @@ -30,11 +31,166 @@ public class LootLockerStateMetaData public Dictionary WhiteLabelEmailToPlayerUlidMap { get; set; } = new Dictionary(); } - public class LootLockerStateData + /// + /// Manages player state data persistence and session lifecycle + /// + public class LootLockerStateData : MonoBehaviour, ILootLockerService { - public LootLockerStateData() + #region ILootLockerService Implementation + + public bool IsInitialized { get; private set; } = false; + public string ServiceName => "StateData"; + + void ILootLockerService.Initialize() + { + if (IsInitialized) return; + + // Event subscriptions will be set up via SetEventSystem() method + // to avoid circular dependency during LifecycleManager initialization + + IsInitialized = true; + } + + /// + /// Set the EventSystem dependency and subscribe to events + /// + public void SetEventSystem(LootLockerEventSystem eventSystem) + { + if (eventSystem != null) + { + eventSystem.SubscribeInstance( + LootLockerEventType.SessionStarted, + OnSessionStartedEvent + ); + + eventSystem.SubscribeInstance( + LootLockerEventType.SessionRefreshed, + OnSessionRefreshedEvent + ); + + eventSystem.SubscribeInstance( + LootLockerEventType.SessionEnded, + OnSessionEndedEvent + ); + } + } + + void ILootLockerService.Reset() + { + if(!IsInitialized || !LootLockerLifecycleManager.HasService()) + { + return; + } + LootLockerEventSystem.Unsubscribe( + LootLockerEventType.SessionStarted, + OnSessionStartedEvent + ); + + LootLockerEventSystem.Unsubscribe( + LootLockerEventType.SessionRefreshed, + OnSessionRefreshedEvent + ); + + LootLockerEventSystem.Unsubscribe( + LootLockerEventType.SessionEnded, + OnSessionEndedEvent + ); + + IsInitialized = false; + + lock (_instanceLock) + { + _instance = null; + } + } + + void ILootLockerService.HandleApplicationPause(bool pauseStatus) { - LoadMetaDataFromPlayerPrefsIfNeeded(); + // StateData doesn't need to handle pause events + } + + void ILootLockerService.HandleApplicationFocus(bool hasFocus) + { + // StateData doesn't need to handle focus events + } + + void ILootLockerService.HandleApplicationQuit() + { + ((ILootLockerService)this).Reset(); + } + + #endregion + + #region Singleton Management + + private static LootLockerStateData _instance; + private static readonly object _instanceLock = new object(); + + /// + /// Get the StateData service instance through the LifecycleManager. + /// Services are automatically registered and initialized on first access if needed. + /// + private static LootLockerStateData GetInstance() + { + if (_instance != null) + { + return _instance; + } + + lock (_instanceLock) + { + if (_instance == null) + { + _instance = LootLockerLifecycleManager.GetService(); + } + return _instance; + } + } + + #endregion + + /// + /// Handle session started events by saving the player data + /// + private void OnSessionStartedEvent(LootLockerSessionStartedEventData eventData) + { + if (eventData?.playerData != null) + { + SetPlayerData(eventData.playerData); + } + } + + /// + /// Handle session refreshed events by updating the player data + /// + private void OnSessionRefreshedEvent(LootLockerSessionRefreshedEventData eventData) + { + if (eventData?.playerData != null) + { + SetPlayerData(eventData.playerData); + } + } + + /// + /// Handle session ended events + /// + private void OnSessionEndedEvent(LootLockerSessionEndedEventData eventData) + { + if (eventData == null || string.IsNullOrEmpty(eventData.playerUlid)) + { + return; + } + + if (eventData.clearLocalState) + { + // Clear all saved state for this player + ClearSavedStateForPlayerWithULID(eventData.playerUlid); + } + else + { + // Just set the player to inactive (remove from active players) + SetPlayerULIDToInactive(eventData.playerUlid); + } } //================================================== @@ -46,7 +202,8 @@ public LootLockerStateData() #else new LootLockerPlayerPrefsStateWriter(); #endif - public static void overrideStateWriter(ILootLockerStateWriter newWriter) + + public void OverrideStateWriter(ILootLockerStateWriter newWriter) { if (newWriter != null) { @@ -64,15 +221,15 @@ public static void overrideStateWriter(ILootLockerStateWriter newWriter) //================================================== // Actual state //================================================== - private static LootLockerStateMetaData ActiveMetaData = null; - private static Dictionary ActivePlayerData = new Dictionary(); + private LootLockerStateMetaData ActiveMetaData = null; + private Dictionary ActivePlayerData = new Dictionary(); #region Private Methods //================================================== // Private Methods //================================================== - private static void LoadMetaDataFromPlayerPrefsIfNeeded() + private void _LoadMetaDataFromPlayerPrefsIfNeeded() { if (ActiveMetaData != null) { @@ -92,16 +249,16 @@ private static void LoadMetaDataFromPlayerPrefsIfNeeded() ActiveMetaData.DefaultPlayer = ActiveMetaData.SavedPlayerStateULIDs[0]; } - SaveMetaDataToPlayerPrefs(); + _SaveMetaDataToPlayerPrefs(); } - private static void SaveMetaDataToPlayerPrefs() + private void _SaveMetaDataToPlayerPrefs() { string metadataJson = LootLockerJson.SerializeObject(ActiveMetaData); _stateWriter.SetString(MetaDataSaveSlot, metadataJson); } - private static void SavePlayerDataToPlayerPrefs(string playerULID) + private void _SavePlayerDataToPlayerPrefs(string playerULID) { if (!ActivePlayerData.TryGetValue(playerULID, out var playerData)) { @@ -112,14 +269,14 @@ private static void SavePlayerDataToPlayerPrefs(string playerULID) _stateWriter.SetString($"{PlayerDataSaveSlot}_{playerULID}", playerDataJson); } - private static bool LoadPlayerDataFromPlayerPrefs(string playerULID) + private bool _LoadPlayerDataFromPlayerPrefs(string playerULID) { if (string.IsNullOrEmpty(playerULID)) { return false; } - if (!SaveStateExistsForPlayer(playerULID)) + if (!_SaveStateExistsForPlayer(playerULID)) { return false; } @@ -136,33 +293,43 @@ private static bool LoadPlayerDataFromPlayerPrefs(string playerULID) } ActivePlayerData.Add(parsedPlayerData.ULID, parsedPlayerData); + LootLockerEventSystem.TriggerLocalSessionActivated(parsedPlayerData); return true; } #endregion // Private Methods - #region Public Methods + #region Private Instance Methods (Used by Static Interface) //================================================== - // Public Methods + // Private Instance Methods (Used by Static Interface) //================================================== - public static bool SaveStateExistsForPlayer(string playerULID) + + private void _OverrideStateWriter(ILootLockerStateWriter newWriter) + { + if (newWriter != null) + { + _stateWriter = newWriter; + } + } + + private bool _SaveStateExistsForPlayer(string playerULID) { return _stateWriter.HasKey($"{PlayerDataSaveSlot}_{playerULID}"); } - public static LootLockerPlayerData GetPlayerDataForPlayerWithUlidWithoutChangingState(string playerULID) + private LootLockerPlayerData _GetPlayerDataForPlayerWithUlidWithoutChangingState(string playerULID) { if (string.IsNullOrEmpty(playerULID)) { return new LootLockerPlayerData(); } - LoadMetaDataFromPlayerPrefsIfNeeded(); + _LoadMetaDataFromPlayerPrefsIfNeeded(); if (ActiveMetaData == null) { return new LootLockerPlayerData(); } - if (!SaveStateExistsForPlayer(playerULID)) + if (!_SaveStateExistsForPlayer(playerULID)) { return new LootLockerPlayerData(); } @@ -181,9 +348,9 @@ public static LootLockerPlayerData GetPlayerDataForPlayerWithUlidWithoutChanging } [CanBeNull] - public static LootLockerPlayerData GetStateForPlayerOrDefaultStateOrEmpty(string playerULID) + private LootLockerPlayerData _GetStateForPlayerOrDefaultStateOrEmpty(string playerULID) { - LoadMetaDataFromPlayerPrefsIfNeeded(); + _LoadMetaDataFromPlayerPrefsIfNeeded(); if (ActiveMetaData == null) { return null; @@ -203,7 +370,7 @@ public static LootLockerPlayerData GetStateForPlayerOrDefaultStateOrEmpty(string return data; } - if (LoadPlayerDataFromPlayerPrefs(playerULIDToGetDataFor)) + if (_LoadPlayerDataFromPlayerPrefs(playerULIDToGetDataFor)) { if (ActivePlayerData.TryGetValue(playerULIDToGetDataFor, out var data2)) { @@ -217,9 +384,9 @@ public static LootLockerPlayerData GetStateForPlayerOrDefaultStateOrEmpty(string return null; } - public static string GetDefaultPlayerULID() + private string _GetDefaultPlayerULID() { - LoadMetaDataFromPlayerPrefsIfNeeded(); + _LoadMetaDataFromPlayerPrefsIfNeeded(); if (ActiveMetaData == null) { return string.Empty; @@ -228,44 +395,44 @@ public static string GetDefaultPlayerULID() return ActiveMetaData.DefaultPlayer; } - public static bool SetDefaultPlayerULID(string playerULID) + private bool _SetDefaultPlayerULID(string playerULID) { if (string.IsNullOrEmpty(playerULID) || !SaveStateExistsForPlayer(playerULID)) { return false; } - if (!ActivePlayerData.ContainsKey(playerULID) && !LoadPlayerDataFromPlayerPrefs(playerULID)) + if (!ActivePlayerData.ContainsKey(playerULID) && !_LoadPlayerDataFromPlayerPrefs(playerULID)) { return false; } - LoadMetaDataFromPlayerPrefsIfNeeded(); + _LoadMetaDataFromPlayerPrefsIfNeeded(); if (ActiveMetaData == null) { return false; } ActiveMetaData.DefaultPlayer = playerULID; - SaveMetaDataToPlayerPrefs(); + _SaveMetaDataToPlayerPrefs(); return true; } - public static bool SetPlayerData(LootLockerPlayerData updatedPlayerData) + private bool _SetPlayerData(LootLockerPlayerData updatedPlayerData) { if (updatedPlayerData == null || string.IsNullOrEmpty(updatedPlayerData.ULID)) { return false; } - LoadMetaDataFromPlayerPrefsIfNeeded(); + _LoadMetaDataFromPlayerPrefsIfNeeded(); if (ActiveMetaData == null) { return false; } ActivePlayerData[updatedPlayerData.ULID] = updatedPlayerData; - SavePlayerDataToPlayerPrefs(updatedPlayerData.ULID); + _SavePlayerDataToPlayerPrefs(updatedPlayerData.ULID); ActiveMetaData.SavedPlayerStateULIDs.AddUnique(updatedPlayerData.ULID); if (!string.IsNullOrEmpty(updatedPlayerData.WhiteLabelEmail)) { @@ -273,21 +440,21 @@ public static bool SetPlayerData(LootLockerPlayerData updatedPlayerData) } if (string.IsNullOrEmpty(ActiveMetaData.DefaultPlayer) || !ActivePlayerData.ContainsKey(ActiveMetaData.DefaultPlayer)) { - SetDefaultPlayerULID(updatedPlayerData.ULID); + _SetDefaultPlayerULID(updatedPlayerData.ULID); } - SaveMetaDataToPlayerPrefs(); + _SaveMetaDataToPlayerPrefs(); return true; } - public static bool ClearSavedStateForPlayerWithULID(string playerULID) + private bool _ClearSavedStateForPlayerWithULID(string playerULID) { if (string.IsNullOrEmpty(playerULID)) { return false; } - if (!SaveStateExistsForPlayer(playerULID)) + if (!_SaveStateExistsForPlayer(playerULID)) { return true; } @@ -295,7 +462,7 @@ public static bool ClearSavedStateForPlayerWithULID(string playerULID) ActivePlayerData.Remove(playerULID); _stateWriter.DeleteKey($"{PlayerDataSaveSlot}_{playerULID}"); - LoadMetaDataFromPlayerPrefsIfNeeded(); + _LoadMetaDataFromPlayerPrefsIfNeeded(); if (ActiveMetaData != null) { ActiveMetaData.SavedPlayerStateULIDs.Remove(playerULID); @@ -309,15 +476,17 @@ public static bool ClearSavedStateForPlayerWithULID(string playerULID) { ActiveMetaData.WhiteLabelEmailToPlayerUlidMap.Remove(playerData?.WhiteLabelEmail); } - SaveMetaDataToPlayerPrefs(); + _SaveMetaDataToPlayerPrefs(); } + + LootLockerEventSystem.TriggerLocalSessionDeactivated(playerULID); return true; } - public static List ClearAllSavedStates() + private List _ClearAllSavedStates() { List removedULIDs = new List(); - LoadMetaDataFromPlayerPrefsIfNeeded(); + _LoadMetaDataFromPlayerPrefsIfNeeded(); if (ActiveMetaData == null) { return removedULIDs; @@ -326,21 +495,21 @@ public static List ClearAllSavedStates() List ulidsToRemove = new List(ActiveMetaData.SavedPlayerStateULIDs); foreach (string ULID in ulidsToRemove) { - if (ClearSavedStateForPlayerWithULID(ULID)) + if (_ClearSavedStateForPlayerWithULID(ULID)) { removedULIDs.Add(ULID); } } ActiveMetaData = new LootLockerStateMetaData(); - SaveMetaDataToPlayerPrefs(); + _SaveMetaDataToPlayerPrefs(); return removedULIDs; } - public static List ClearAllSavedStatesExceptForPlayer(string playerULID) + private List _ClearAllSavedStatesExceptForPlayer(string playerULID) { List removedULIDs = new List(); - LoadMetaDataFromPlayerPrefsIfNeeded(); + _LoadMetaDataFromPlayerPrefsIfNeeded(); if (ActiveMetaData == null) { return removedULIDs; @@ -351,18 +520,18 @@ public static List ClearAllSavedStatesExceptForPlayer(string playerULID) { if (!ULID.Equals(playerULID, StringComparison.OrdinalIgnoreCase)) { - if (ClearSavedStateForPlayerWithULID(ULID)) + if (_ClearSavedStateForPlayerWithULID(ULID)) { removedULIDs.Add(ULID); } } } - SetDefaultPlayerULID(playerULID); + _SetDefaultPlayerULID(playerULID); return removedULIDs; } - public static void SetPlayerULIDToInactive(string playerULID) + private void _SetPlayerULIDToInactive(string playerULID) { if (string.IsNullOrEmpty(playerULID) || !ActivePlayerData.ContainsKey(playerULID)) { @@ -370,14 +539,19 @@ public static void SetPlayerULIDToInactive(string playerULID) } ActivePlayerData.Remove(playerULID); + LootLockerEventSystem.TriggerLocalSessionDeactivated(playerULID); } - public static void SetAllPlayersToInactive() + private void _SetAllPlayersToInactive() { - ActivePlayerData.Clear(); + var activePlayers = ActivePlayerData.Keys.ToList(); + foreach (string playerULID in activePlayers) + { + _SetPlayerULIDToInactive(playerULID); + } } - public static void SetAllPlayersToInactiveExceptForPlayer(string playerULID) + private void _SetAllPlayersToInactiveExceptForPlayer(string playerULID) { if (string.IsNullOrEmpty(playerULID)) { @@ -387,20 +561,20 @@ public static void SetAllPlayersToInactiveExceptForPlayer(string playerULID) var keysToRemove = ActivePlayerData.Keys.Where(key => !key.Equals(playerULID, StringComparison.OrdinalIgnoreCase)).ToList(); foreach (string key in keysToRemove) { - ActivePlayerData.Remove(key); + _SetPlayerULIDToInactive(key); } - SetDefaultPlayerULID(playerULID); + _SetDefaultPlayerULID(playerULID); } - public static List GetActivePlayerULIDs() + private List _GetActivePlayerULIDs() { return ActivePlayerData.Keys.ToList(); } - public static List GetCachedPlayerULIDs() + private List _GetCachedPlayerULIDs() { - LoadMetaDataFromPlayerPrefsIfNeeded(); + _LoadMetaDataFromPlayerPrefsIfNeeded(); if (ActiveMetaData == null) { return new List(); @@ -409,9 +583,9 @@ public static List GetCachedPlayerULIDs() } [CanBeNull] - public static string GetPlayerUlidFromWLEmail(string email) + private string _GetPlayerUlidFromWLEmail(string email) { - LoadMetaDataFromPlayerPrefsIfNeeded(); + _LoadMetaDataFromPlayerPrefsIfNeeded(); if (ActiveMetaData == null) { return null; @@ -421,11 +595,106 @@ public static string GetPlayerUlidFromWLEmail(string email) return playerUlid; } - public static void Reset() + private void _UnloadState() { ActiveMetaData = null; ActivePlayerData.Clear(); } + + #endregion // Private Instance Methods + + #region Static Methods + //================================================== + // Static Methods (Primary Interface) + //================================================== + + public static void overrideStateWriter(ILootLockerStateWriter newWriter) + { + GetInstance()?._OverrideStateWriter(newWriter); + } + + public static bool SaveStateExistsForPlayer(string playerULID) + { + return GetInstance()?._SaveStateExistsForPlayer(playerULID) ?? false; + } + + public static LootLockerPlayerData GetPlayerDataForPlayerWithUlidWithoutChangingState(string playerULID) + { + return GetInstance()?._GetPlayerDataForPlayerWithUlidWithoutChangingState(playerULID) ?? new LootLockerPlayerData(); + } + + [CanBeNull] + public static LootLockerPlayerData GetStateForPlayerOrDefaultStateOrEmpty(string playerULID) + { + return GetInstance()?._GetStateForPlayerOrDefaultStateOrEmpty(playerULID); + } + + public static string GetDefaultPlayerULID() + { + return GetInstance()?._GetDefaultPlayerULID() ?? string.Empty; + } + + public static bool SetDefaultPlayerULID(string playerULID) + { + return GetInstance()?._SetDefaultPlayerULID(playerULID) ?? false; + } + + public static bool SetPlayerData(LootLockerPlayerData updatedPlayerData) + { + return GetInstance()?._SetPlayerData(updatedPlayerData) ?? false; + } + + public static bool ClearSavedStateForPlayerWithULID(string playerULID) + { + return GetInstance()?._ClearSavedStateForPlayerWithULID(playerULID) ?? false; + } + + public static List ClearAllSavedStates() + { + return GetInstance()?._ClearAllSavedStates() ?? new List(); + } + + public static List ClearAllSavedStatesExceptForPlayer(string playerULID) + { + return GetInstance()?._ClearAllSavedStatesExceptForPlayer(playerULID) ?? new List(); + } + + public static void SetPlayerULIDToInactive(string playerULID) + { + GetInstance()?._SetPlayerULIDToInactive(playerULID); + } + + public static void SetAllPlayersToInactive() + { + GetInstance()?._SetAllPlayersToInactive(); + } + + public static void SetAllPlayersToInactiveExceptForPlayer(string playerULID) + { + GetInstance()?._SetAllPlayersToInactiveExceptForPlayer(playerULID); + } + + public static List GetActivePlayerULIDs() + { + return GetInstance()?._GetActivePlayerULIDs() ?? new List(); + } + + public static List GetCachedPlayerULIDs() + { + return GetInstance()?._GetCachedPlayerULIDs() ?? new List(); + } + + [CanBeNull] + public static string GetPlayerUlidFromWLEmail(string email) + { + return GetInstance()?._GetPlayerUlidFromWLEmail(email); + } + + public static void UnloadState() + { + GetInstance()?._UnloadState(); + } + + #endregion // Static Methods } - #endregion // Public Methods -} \ No newline at end of file +} diff --git a/Runtime/Editor/Editor UI/LootLockerAdminExtension.cs b/Runtime/Editor/Editor UI/LootLockerAdminExtension.cs index aa05bab5f..35039753f 100644 --- a/Runtime/Editor/Editor UI/LootLockerAdminExtension.cs +++ b/Runtime/Editor/Editor UI/LootLockerAdminExtension.cs @@ -101,6 +101,18 @@ public partial class LootLockerAdminExtension : EditorWindow private Toggle logErrorsAsWarningsToggle, logInBuildsToggle, allowTokenRefreshToggle; #endregion + #region SDK Tools + [MenuItem("Window/LootLocker/Tools/Clear Local Player Data", false, 101)] + public static void ClearLocalPlayerData() + { + if (!EditorUtility.DisplayDialog("Clear Local Player Data", "Are you sure you want to clear all local player data? This action cannot be undone.", "Yes", "No")) + { + return; + } + LootLockerStateData.ClearAllSavedStates(); + } + #endregion + #region Window Management [MenuItem("Window/LootLocker/Manage", false, 100)] public static void Run() @@ -300,11 +312,6 @@ private void ConfigureMfaFlow() SetMenuVisibility(apiKey: false, changeGame: false, logout: true); } #endregion - - private void OnDestroy() - { - LootLockerHTTPClient.ResetInstance(); - } } } #endif diff --git a/Runtime/Editor/ProjectSettings.cs b/Runtime/Editor/ProjectSettings.cs index 161f52bcb..1a6b1024f 100644 --- a/Runtime/Editor/ProjectSettings.cs +++ b/Runtime/Editor/ProjectSettings.cs @@ -101,7 +101,7 @@ private void DrawGameSettings() if (match.Success) { string regexKey = match.Value; - Debug.LogWarning("You accidentally used the domain url instead of the domain key,\nWe took the domain key from the url.: " + regexKey); + LootLockerLogger.Log("You accidentally used the domain url instead of the domain key,\nWe took the domain key from the url.: " + regexKey, LootLockerLogger.LogLevel.Info); gameSettings.domainKey = regexKey; m_CustomSettings.FindProperty("domainKey").stringValue = regexKey; } @@ -176,6 +176,8 @@ private void DrawGameSettings() gameSettings.allowTokenRefresh = m_CustomSettings.FindProperty("allowTokenRefresh").boolValue; } EditorGUILayout.Space(); + + DrawPresenceSettings(); } private static bool IsSemverString(string str) @@ -184,6 +186,50 @@ private static bool IsSemverString(string str) @"^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?$"); } + private void DrawPresenceSettings() + { +#if LOOTLOCKER_ENABLE_PRESENCE + EditorGUILayout.LabelField("Presence Settings", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + // Enable presence toggle + EditorGUI.BeginChangeCheck(); + EditorGUILayout.PropertyField(m_CustomSettings.FindProperty("enablePresence")); + if (EditorGUI.EndChangeCheck()) + { + gameSettings.enablePresence = m_CustomSettings.FindProperty("enablePresence").boolValue; + } + + // Only show sub-settings if presence is enabled + if (gameSettings.enablePresence) + { + EditorGUILayout.Space(); + + // Auto-connect toggle + EditorGUI.BeginChangeCheck(); + EditorGUILayout.PropertyField(m_CustomSettings.FindProperty("enablePresenceAutoConnect"), new GUIContent("Auto Connect")); + if (EditorGUI.EndChangeCheck()) + { + gameSettings.enablePresenceAutoConnect = m_CustomSettings.FindProperty("enablePresenceAutoConnect").boolValue; + } + + // Auto-disconnect on focus change toggle + EditorGUI.BeginChangeCheck(); + EditorGUILayout.PropertyField(m_CustomSettings.FindProperty("enablePresenceAutoDisconnectOnFocusChange"), new GUIContent("Auto Disconnect on Pause/Focus Loss")); + if (EditorGUI.EndChangeCheck()) + { + gameSettings.enablePresenceAutoDisconnectOnFocusChange = m_CustomSettings.FindProperty("enablePresenceAutoDisconnectOnFocusChange").boolValue; + } + + EditorGUILayout.Space(); + + EditorGUILayout.HelpBox("These are default settings that can be overridden using SDK methods. You can use that to control presence behavior differently for different platforms.", MessageType.Info); + } + + EditorGUILayout.Space(); +#endif + } + [SettingsProvider] public static SettingsProvider CreateProvider() { diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index c937a108c..b8c3e273c 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -21,13 +21,6 @@ namespace LootLocker.Requests { public partial class LootLockerSDKManager { -#if UNITY_EDITOR - [InitializeOnEnterPlayMode] - static void OnEnterPlaymodeInEditor(EnterPlayModeOptions options) - { - initialized = false; - } -#endif /// /// Stores which platform the player currently has a session for. @@ -39,12 +32,12 @@ public static string GetCurrentPlatform(string forPlayerWithUlid = null) } #region Init - private static bool initialized; static bool Init() { - LootLockerHTTPClient.Instantiate(); - return LoadConfig(); + // Initialize the lifecycle manager which will set up HTTP client + var _ = LootLockerLifecycleManager.Instance; + return LootLockerConfig.ValidateSettings(); } /// @@ -57,26 +50,21 @@ static bool Init() /// True if initialized successfully, false otherwise public static bool Init(string apiKey, string gameVersion, string domainKey, LootLockerLogger.LogLevel logLevel = LootLockerLogger.LogLevel.Info) { - LootLockerHTTPClient.Instantiate(); - return LootLockerConfig.CreateNewSettings(apiKey, gameVersion, domainKey, logLevel: logLevel); - } - - static bool LoadConfig() - { - initialized = false; - if (LootLockerConfig.current == null) - { - LootLockerLogger.Log("SDK could not find settings, please contact support \n You can also set config manually by calling Init(string apiKey, string gameVersion, bool onDevelopmentMode, string domainKey)", LootLockerLogger.LogLevel.Error); - return false; - } - if (string.IsNullOrEmpty(LootLockerConfig.current.apiKey)) + // Create new settings first + bool configResult = LootLockerConfig.CreateNewSettings(apiKey, gameVersion, domainKey, logLevel); + if (!configResult) { - LootLockerLogger.Log("API Key has not been set, set it in project settings or manually calling Init(string apiKey, string gameVersion, bool onDevelopmentMode, string domainKey)", LootLockerLogger.LogLevel.Error); return false; } - initialized = true; - return initialized; + LootLockerLifecycleManager.ResetInstance(); + + return LootLockerLifecycleManager.IsReady; + } + + static bool LoadConfig() + { + return LootLockerConfig.ValidateSettings(); } /// @@ -97,13 +85,20 @@ private static bool CheckActiveSession(string forPlayerWithUlid = null) /// True if initialized, false otherwise. public static bool CheckInitialized(bool skipSessionCheck = false, string forPlayerWithUlid = null) { - if (!initialized) + // Check if lifecycle manager exists and is ready, if not try to initialize + if (!LootLockerLifecycleManager.IsReady) { - LootLockerStateData.Reset(); if (!Init()) { return false; } + + // Double check that initialization succeeded + if (!LootLockerLifecycleManager.IsReady) + { + LootLockerLogger.Log("LootLocker services are still initializing. Please try again in a moment.", LootLockerLogger.LogLevel.Warning); + return false; + } } if (skipSessionCheck) @@ -114,15 +109,15 @@ public static bool CheckInitialized(bool skipSessionCheck = false, string forPla return CheckActiveSession(forPlayerWithUlid); } -#if !LOOTLOCKER_LEGACY_HTTP_STACK && LOOTLOCKER_ENABLE_HTTP_CONFIGURATION_OVERRIDE +#if LOOTLOCKER_ENABLE_HTTP_CONFIGURATION_OVERRIDE public static void _OverrideLootLockerHTTPClientConfiguration(int maxRetries, int incrementalBackoffFactor, int initialRetryWaitTime) { - LootLockerHTTPClient.Get().OverrideConfiguration(new LootLockerHTTPClientConfiguration(maxRetries, incrementalBackoffFactor, initialRetryWaitTime)); + LootLockerHTTPClient.Get()?.OverrideConfiguration(new LootLockerHTTPClientConfiguration(maxRetries, incrementalBackoffFactor, initialRetryWaitTime)); } public static void _OverrideLootLockerCertificateHandler(CertificateHandler certificateHandler) { - LootLockerHTTPClient.Get().OverrideCertificateHandler(certificateHandler); + LootLockerHTTPClient.Get()?.OverrideCertificateHandler(certificateHandler); } #endif @@ -136,6 +131,21 @@ public static void SetStateWriter(ILootLockerStateWriter stateWriter) LootLockerStateData.overrideStateWriter(stateWriter); } #endif + + /// + /// Reset the entire LootLocker SDK, clearing all services and state. + /// This will terminate all ongoing requests and reset all cached data. + /// Call this when switching between different game contexts or during application cleanup. + /// After calling this method, you'll need to re-initialize the SDK before making API calls. + /// + public static void ResetSDK() + { + LootLockerLogger.Log("Resetting LootLocker SDK - all services and state will be cleared", LootLockerLogger.LogLevel.Info); + + LootLockerLifecycleManager.ResetInstance(); + + LootLockerLogger.Log("LootLocker SDK reset complete", LootLockerLogger.LogLevel.Info); + } #endregion #region Multi-User Management @@ -314,7 +324,7 @@ public static void StartPlaystationNetworkSession(string psnOnlineId, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = "", @@ -329,7 +339,8 @@ public static void StartPlaystationNetworkSession(string psnOnlineId, Action(serverResponse); if (sessionResponse.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = sessionResponse.session_token, RefreshToken = "", @@ -394,7 +405,8 @@ public static void VerifyPlayerAndStartPlaystationNetworkSession(string AuthCode CreatedAt = sessionResponse.player_created_at, WalletID = sessionResponse.wallet_id, SessionOptionals = Optionals - }); + }; + LootLockerEventSystem.TriggerSessionStarted(playerData); } onComplete?.Invoke(sessionResponse); @@ -432,7 +444,7 @@ public static void VerifyPlayerAndStartPlaystationNetworkV3Session(string AuthCo var sessionResponse = LootLockerResponse.Deserialize(serverResponse); if (sessionResponse.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = sessionResponse.session_token, RefreshToken = "", @@ -448,7 +460,8 @@ public static void VerifyPlayerAndStartPlaystationNetworkV3Session(string AuthCo CreatedAt = sessionResponse.player_created_at, WalletID = sessionResponse.wallet_id, SessionOptionals = Optionals - }); + }; + LootLockerEventSystem.TriggerSessionStarted(playerData); } onComplete?.Invoke(sessionResponse); @@ -478,7 +491,7 @@ public static void StartAndroidSession(string deviceId, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = "", @@ -494,7 +507,8 @@ public static void StartAndroidSession(string deviceId, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = "", @@ -542,7 +556,8 @@ public static void StartAmazonLunaSession(string amazonLunaGuid, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = "", @@ -642,7 +657,9 @@ public static void StartGuestSession(string identifier, Action(serverResponse); if (sessionResponse.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = sessionResponse.session_token, RefreshToken = "", @@ -687,7 +704,9 @@ public static void VerifyPlayerAndStartSteamSession(ref byte[] ticket, uint tick CreatedAt = sessionResponse.player_created_at, WalletID = sessionResponse.wallet_id, SessionOptionals = Optionals - }); + }; + + LootLockerEventSystem.TriggerSessionStarted(playerData); } onComplete?.Invoke(sessionResponse); @@ -715,7 +734,7 @@ public static void VerifyPlayerAndStartSteamSessionWithSteamAppId(ref byte[] tic var sessionResponse = LootLockerResponse.Deserialize(serverResponse); if (sessionResponse.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = sessionResponse.session_token, RefreshToken = "", @@ -731,7 +750,9 @@ public static void VerifyPlayerAndStartSteamSessionWithSteamAppId(ref byte[] tic CreatedAt = sessionResponse.player_created_at, WalletID = sessionResponse.wallet_id, SessionOptionals = Optionals - }); + }; + + LootLockerEventSystem.TriggerSessionStarted(playerData); } onComplete?.Invoke(sessionResponse); @@ -778,7 +799,7 @@ public static void StartNintendoSwitchSession(string nsa_id_token, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = "", @@ -794,7 +815,9 @@ public static void StartNintendoSwitchSession(string nsa_id_token, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = "", @@ -842,7 +865,9 @@ public static void StartXboxOneSession(string xbox_user_token, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -890,7 +915,9 @@ public static void StartGoogleSession(string idToken, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -940,7 +967,9 @@ public static void StartGoogleSession(string idToken, GooglePlatform googlePlatf CreatedAt = response.player_created_at, WalletID = response.wallet_id, SessionOptionals = Optionals - }); + }; + + LootLockerEventSystem.TriggerSessionStarted(playerData); } onComplete?.Invoke(response); @@ -1005,7 +1034,7 @@ public static void RefreshGoogleSession(string refresh_token, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -1021,7 +1050,9 @@ public static void RefreshGoogleSession(string refresh_token, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -1071,7 +1102,9 @@ public static void StartGooglePlayGamesSession(string authCode, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -1122,7 +1155,9 @@ public static void RefreshGooglePlayGamesSession(string refreshToken, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -1195,7 +1230,9 @@ public static void StartAppleSession(string authorization_code, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -1276,7 +1313,9 @@ public static void RefreshAppleSession(string refresh_token, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -1329,7 +1368,9 @@ public static void StartAppleGameCenterSession(string bundleId, string playerId, CreatedAt = response.player_created_at, WalletID = response.wallet_id, SessionOptionals = Optionals - }); + }; + + LootLockerEventSystem.TriggerSessionStarted(playerData); } onComplete?.Invoke(response); @@ -1374,7 +1415,7 @@ public static void RefreshAppleGameCenterSession(Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + LootLockerEventSystem.TriggerSessionRefreshed(new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -1422,7 +1463,7 @@ public static void StartEpicSession(string id_token, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -1438,7 +1479,9 @@ public static void StartEpicSession(string id_token, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + LootLockerEventSystem.TriggerSessionRefreshed(new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -1557,7 +1600,7 @@ public static void StartMetaSession(string user_id, string nonce, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + LootLockerEventSystem.TriggerSessionStarted(new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -1636,7 +1679,7 @@ public static void RefreshMetaSession(string refresh_token, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + LootLockerEventSystem.TriggerSessionRefreshed(new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -1686,7 +1729,7 @@ public static void StartDiscordSession(string accessToken, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + LootLockerEventSystem.TriggerSessionStarted(new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -1778,7 +1821,7 @@ public static void RefreshDiscordSession(string refresh_token, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + LootLockerEventSystem.TriggerSessionRefreshed(new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -1829,14 +1872,7 @@ public static void EndSession(Action onComplete, bool var response = LootLockerResponse.Deserialize(serverResponse); if (response.success) { - if (clearLocalState) - { - ClearLocalSession(serverResponse.requestContext.player_ulid); - } - else - { - LootLockerStateData.SetPlayerULIDToInactive(serverResponse.requestContext.player_ulid); - } + LootLockerEventSystem.TriggerSessionEnded(serverResponse.requestContext.player_ulid, clearLocalState); } onComplete?.Invoke(response); @@ -1850,8 +1886,230 @@ public static void EndSession(Action onComplete, bool /// Execute the request for the specified player. public static void ClearLocalSession(string forPlayerWithUlid) { - LootLockerStateData.ClearSavedStateForPlayerWithULID(forPlayerWithUlid); + ClearCacheForPlayer(forPlayerWithUlid); + } + #endregion + + #region Event System + + /// + /// Subscribe to SDK events using the unified event system + /// + /// The event data type + /// The event type to subscribe to + /// The event handler + public static void Subscribe(LootLockerEventType eventType, LootLockerEventHandler handler) where T : LootLockerEventData + { + LootLockerEventSystem.Subscribe(eventType, handler); + } + + /// + /// Unsubscribe from SDK events + /// + /// The event data type + /// The event type to unsubscribe from + /// The event handler to remove + public static void Unsubscribe(LootLockerEventType eventType, LootLockerEventHandler handler) where T : LootLockerEventData + { + LootLockerEventSystem.Unsubscribe(eventType, handler); } + + #endregion + + #region Presence + +#if LOOTLOCKER_ENABLE_PRESENCE + /// + /// Force start the Presence WebSocket connection manually. + /// This will override the automatic presence management and manually establish a connection. + /// Use this when you need precise control over presence connections, otherwise let the SDK auto-manage. + /// + /// NOTE: To use this the presence feature must be enabled for your game. Contact LootLocker support if you need assistance. + /// + /// Callback indicating whether the connection and authentication succeeded + /// Optional: Execute the request for the specified player. If not supplied, the default player will be used. + public static void ForceStartPresenceConnection(LootLockerPresenceCallback onComplete = null, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(false, "SDK not initialized"); + return; + } + + // Connect with simple completion callback + LootLockerPresenceManager.ConnectPresence(forPlayerWithUlid, onComplete); + } + + /// + /// Force stop the Presence WebSocket connection manually. + /// This will override the automatic presence management and manually disconnect. + /// Use this when you need precise control over presence connections, otherwise let the SDK auto-manage. + /// + /// NOTE: To use this the presence feature must be enabled for your game. Contact LootLocker support if you need assistance. + /// + /// Optional callback indicating whether the disconnection succeeded + /// Optional: Execute the request for the specified player. If not supplied, the default player will be used. + public static void ForceStopPresenceConnection(LootLockerPresenceCallback onComplete = null, string forPlayerWithUlid = null) + { + LootLockerPresenceManager.DisconnectPresence(forPlayerWithUlid, onComplete); + } + + /// + /// Force stop all Presence WebSocket connections manually. + /// This will override the automatic presence management and disconnect all active connections. + /// Use this when you need to immediately disconnect all presence connections. + /// + /// NOTE: To use this the presence feature must be enabled for your game. Contact LootLocker support if you need assistance. + /// + public static void ForceStopAllPresenceConnections() + { + LootLockerPresenceManager.DisconnectAll(); + } + + /// + /// Get a list of player ULIDs that currently have active Presence connections + /// + /// NOTE: To use this the presence feature must be enabled for your game. Contact LootLocker support if you need assistance. + /// + /// Collection of player ULIDs that have active presence connections + public static IEnumerable ListPresenceConnections() + { + return LootLockerPresenceManager.ActiveClientUlids; + } + + /// + /// Update the player's presence status + /// + /// NOTE: To use this the *advanced* presence feature must be enabled for your game. Contact LootLocker support if you need assistance. + /// + /// The status to set (e.g., "online", "in_game", "away") + /// Optional metadata to include with the status + /// Callback for the result of the operation + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void UpdatePresenceStatus(string status, Dictionary metadata = null, Action onComplete = null, string forPlayerWithUlid = null) + { + LootLockerPresenceManager.UpdatePresenceStatus(status, metadata, forPlayerWithUlid, (success, error) => { + onComplete?.Invoke(success); + }); + } + + /// + /// Get the current Presence connection state for a specific player + /// + /// NOTE: To use this the presence feature must be enabled for your game. Contact LootLocker support if you need assistance. + /// + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + /// The current connection state + public static LootLockerPresenceConnectionState GetPresenceConnectionState(string forPlayerWithUlid = null) + { + return LootLockerPresenceManager.GetPresenceConnectionState(forPlayerWithUlid); + } + + /// + /// Check if Presence is connected and authenticated for a specific player + /// + /// NOTE: To use this the presence feature must be enabled for your game. Contact LootLocker support if you need assistance. + /// + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + /// True if connected and active, false otherwise + public static bool IsPresenceConnected(string forPlayerWithUlid = null) + { + return LootLockerPresenceManager.IsPresenceConnected(forPlayerWithUlid); + } + + /// + /// Get statistics about the Presence connection for a specific player + /// + /// NOTE: To use this the presence feature must be enabled for your game. Contact LootLocker support if you need assistance. + /// + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + /// Connection statistics + public static LootLockerPresenceConnectionStats GetPresenceConnectionStats(string forPlayerWithUlid) + { + return LootLockerPresenceManager.GetPresenceConnectionStats(forPlayerWithUlid); + } + + /// + /// Get the last status that was sent for a specific player + /// + /// NOTE: To use this the *advanced* presence feature must be enabled for your game. Contact LootLocker support if you need assistance. + /// + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + /// The last sent status string, or null if no client is found or no status has been sent + public static string GetCurrentPresenceStatus(string forPlayerWithUlid = null) + { + return LootLockerPresenceManager.GetLastSentStatus(forPlayerWithUlid); + } + + /// + /// Enable or disable the entire Presence system + /// + /// NOTE: To use this the presence feature must be enabled for your game. Contact LootLocker support if you need assistance. + /// + /// Whether to enable presence + public static void SetPresenceEnabled(bool enabled) + { + LootLockerPresenceManager.IsEnabled = enabled; + } + + /// + /// Check if presence system is currently enabled + /// + /// NOTE: To use this the presence feature must be enabled for your game. Contact LootLocker support if you need assistance. + /// + /// True if enabled, false otherwise + public static bool IsPresenceEnabled() + { + return LootLockerPresenceManager.IsEnabled; + } + + /// + /// Enable or disable automatic presence connection when sessions start + /// + /// NOTE: To use this the presence feature must be enabled for your game. Contact LootLocker support if you need assistance. + /// + /// Whether to auto-connect presence + public static void SetPresenceAutoConnectEnabled(bool enabled) + { + LootLockerPresenceManager.AutoConnectEnabled = enabled; + } + + /// + /// Check if automatic presence connections are enabled + /// + /// NOTE: To use this the presence feature must be enabled for your game. Contact LootLocker support if you need assistance. + /// + /// True if auto-connect is enabled, false otherwise + public static bool IsPresenceAutoConnectEnabled() + { + return LootLockerPresenceManager.AutoConnectEnabled; + } + + /// + /// Enable or disable automatic presence disconnection when the application loses focus or is paused. + /// When enabled, presence connections will automatically disconnect when the app goes to background + /// and reconnect when it returns to foreground. Useful for saving battery on mobile or managing resources. + /// + /// NOTE: To use this the presence feature must be enabled for your game. Contact LootLocker support if you need assistance. + /// + /// True to enable auto-disconnect on focus change, false to disable + public static void SetPresenceAutoDisconnectOnFocusChangeEnabled(bool enabled) + { + LootLockerPresenceManager.AutoDisconnectOnFocusChange = enabled; + } + + /// + /// Check if automatic presence disconnection on focus change is enabled + /// + /// NOTE: To use this the presence feature must be enabled for your game. Contact LootLocker support if you need assistance. + /// + /// True if auto-disconnect on focus change is enabled, false otherwise + public static bool IsPresenceAutoDisconnectOnFocusChangeEnabled() + { + return LootLockerPresenceManager.AutoDisconnectOnFocusChange; + } +#endif + #endregion #region Connected Accounts @@ -2262,7 +2520,7 @@ public static void RefreshRemoteSession(string refreshToken, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + LootLockerEventSystem.TriggerSessionRefreshed(new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -2664,7 +2922,7 @@ public static void StartWhiteLabelSession(LootLockerWhiteLabelSessionRequest ses var response = LootLockerResponse.Deserialize(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + LootLockerEventSystem.TriggerSessionStarted(new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = "", diff --git a/Runtime/Game/Requests/BroadcastRequest.cs b/Runtime/Game/Requests/BroadcastRequest.cs index 4c502624d..b8f7122d5 100644 --- a/Runtime/Game/Requests/BroadcastRequest.cs +++ b/Runtime/Game/Requests/BroadcastRequest.cs @@ -245,7 +245,7 @@ public LootLockerListBroadcastsResponse(__LootLockerInternalListBroadcastsRespon translatedBroadcast.language_codes = new string[internalBroadcast.languages?.Length ?? 0]; translatedBroadcast.languages = new Dictionary(); - for (int j = 0; j < internalBroadcast.languages.Length; j++) + for (int j = 0; j < internalBroadcast?.languages?.Length; j++) { var internalLang = internalBroadcast.languages[j]; if (internalLang == null || string.IsNullOrEmpty(internalLang.language_code)) diff --git a/Runtime/Game/Requests/RemoteSessionRequest.cs b/Runtime/Game/Requests/RemoteSessionRequest.cs index 91d583f93..56ece827e 100644 --- a/Runtime/Game/Requests/RemoteSessionRequest.cs +++ b/Runtime/Game/Requests/RemoteSessionRequest.cs @@ -190,39 +190,85 @@ namespace LootLocker { public partial class LootLockerAPIManager { - public class RemoteSessionPoller : MonoBehaviour + public class RemoteSessionPoller : MonoBehaviour, ILootLockerService { - #region Singleton Setup - private static RemoteSessionPoller _instance; - protected static RemoteSessionPoller GetInstance() + #region ILootLockerService Implementation + + public bool IsInitialized { get; private set; } + public string ServiceName => "RemoteSessionPoller"; + + void ILootLockerService.Initialize() + { + if (IsInitialized) return; + IsInitialized = true; + } + + void ILootLockerService.Reset() { - if (_instance == null) + + // Cancel all ongoing processes + if (_remoteSessionsProcesses != null) { - _instance = new GameObject("LootLockerRemoteSessionPoller").AddComponent(); + foreach (var process in _remoteSessionsProcesses.Values) + { + if (process != null) + { + process.ShouldCancel = true; + } + } + _remoteSessionsProcesses.Clear(); } - if (Application.isPlaying) - DontDestroyOnLoad(_instance.gameObject); + IsInitialized = false; + _instance = null; + } + + void ILootLockerService.HandleApplicationPause(bool pauseStatus) + { + // RemoteSessionPoller doesn't need special pause handling + } - return _instance; + void ILootLockerService.HandleApplicationFocus(bool hasFocus) + { + // RemoteSessionPoller doesn't need special focus handling } - protected static bool DestroyInstance() + void ILootLockerService.HandleApplicationQuit() { - if (_instance == null) - return false; - Destroy(_instance.gameObject); - _instance = null; - return true; + ((ILootLockerService)this).Reset(); } -#if UNITY_EDITOR - [InitializeOnEnterPlayMode] - static void OnEnterPlaymodeInEditor(EnterPlayModeOptions options) + #endregion + + #region Hybrid Singleton Pattern + + private static RemoteSessionPoller _instance; + private static readonly object _instanceLock = new object(); + + protected static RemoteSessionPoller GetInstance() { - DestroyInstance(); + if (_instance != null) + { + return _instance; + } + + lock (_instanceLock) + { + if (_instance == null) + { + // Register the service on-demand if not already registered + if (!LootLockerLifecycleManager.HasService()) + { + LootLockerLifecycleManager.RegisterService(); + } + + // Get service from LifecycleManager + _instance = LootLockerLifecycleManager.GetService(); + } + } + + return _instance; } -#endif #endregion @@ -236,14 +282,25 @@ public static Guid StartRemoteSessionWithContinualPolling( float timeOutAfterMinutes = 5.0f, string forPlayerWithUlid = null) { - return GetInstance()._StartRemoteSessionWithContinualPolling(leaseIntent, remoteSessionLeaseInformation, + var instance = GetInstance(); + if (instance == null) + { + remoteSessionCompleted?.Invoke(new LootLockerStartRemoteSessionResponse + { + success = false, + lease_status = LootLockerRemoteSessionLeaseStatus.Failed, + errorData = new LootLockerErrorData { message = "Failed to start remote session with continual polling: RemoteSessionPoller instance could not be created." } + }); + return Guid.Empty; + } + return instance._StartRemoteSessionWithContinualPolling(leaseIntent, remoteSessionLeaseInformation, remoteSessionLeaseStatusUpdateCallback, remoteSessionCompleted, pollingIntervalSeconds, timeOutAfterMinutes, forPlayerWithUlid); } public static void CancelRemoteSessionProcess(Guid processGuid) { - GetInstance()._CancelRemoteSessionProcess(processGuid); + GetInstance()?._CancelRemoteSessionProcess(processGuid); } #endregion @@ -269,18 +326,34 @@ private class LootLockerRemoteSessionProcess private readonly Dictionary _remoteSessionsProcesses = new Dictionary(); - - private static void AddRemoteSessionProcess(Guid processGuid, LootLockerRemoteSessionProcess processData) - { - GetInstance()._remoteSessionsProcesses.Add(processGuid, processData); - } + private static void RemoveRemoteSessionProcess(Guid processGuid) { var i = GetInstance(); + if (i == null) return; i._remoteSessionsProcesses.Remove(processGuid); + + // Auto-cleanup: if no more processes are running, unregister the service if (i._remoteSessionsProcesses.Count <= 0) { - DestroyInstance(); + CleanupServiceWhenDone(); + } + } + + /// + /// Cleanup and unregister the RemoteSessionPoller service when all processes are complete + /// + private static void CleanupServiceWhenDone() + { + if (LootLockerLifecycleManager.HasService()) + { + LootLockerLogger.Log("All remote session processes complete - cleaning up RemoteSessionPoller", LootLockerLogger.LogLevel.Debug); + + // Reset our local cache first + _instance = null; + + // Remove the service from LifecycleManager + LootLockerLifecycleManager.UnregisterService(); } } @@ -394,7 +467,7 @@ private Guid _StartRemoteSessionWithContinualPolling( Intent = leaseIntent, forPlayerWithUlid = forPlayerWithUlid }; - AddRemoteSessionProcess(processGuid, lootLockerRemoteSessionProcess); + _remoteSessionsProcesses.Add(processGuid, lootLockerRemoteSessionProcess); LootLockerAPIManager.GetGameInfo(gameInfoResponse => { @@ -488,7 +561,7 @@ private void StartRemoteSession(string leaseCode, string nonce, Action + /// Validate the current configuration settings + /// + /// True if configuration is valid, false otherwise + public static bool ValidateSettings() + { + if (current == null) + { + LootLockerLogger.Log("SDK could not find settings, please contact support \n You can also set config manually by calling Init(string apiKey, string gameVersion, string domainKey)", LootLockerLogger.LogLevel.Error); + return false; + } + if (string.IsNullOrEmpty(current.apiKey)) + { + LootLockerLogger.Log("API Key has not been set, set it in project settings or manually calling Init(string apiKey, string gameVersion, string domainKey)", LootLockerLogger.LogLevel.Error); + return false; + } + + return true; + } + public static bool ClearSettings() { _current.apiKey = null; @@ -289,13 +331,16 @@ private void ConstructUrls() { string urlCore = GetUrlCore(); string startOfUrl = urlCore.Contains("localhost") ? "http://" : UrlProtocol; + string wssStartOfUrl = urlCore.Contains("localhost") ? "ws://" : WssProtocol; if (!string.IsNullOrEmpty(domainKey)) { startOfUrl += domainKey + "."; + wssStartOfUrl += domainKey + "."; } adminUrl = startOfUrl + urlCore + AdminUrlAppendage; playerUrl = startOfUrl + urlCore + PlayerUrlAppendage; userUrl = startOfUrl + urlCore + UserUrlAppendage; + webSocketBaseUrl = wssStartOfUrl + urlCore + UserUrlAppendage; baseUrl = startOfUrl + urlCore; } @@ -323,6 +368,7 @@ public static LootLockerConfig current public string game_version = "1.0.0.0"; [HideInInspector] public string sdk_version = ""; [HideInInspector] private static readonly string UrlProtocol = "https://"; + [HideInInspector] private static readonly string WssProtocol = "wss://"; [HideInInspector] private static readonly string UrlCore = "api.lootlocker.com"; [HideInInspector] private static string UrlCoreOverride = #if LOOTLOCKER_TARGET_STAGE_ENV @@ -349,6 +395,7 @@ public static bool IsTargetingProductionEnvironment() [HideInInspector] public string adminUrl = UrlProtocol + GetUrlCore() + AdminUrlAppendage; [HideInInspector] public string playerUrl = UrlProtocol + GetUrlCore() + PlayerUrlAppendage; [HideInInspector] public string userUrl = UrlProtocol + GetUrlCore() + UserUrlAppendage; + [HideInInspector] public string webSocketBaseUrl = WssProtocol + GetUrlCore() + UserUrlAppendage; [HideInInspector] public string baseUrl = UrlProtocol + GetUrlCore(); [HideInInspector] public float clientSideRequestTimeOut = 180f; public LootLockerLogger.LogLevel logLevel = LootLockerLogger.LogLevel.Info; @@ -359,6 +406,17 @@ public static bool IsTargetingProductionEnvironment() public bool logInBuilds = false; public bool allowTokenRefresh = true; +#if LOOTLOCKER_ENABLE_PRESENCE + [Tooltip("Enable WebSocket presence system by default. Can be controlled at runtime via SetPresenceEnabled().")] + public bool enablePresence = false; + + [Tooltip("Automatically connect presence when sessions are started. Can be controlled at runtime via SetPresenceAutoConnectEnabled().")] + public bool enablePresenceAutoConnect = true; + + [Tooltip("Automatically disconnect presence when app loses focus or is paused (useful for battery saving). Can be controlled at runtime via SetPresenceAutoDisconnectOnFocusChangeEnabled().")] + public bool enablePresenceAutoDisconnectOnFocusChange = false; +#endif + #if UNITY_EDITOR [InitializeOnEnterPlayMode] static void OnEnterPlaymodeInEditor(EnterPlayModeOptions options) diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs index 51f346f02..9cc760a79 100644 --- a/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs @@ -107,6 +107,10 @@ public class LootLockerTestConfigurationEndpoints [Header("LootLocker Admin API Metadata Operations")] public static EndPointClass metadataOperations = new EndPointClass("game/#GAMEID#/metadata", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + + [Header("LootLocker Admin Title Config Operations")] + public static EndPointClass getTitleConfig = new EndPointClass("game/#GAMEID#/config/{0}", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin); + public static EndPointClass updateTitleConfig = new EndPointClass("game/#GAMEID#/config/{0}", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); } #endregion } diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationGame.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationGame.cs index a8bf80329..a82ac9490 100644 --- a/Tests/LootLockerTestUtils/LootLockerTestConfigurationGame.cs +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationGame.cs @@ -136,6 +136,7 @@ public bool InitializeLootLockerSDK() string adminToken = LootLockerConfig.current.adminToken; bool result = LootLockerSDKManager.Init(GetApiKeyForActiveEnvironment(), GameVersion, GameDomainKey, LootLockerLogger.LogLevel.Debug); LootLockerConfig.current.adminToken = adminToken; + LootLockerSDKManager.ClearAllPlayerCaches(); return result; } @@ -235,6 +236,14 @@ public void CreateTrigger(string key, string name, int limit, string rewardId, A }); } + public void EnablePresence(bool advancedMode, Action onComplete) + { + LootLockerTestConfigurationTitleConfig.UpdateGameConfig(LootLockerTestConfigurationTitleConfig.TitleConfigKeys.global_player_presence, true, advancedMode, response => + { + onComplete?.Invoke(response.success, response.errorData?.message); + }); + } + } public class CreateGameRequest diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationTitleConfig.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationTitleConfig.cs new file mode 100644 index 000000000..bfb7288fc --- /dev/null +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationTitleConfig.cs @@ -0,0 +1,56 @@ +using LootLocker; +using System; + +namespace LootLockerTestConfigurationUtils +{ + public static class LootLockerTestConfigurationTitleConfig + { + + public enum TitleConfigKeys + { + global_player_presence + } + + public class PresenceTitleConfigRequest + { + public bool enabled { get; set; } + public bool advanced_mode { get; set; } + } + + public static void GetGameConfig(TitleConfigKeys ConfigKey, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + string endpoint = LootLockerTestConfigurationEndpoints.getTitleConfig.WithPathParameter(ConfigKey.ToString()); + LootLockerAdminRequest.Send(endpoint, LootLockerTestConfigurationEndpoints.getTitleConfig.httpMethod, null, onComplete: (serverResponse) => + { + onComplete?.Invoke(serverResponse); + }, true); + } + + public static void UpdateGameConfig(TitleConfigKeys ConfigKey, bool Enabled, bool AdvancedMode, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + string endpoint = LootLockerTestConfigurationEndpoints.updateTitleConfig.WithPathParameter(ConfigKey.ToString()); + LootLockerTestConfigurationTitleConfig.PresenceTitleConfigRequest request = new LootLockerTestConfigurationTitleConfig.PresenceTitleConfigRequest + { + enabled = Enabled, + advanced_mode = AdvancedMode + }; + string json = LootLockerJson.SerializeObject(request); + LootLockerAdminRequest.Send(endpoint, LootLockerTestConfigurationEndpoints.updateTitleConfig.httpMethod, json, onComplete: (serverResponse) => + { + onComplete?.Invoke(serverResponse); + }, true); + } + } +} diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationTitleConfig.cs.meta b/Tests/LootLockerTestUtils/LootLockerTestConfigurationTitleConfig.cs.meta new file mode 100644 index 000000000..e5f52475a --- /dev/null +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationTitleConfig.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 996c23965613e98428e6341202132eec \ No newline at end of file diff --git a/Tests/LootLockerTests/PlayMode/GuestSessionTest.cs b/Tests/LootLockerTests/PlayMode/GuestSessionTest.cs index 4c858b140..fcd947eb5 100644 --- a/Tests/LootLockerTests/PlayMode/GuestSessionTest.cs +++ b/Tests/LootLockerTests/PlayMode/GuestSessionTest.cs @@ -294,7 +294,7 @@ public IEnumerator StartGuestSession_MultipleSessionStartsWithoutIdentifierWithD }); yield return new WaitUntil(() => guestSessionCompleted); guestSessionCompleted = false; - LootLockerStateData.Reset(); + LootLockerStateData.UnloadState(); LootLockerSDKManager.StartGuestSession((response) => { @@ -303,7 +303,7 @@ public IEnumerator StartGuestSession_MultipleSessionStartsWithoutIdentifierWithD }); yield return new WaitUntil(() => guestSessionCompleted); guestSessionCompleted = false; - LootLockerStateData.Reset(); + LootLockerStateData.UnloadState(); LootLockerSDKManager.StartGuestSession((response) => { @@ -312,7 +312,7 @@ public IEnumerator StartGuestSession_MultipleSessionStartsWithoutIdentifierWithD }); yield return new WaitUntil(() => guestSessionCompleted); guestSessionCompleted = false; - LootLockerStateData.Reset(); + LootLockerStateData.UnloadState(); //Then Assert.IsNotNull(player1Ulid); @@ -341,7 +341,7 @@ public IEnumerator StartGuestSession_MultipleSessionStartsWithoutIdentifierWithD var player1Data = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(player1Ulid); player1Data.CurrentPlatform = LootLockerAuthPlatform.GetPlatformRepresentation(LL_AuthPlatforms.WhiteLabel); LootLockerStateData.SetPlayerData(player1Data); - LootLockerStateData.Reset(); + LootLockerStateData.UnloadState(); LootLockerSDKManager.StartGuestSession((response) => { @@ -353,7 +353,7 @@ public IEnumerator StartGuestSession_MultipleSessionStartsWithoutIdentifierWithD player2Data.CurrentPlatform = LootLockerAuthPlatform.GetPlatformRepresentation(LL_AuthPlatforms.WhiteLabel); LootLockerStateData.SetPlayerData(player1Data); guestSessionCompleted = false; - LootLockerStateData.Reset(); + LootLockerStateData.UnloadState(); LootLockerSDKManager.StartGuestSession((response) => { diff --git a/Tests/LootLockerTests/PlayMode/MultiUserTests.cs b/Tests/LootLockerTests/PlayMode/MultiUserTests.cs index fe2517281..3eb30913b 100644 --- a/Tests/LootLockerTests/PlayMode/MultiUserTests.cs +++ b/Tests/LootLockerTests/PlayMode/MultiUserTests.cs @@ -110,6 +110,16 @@ public IEnumerator Setup() gameUnderTest?.InitializeLootLockerSDK(); + float setupTimeout = Time.time + 10f; + + yield return new WaitUntil(() => LootLockerSDKManager.CheckInitialized(true) || setupTimeout < Time.time); + if (!LootLockerSDKManager.CheckInitialized(true)) + { + Debug.LogError("LootLocker SDK failed to initialize in setup"); + SetupFailed = true; + yield break; + } + Debug.Log($"##### Start of {this.GetType().Name} test no.{TestCounter} test case #####"); } @@ -770,7 +780,7 @@ public IEnumerator MultiUser_SetPlayerDataWhenNoPlayerCachesExist_CreatesPlayerC Assert.AreEqual(preSetPlayerDataPlayerCount + 1, postSetPlayerDataPlayerCount); Assert.IsNull(defaultPlayerPlayerData); - Assert.IsNull(defaultPlayerUlid); + Assert.IsTrue(string.IsNullOrEmpty(defaultPlayerUlid), "defaultPlayerUlid was not null or empty"); Assert.IsNotNull(postSetDefaultPlayerUlid); Assert.IsNotNull(postSetDefaultPlayerPlayerData); Assert.AreEqual("HSDHSAJKLDLKASJDLK", postSetDefaultPlayerPlayerData.ULID); @@ -968,7 +978,7 @@ public IEnumerator MultiUser_GetPlayerUlidFromWLEmailWhenPlayerIsNotCached_Retur var playerUlid = LootLockerStateData.GetPlayerUlidFromWLEmail(wlPlayer.WhiteLabelEmail + "-jk"); var notPlayerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(playerUlid); - Assert.IsNull(playerUlid); + Assert.IsTrue(string.IsNullOrEmpty(playerUlid), "playerUlid was not null or empty"); Assert.AreEqual(ulids[0], notPlayerData.ULID); yield return null; diff --git a/Tests/LootLockerTests/PlayMode/PresenceTests.cs b/Tests/LootLockerTests/PlayMode/PresenceTests.cs new file mode 100644 index 000000000..ffe768a1c --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/PresenceTests.cs @@ -0,0 +1,418 @@ +#if LOOTLOCKER_ENABLE_PRESENCE +using System; +using System.Collections; +using System.Linq; +using LootLocker; +using LootLocker.Requests; +using LootLockerTestConfigurationUtils; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace LootLockerTests.PlayMode +{ + public class PresenceTests + { + private LootLockerTestGame gameUnderTest = null; + private LootLockerConfig configCopy = null; + private static int TestCounter = 0; + private bool SetupFailed = false; + + [UnitySetUp] + public IEnumerator Setup() + { + TestCounter++; + configCopy = LootLockerConfig.current; + Debug.Log($"##### Start of {this.GetType().Name} test no.{TestCounter} setup #####"); + + if (!LootLockerConfig.ClearSettings()) + { + Debug.LogError("Could not clear LootLocker config"); + } + + LootLockerConfig.current.logLevel = LootLockerLogger.LogLevel.Debug; + + // Create game + bool gameCreationCallCompleted = false; + LootLockerTestGame.CreateGame(testName: this.GetType().Name + TestCounter + " ", onComplete: (success, errorMessage, game) => + { + if (!success) + { + gameCreationCallCompleted = true; + Debug.LogError(errorMessage); + SetupFailed = true; + } + gameUnderTest = game; + gameCreationCallCompleted = true; + }); + yield return new WaitUntil(() => gameCreationCallCompleted); + if (SetupFailed) + { + yield break; + } + gameUnderTest?.SwitchToStageEnvironment(); + + // Enable guest platform + bool enableGuestLoginCallCompleted = false; + gameUnderTest?.EnableGuestLogin((success, errorMessage) => + { + if (!success) + { + Debug.LogError(errorMessage); + SetupFailed = true; + } + enableGuestLoginCallCompleted = true; + }); + yield return new WaitUntil(() => enableGuestLoginCallCompleted); + if (SetupFailed) + { + yield break; + } + + bool enablePresenceCompleted = false; + gameUnderTest?.EnablePresence(true, (success, errorMessage) => + { + if (!success) + { + Debug.LogError(errorMessage); + SetupFailed = true; + } + enablePresenceCompleted = true; + }); + + yield return new WaitUntil(() => enablePresenceCompleted); + if (SetupFailed) + { + yield break; + } + + Assert.IsTrue(gameUnderTest?.InitializeLootLockerSDK(), "Successfully created test game and initialized LootLocker"); + int i = 0; + yield return new WaitUntil(() => LootLockerSDKManager.CheckInitialized(true) || ++i > 20_000); + + Debug.Log($"##### Start of {this.GetType().Name} test no.{TestCounter} test case #####"); + } + + [UnityTearDown] + public IEnumerator Teardown() + { + Debug.Log($"##### Start of {this.GetType().Name} test no.{TestCounter} teardown #####"); + + // Cleanup presence connections + LootLockerSDKManager.SetPresenceEnabled(false); + + // End session if active + bool sessionEnded = false; + LootLockerSDKManager.EndSession((response) => + { + sessionEnded = true; + }); + + yield return new WaitUntil(() => sessionEnded); + LootLockerSDKManager.ResetSDK(); + yield return LootLockerLifecycleManager.CleanUpOldInstances(); + + LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, + configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + + Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} teardown #####"); + + yield return null; + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PresenceConnection_WithValidSessionAndPresenceEnabled_ConnectsSuccessfully() + { + if (SetupFailed) + { + yield break; + } + + // Ensure presence is enabled + LootLockerSDKManager.SetPresenceEnabled(true); + LootLockerSDKManager.SetPresenceAutoConnectEnabled(false); // Manual control for testing + + // Start session + bool sessionStarted = false; + LootLockerGuestSessionResponse sessionResponse = null; + + LootLockerSDKManager.StartGuestSession((response) => + { + sessionResponse = response; + sessionStarted = true; + }); + + yield return new WaitUntil(() => sessionStarted); + Assert.IsTrue(sessionResponse.success, $"Session should start successfully. Error: {sessionResponse.errorData?.message}"); + + // Test presence connection + bool presenceConnectCallCompleted = false; + bool connectionSuccess = false; + string connectionError = null; + + LootLockerSDKManager.ForceStartPresenceConnection((success, error) => + { + connectionSuccess = success; + connectionError = error; + presenceConnectCallCompleted = true; + }); + + yield return new WaitUntil(() => presenceConnectCallCompleted); + Assert.IsTrue(connectionSuccess, $"Presence connection should succeed. Error: {connectionError}"); + + // Wait a moment for connection to stabilize + yield return new WaitForSeconds(2f); + + // Verify connection state + Assert.IsTrue(LootLockerSDKManager.IsPresenceConnected(), "Presence should be connected"); + + // Verify client is tracked + var activeClients = LootLockerSDKManager.ListPresenceConnections().ToList(); + Assert.IsTrue(activeClients.Count > 0, "Should have at least one active presence client"); + + // Get connection stats + var stats = LootLockerSDKManager.GetPresenceConnectionStats(null); + Assert.IsNotNull(stats, "Connection stats should be available"); + Assert.GreaterOrEqual(stats.currentLatencyMs, 0, "Current latency should be non-negative"); + + yield return null; + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PresenceConnection_UpdateStatus_UpdatesSuccessfully() + { + if (SetupFailed) + { + yield break; + } + + // Setup session and presence connection + LootLockerSDKManager.SetPresenceEnabled(true); + LootLockerSDKManager.SetPresenceAutoConnectEnabled(false); + + bool sessionStarted = false; + LootLockerGuestSessionResponse sessionResponse = null; + + LootLockerSDKManager.StartGuestSession((response) => + { + sessionResponse = response; + sessionStarted = true; + }); + + yield return new WaitUntil(() => sessionStarted); + Assert.IsTrue(sessionResponse.success, "Session should start successfully"); + + // Connect presence + bool presenceConnected = false; + bool connectionSuccess = false; + + LootLockerSDKManager.ForceStartPresenceConnection((success, error) => + { + connectionSuccess = success; + presenceConnected = true; + }); + + yield return new WaitUntil(() => presenceConnected); + Assert.IsTrue(connectionSuccess, "Presence should connect successfully"); + + // Wait for connection to stabilize + yield return new WaitForSeconds(2f); + + // Test status update + bool statusUpdated = false; + bool updateSuccess = false; + const string testStatus = "testing_status"; + + LootLockerSDKManager.UpdatePresenceStatus(testStatus, null, (success) => + { + updateSuccess = success; + statusUpdated = true; + }); + + yield return new WaitUntil(() => statusUpdated); + Assert.IsTrue(updateSuccess, "Status update should succeed"); + + // Verify the status was set via connection stats + var statsAfterUpdate = LootLockerSDKManager.GetPresenceConnectionStats(null); + Assert.IsNotNull(statsAfterUpdate, "Should be able to get stats after status update"); + Assert.AreEqual(testStatus, statsAfterUpdate.lastSentStatus, "Last sent status should match the test status"); + + yield return null; + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PresenceConnection_DisconnectPresence_DisconnectsCleanly() + { + if (SetupFailed) + { + yield break; + } + + // Setup session and presence connection + LootLockerSDKManager.SetPresenceEnabled(true); + LootLockerSDKManager.SetPresenceAutoConnectEnabled(false); + + bool sessionStarted = false; + LootLockerSDKManager.StartGuestSession((response) => + { + sessionStarted = true; + }); + + yield return new WaitUntil(() => sessionStarted); + + // Connect presence + bool presenceConnected = false; + LootLockerSDKManager.ForceStartPresenceConnection((success, error) => + { + presenceConnected = true; + }); + + yield return new WaitUntil(() => presenceConnected); + yield return new WaitForSeconds(1f); + + // Verify connection + Assert.IsTrue(LootLockerSDKManager.IsPresenceConnected(), "Should be connected before disconnect test"); + + // Test disconnection + bool presenceDisconnected = false; + bool disconnectSuccess = false; + string disconnectError = null; + + LootLockerSDKManager.ForceStopPresenceConnection((success, error) => + { + disconnectSuccess = success; + disconnectError = error; + presenceDisconnected = true; + }); + + yield return new WaitUntil(() => presenceDisconnected); + Assert.IsTrue(disconnectSuccess, $"Presence disconnection should succeed. Error: {disconnectError}"); + + // Wait a moment for disconnection to process + yield return new WaitForSeconds(1f); + + // Verify disconnection + Assert.IsFalse(LootLockerSDKManager.IsPresenceConnected(), "Presence should be disconnected"); + + // Verify no active clients + var activeClients = LootLockerSDKManager.ListPresenceConnections().ToList(); + Assert.AreEqual(0, activeClients.Count, "Should have no active presence clients after disconnect"); + + yield return null; + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PresenceConnection_WithAutoConnect_ConnectsOnSessionStart() + { + if (SetupFailed) + { + yield break; + } + + // Enable auto-connect + LootLockerSDKManager.SetPresenceEnabled(true); + LootLockerSDKManager.SetPresenceAutoConnectEnabled(true); + + // Start session (should auto-connect presence) + bool sessionStarted = false; + LootLockerGuestSessionResponse sessionResponse = null; + + LootLockerSDKManager.StartGuestSession((response) => + { + sessionResponse = response; + sessionStarted = true; + }); + + yield return new WaitUntil(() => sessionStarted); + Assert.IsTrue(sessionResponse.success, "Session should start successfully"); + + // Wait for auto-connect to complete + yield return new WaitForSeconds(3f); + + // Verify presence auto-connected + Assert.IsTrue(LootLockerSDKManager.IsPresenceConnected(), "Presence should auto-connect when enabled"); + + var activeClients = LootLockerSDKManager.ListPresenceConnections().ToList(); + Assert.IsTrue(activeClients.Count > 0, "Should have active presence clients after auto-connect"); + + yield return null; + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PresenceConnection_WithoutSession_FailsGracefully() + { + if (SetupFailed) + { + yield break; + } + + // Ensure no active session + bool sessionEnded = false; + LootLockerSDKManager.EndSession((response) => + { + sessionEnded = true; + }); + yield return new WaitUntil(() => sessionEnded); + + // Try to connect presence without session + bool presenceAttempted = false; + bool connectionSuccess = false; + string connectionError = null; + + LootLockerSDKManager.ForceStartPresenceConnection((success, error) => + { + connectionSuccess = success; + connectionError = error; + presenceAttempted = true; + }); + + yield return new WaitUntil(() => presenceAttempted); + Assert.IsFalse(connectionSuccess, "Presence connection should fail without valid session"); + Assert.IsNotNull(connectionError, "Should have error message when connection fails"); + Assert.IsFalse(LootLockerSDKManager.IsPresenceConnected(), "Presence should not be connected"); + + yield return null; + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PresenceConnection_WhenDisabled_DoesNotConnect() + { + if (SetupFailed) + { + yield break; + } + + // Disable presence system + LootLockerSDKManager.SetPresenceEnabled(false); + + // Start session + bool sessionStarted = false; + LootLockerSDKManager.StartGuestSession((response) => + { + sessionStarted = true; + }); + + yield return new WaitUntil(() => sessionStarted); + + // Try to connect presence while disabled + bool presenceAttempted = false; + bool connectionSuccess = false; + string connectionError = null; + + LootLockerSDKManager.ForceStartPresenceConnection((success, error) => + { + connectionSuccess = success; + connectionError = error; + presenceAttempted = true; + }); + + yield return new WaitUntil(() => presenceAttempted); + Assert.IsFalse(connectionSuccess, "Presence connection should fail when system is disabled"); + Assert.IsNotNull(connectionError, "Should have error message explaining system is disabled"); + Assert.IsFalse(LootLockerSDKManager.IsPresenceConnected(), "Presence should not be connected when disabled"); + + yield return null; + } + } +} +#endif diff --git a/Tests/LootLockerTests/PlayMode/PresenceTests.cs.meta b/Tests/LootLockerTests/PlayMode/PresenceTests.cs.meta new file mode 100644 index 000000000..6d81e0ca3 --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/PresenceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a1b2c3d4e5f6789012345678901234af +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Tests/LootLockerTests/PlayMode/RateLimiterTests.cs b/Tests/LootLockerTests/PlayMode/RateLimiterTests.cs index 3acd98940..ed41870d8 100644 --- a/Tests/LootLockerTests/PlayMode/RateLimiterTests.cs +++ b/Tests/LootLockerTests/PlayMode/RateLimiterTests.cs @@ -132,11 +132,13 @@ private char[][] GetBucketsAsCharMatrix() } private TestRateLimiter _rateLimiterUnderTest = null; + private GameObject _rateLimiterGameObject = null; [UnitySetUp] public IEnumerator UnitySetUp() { - _rateLimiterUnderTest = new TestRateLimiter(); + _rateLimiterGameObject = new GameObject("TestRateLimiterGO"); + _rateLimiterUnderTest = _rateLimiterGameObject.AddComponent(); _rateLimiterUnderTest.SetTime(new DateTime(2021, 1, 1, 0, 0, 0)); yield return null; } @@ -145,6 +147,11 @@ public IEnumerator UnitySetUp() public IEnumerator UnityTearDown() { // Cleanup + if (_rateLimiterGameObject != null) + { + GameObject.DestroyImmediate(_rateLimiterGameObject); + _rateLimiterGameObject = null; + } _rateLimiterUnderTest = null; yield return null; } diff --git a/Tests/LootLockerTests/PlayMode/WhiteLabelLoginTest.cs b/Tests/LootLockerTests/PlayMode/WhiteLabelLoginTest.cs index 97a2395e3..dd56a19b0 100644 --- a/Tests/LootLockerTests/PlayMode/WhiteLabelLoginTest.cs +++ b/Tests/LootLockerTests/PlayMode/WhiteLabelLoginTest.cs @@ -285,7 +285,7 @@ public IEnumerator WhiteLabel_RequestsAfterGameResetWhenWLDefaultUser_ReusesSess Assert.IsNotEmpty(loginResponse.LoginResponse.SessionToken, "No session token found from login"); //When - LootLockerStateData.Reset(); + LootLockerStateData.UnloadState(); bool pingRequestCompleted = false; LootLockerPingResponse pingResponse = null; @@ -341,7 +341,7 @@ public IEnumerator WhiteLabel_WLSessionStartByEmailAfterGameReset_ReusesSession( Assert.IsNotEmpty(loginResponse.SessionResponse.session_token, "No session token found from login"); //When - LootLockerStateData.Reset(); + LootLockerStateData.UnloadState(); bool postResetSessionRequestCompleted = false; LootLockerSessionResponse postResetSessionResponse = null;