diff --git a/.github/workflows/run-tests-and-package.yml b/.github/workflows/run-tests-and-package.yml index 9eb0f9b78..ce5a45a23 100644 --- a/.github/workflows/run-tests-and-package.yml +++ b/.github/workflows/run-tests-and-package.yml @@ -227,7 +227,7 @@ jobs: runs-on: [ubuntu-latest] needs: [editor-smoke-test] timeout-minutes: 20 - if: false && (startsWith(github.ref, 'refs/pull') && endsWith(github.base_ref, 'main')) || startsWith(github.ref, 'refs/tags/v') || (startsWith(github.ref, 'refs/heads') && endsWith(github.ref, 'main')) + if: false && ((startsWith(github.ref, 'refs/pull') && endsWith(github.base_ref, 'main')) || startsWith(github.ref, 'refs/tags/v') || (startsWith(github.ref, 'refs/heads') && endsWith(github.ref, 'main'))) env: LL_USE_STAGE: false strategy: @@ -465,15 +465,14 @@ 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: Set presence to disabled + run: | + 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 +519,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..94550e089 --- /dev/null +++ b/Runtime/Client/LootLockerEventSystem.cs @@ -0,0 +1,581 @@ +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() + { + if(eventSubscribers == null || eventSubscribers.Count == 0) return; + 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..22c5da801 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; - _instance = gameObject.AddComponent(); - _instanceId = _instance.GetInstanceID(); - _instance._hostingGameObject = gameObject; - _instance.StartCoroutine(CleanUpOldInstances()); - if (Application.isPlaying) - DontDestroyOnLoad(_instance.gameObject); + IsInitialized = false; + + 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) + { + return _instance; + } + + lock (_instanceLock) { - Instantiate(); + 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; } @@ -457,11 +622,13 @@ private HTTPExecutionQueueProcessingResult ProcessOngoingRequest(LootLockerHTTPE return HTTPExecutionQueueProcessingResult.Completed_Success; } - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(executionItem.RequestData.ForPlayerWithUlid); - if (ShouldRetryRequest(executionItem.WebRequest.responseCode, executionItem.RequestData.TimesRetried) && !(executionItem.WebRequest.responseCode == 401 && !IsAuthorizedRequest(executionItem))) { - if (ShouldRefreshSession(executionItem, playerData == null ? LL_AuthPlatforms.None : playerData.CurrentPlatform.Platform) && (CanRefreshUsingRefreshToken(executionItem.RequestData) || CanStartNewSessionUsingCachedAuthData(executionItem.RequestData.ForPlayerWithUlid))) + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(executionItem.RequestData.ForPlayerWithUlid); + bool shouldRefreshSession = ShouldRefreshSession(executionItem, playerData == null ? LL_AuthPlatforms.None : playerData.CurrentPlatform.Platform); + bool canRefreshWithToken = CanRefreshUsingRefreshToken(executionItem.RequestData); + bool canReAuth = CanStartNewSessionUsingCachedAuthData(executionItem.RequestData.ForPlayerWithUlid); + if (shouldRefreshSession && (canRefreshWithToken || canReAuth)) { return HTTPExecutionQueueProcessingResult.NeedsSessionRefresh; } @@ -575,10 +742,11 @@ private void CallListenersAndMarkDone(LootLockerHTTPExecutionQueueItem execution private IEnumerator RefreshSession(string refreshForPlayerUlid, string forExecutionItemId, Action onSessionRefreshedCallback) { - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(refreshForPlayerUlid); + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(refreshForPlayerUlid); 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 +833,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 +844,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); @@ -696,12 +866,19 @@ private void HandleSessionRefreshResult(LootLockerResponse newSessionResponse, s LootLockerLogger.Log($"Session refresh callback ulid {forPlayerWithUlid} does not match the execution item ulid {executionItem.RequestData.ForPlayerWithUlid}. Ignoring.", LootLockerLogger.LogLevel.Error); return; } + if (newSessionResponse == null || !newSessionResponse.success) + { + LootLockerLogger.Log($"Session refresh failed for player with ulid {forPlayerWithUlid}.", LootLockerLogger.LogLevel.Error); + return; + } + var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(executionItem.RequestData.ForPlayerWithUlid); string tokenBeforeRefresh = executionItem.RequestData.ExtraHeaders.TryGetValue("x-session-token", out var existingToken) ? existingToken : ""; string tokenAfterRefresh = playerData?.SessionToken; 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 +901,8 @@ private void HandleSessionRefreshResult(LootLockerResponse newSessionResponse, s } } + #endregion + #region Session Refresh Helper Methods private static bool ShouldRetryRequest(long statusCode, int timesRetried) @@ -733,7 +912,10 @@ private static bool ShouldRetryRequest(long statusCode, int timesRetried) private static bool ShouldRefreshSession(LootLockerHTTPExecutionQueueItem request, LL_AuthPlatforms platform) { - return IsAuthorizedGameRequest(request) && (request.WebRequest?.responseCode == 401 || request.WebRequest?.responseCode == 403) && LootLockerConfig.current.allowTokenRefresh && !new List{ LL_AuthPlatforms.Steam, LL_AuthPlatforms.NintendoSwitch, LL_AuthPlatforms.None }.Contains(platform); + bool isAuthorized = IsAuthorizedRequest(request); + bool isRefreshableResponseCode = (request.WebRequest?.responseCode == 401 || request.WebRequest?.responseCode == 403); + bool isRefreshablePlatform = LootLockerConfig.current.allowTokenRefresh && !new List{ LL_AuthPlatforms.Steam, LL_AuthPlatforms.NintendoSwitch, LL_AuthPlatforms.None }.Contains(platform); + return isAuthorized && isRefreshableResponseCode && isRefreshablePlatform; } private static bool IsAuthorizedRequest(LootLockerHTTPExecutionQueueItem request) @@ -753,19 +935,19 @@ private static bool IsAuthorizedAdminRequest(LootLockerHTTPExecutionQueueItem re private static bool CanRefreshUsingRefreshToken(LootLockerHTTPRequestData cachedRequest) { - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(cachedRequest.ForPlayerWithUlid); + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(cachedRequest.ForPlayerWithUlid); if (!LootLockerAuthPlatformSettings.PlatformsWithRefreshTokens.Contains(playerData == null ? LL_AuthPlatforms.None : playerData.CurrentPlatform.Platform)) { return false; } // 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 string json = cachedRequest.Content.dataType == LootLockerHTTPRequestDataType.JSON ? ((LootLockerJsonBodyRequestContent)cachedRequest.Content).jsonBody : null; - return (string.IsNullOrEmpty(json) || !json.Contains("refresh_token")) && !string.IsNullOrEmpty(LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(cachedRequest.ForPlayerWithUlid)?.RefreshToken); + return (string.IsNullOrEmpty(json) || !json.Contains("refresh_token")) && !string.IsNullOrEmpty(playerData?.RefreshToken); } private static bool CanStartNewSessionUsingCachedAuthData(string forPlayerWithUlid) { - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(forPlayerWithUlid); if (playerData == null) { return false; @@ -954,7 +1136,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..e7b8c3f66 --- /dev/null +++ b/Runtime/Client/LootLockerLifecycleManager.cs @@ -0,0 +1,676 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; + +namespace LootLocker +{ + /// + /// Lifecycle state of the LifecycleManager + /// + public enum LifecycleManagerState + { + /// + /// Manager is not initialized or has been destroyed - can be recreated + /// + Uninitialized, + + /// + /// 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; + } + + // Set to Uninitialized after teardown to allow recreation + _state = LifecycleManagerState.Uninitialized; + } + } + + 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 && ((LootLockerLifecycleManager)manager)._isInitialized) + { +#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.Uninitialized; + 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); + + // 5. Initialize PresenceManager (no special dependencies) + var presenceManager = _RegisterAndInitializeService(); + if (eventSystem != null) + { + presenceManager.SetEventSystem(eventSystem); + } + + _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 + + 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); + } + } + + TeardownInstance(); + } + + 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); + } + } + else if (serviceType == typeof(LootLockerPresenceManager)) + { + var presenceManager = _RegisterAndInitializeService(); + var eventSystem = _GetService(); + if (eventSystem != null) + { + presenceManager.SetEventSystem(eventSystem); + } + } + + 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..502bb5540 --- /dev/null +++ b/Runtime/Client/LootLockerPresenceClient.cs @@ -0,0 +1,1177 @@ +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, + Destroying, + Destroyed + } + + #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 LootLockerPresencePingRequest() + { + } + } + + /// + /// 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 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 session token this client is using for authentication + /// + public string SessionToken => sessionToken; + + /// + /// 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 { + get { + connectionStats.connectionState = connectionState; + return connectionStats; + } + set { connectionStats = value; } + } + + #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() + { + 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 (connectionState == LootLockerPresenceConnectionState.Destroying || connectionState == LootLockerPresenceConnectionState.Destroyed) return; + + ChangeConnectionState(LootLockerPresenceConnectionState.Destroying); + + 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.Destroyed); + } + + /// + /// 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); + } + + /// + /// Update the session token for this client (used during token refresh) + /// + internal void UpdateSessionToken(string newSessionToken) + { + this.sessionToken = newSessionToken; + } + + /// + /// Connect to the Presence WebSocket + /// + internal void Connect(LootLockerPresenceCallback onComplete = null) + { + if (connectionState == LootLockerPresenceConnectionState.Destroying || + connectionState == LootLockerPresenceConnectionState.Destroyed) + { + onComplete?.Invoke(false, "Client has been destroyed"); + 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 (connectionState == LootLockerPresenceConnectionState.Destroying || + connectionState == LootLockerPresenceConnectionState.Destroyed || + connectionState == LootLockerPresenceConnectionState.Disconnected || + connectionState == LootLockerPresenceConnectionState.Failed) + { + 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(DateTime.UtcNow); + + // 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 (connectionState == LootLockerPresenceConnectionState.Destroying || + connectionState == LootLockerPresenceConnectionState.Destroyed) + { + HandleConnectionError("Presence client is destroyed"); + 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 (connectionState == LootLockerPresenceConnectionState.Destroying || + connectionState == LootLockerPresenceConnectionState.Destroyed) + { + 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 || connectionState == LootLockerPresenceConnectionState.Destroying || connectionState == LootLockerPresenceConnectionState.Destroyed); + + if(connectionState == LootLockerPresenceConnectionState.Destroying || connectionState == LootLockerPresenceConnectionState.Destroyed) + { + 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()); + + // Auto-resend last status if we have one + if (!string.IsNullOrEmpty(connectionStats.lastSentStatus)) + { + LootLockerLogger.Log($"Auto-resending last status '{connectionStats.lastSentStatus}' after reconnection", LootLockerLogger.LogLevel.Debug); + // Use a coroutine to avoid blocking the authentication flow + StartCoroutine(AutoResendLastStatusCoroutine()); + } + + // 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 state changed from {previousState} to {newState} for player {playerUlid}", LootLockerLogger.LogLevel.Debug); + + // Then notify external systems via the unified event system + LootLockerEventSystem.TriggerPresenceConnectionStateChanged(playerUlid, previousState, newState, error); + } + } + + private IEnumerator PingCoroutine() + { + + while (IsConnectedAndAuthenticated) + { + SendPing(); + yield return new WaitForSeconds(PING_INTERVAL); + } + } + + private IEnumerator ScheduleReconnectCoroutine(float customDelay = -1f) + { + if (!shouldReconnect || 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); + ChangeConnectionState(LootLockerPresenceConnectionState.Reconnecting); + + yield return new WaitForSeconds(delayToUse); + + if (shouldReconnect && connectionState == LootLockerPresenceConnectionState.Reconnecting) + { + StartCoroutine(ConnectCoroutine()); + } + } + + /// + /// Coroutine to auto-resend the last status after successful reconnection + /// + private IEnumerator AutoResendLastStatusCoroutine() + { + // Wait a frame to ensure we're fully connected + yield return null; + + // Double-check we're still connected and have a status to send + if (IsConnectedAndAuthenticated && !string.IsNullOrEmpty(connectionStats.lastSentStatus)) + { + // Find the last sent metadata if any + // Note: We don't store metadata currently, so we'll resend with null metadata + // This could be enhanced later if metadata preservation is needed + UpdateStatus(connectionStats.lastSentStatus, null, (success, error) => { + if (success) + { + LootLockerLogger.Log($"Successfully auto-resent status '{connectionStats.lastSentStatus}' after reconnection", LootLockerLogger.LogLevel.Debug); + } + else + { + LootLockerLogger.Log($"Failed to auto-resend status after reconnection: {error}", LootLockerLogger.LogLevel.Warning); + } + }); + } + } + + #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..a741d95a0 --- /dev/null +++ b/Runtime/Client/LootLockerPresenceManager.cs @@ -0,0 +1,1418 @@ +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 Dictionary _disconnectedClients = new Dictionary(); // Track disconnected but not destroyed clients + 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) + { + var result = new List(instance._activeClients.Keys); + result.AddRange(instance._disconnectedClients.Keys); + return result; + } + } + } + + #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() + { + // During Unity shutdown, don't create new instances + if (!Application.isPlaying) + { + return _instance; + } + + 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 UNITY_EDITOR + _isEnabled = LootLockerConfig.current.enablePresence && LootLockerConfig.current.enablePresenceInEditor; + #else + _isEnabled = LootLockerConfig.current.enablePresence; + #endif + _autoConnectEnabled = LootLockerConfig.current.enablePresenceAutoConnect; + _autoDisconnectOnFocusChange = LootLockerConfig.current.enablePresenceAutoDisconnectOnFocusChange; + + 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() + { + if (!_isShuttingDown) + { + _DestroyAllClients(); + } + + _UnsubscribeFromEvents(); + + _connectedSessions?.Clear(); + + IsInitialized = false; + lock(_instanceLock) { + _instance = null; + } + } + + void ILootLockerService.HandleApplicationPause(bool pauseStatus) + { + if(!IsInitialized || !_isEnabled) + { + return; + } + + if (pauseStatus && _autoDisconnectOnFocusChange) + { + DisconnectAll(); + } + else + { + StartCoroutine(_AutoConnectExistingSessions()); + } + } + + void ILootLockerService.HandleApplicationFocus(bool hasFocus) + { + if(!IsInitialized || !_isEnabled) + return; + + if (hasFocus) + { + // App gained focus - ensure presence is reconnected + StartCoroutine(_AutoConnectExistingSessions()); + } + else if (_autoDisconnectOnFocusChange) + { + // App lost focus - disconnect presence to save resources + DisconnectAll(); + } + } + + void ILootLockerService.HandleApplicationQuit() + { + if (!_isShuttingDown) + { + _isShuttingDown = true; + + _UnsubscribeFromEvents(); + _DestroyAllClients(); + _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}, checking for existing clients", LootLockerLogger.LogLevel.Debug); + + // Check if we have existing clients (active or disconnected) + bool hasExistingClient = false; + lock (_activeClientsLock) + { + hasExistingClient = _activeClients.ContainsKey(playerData.ULID) || _disconnectedClients.ContainsKey(playerData.ULID); + } + + if (hasExistingClient) + { + // Update existing client with new session token and reconnect + _UpdateClientSessionTokenAndReconnect(playerData); + } + else + { + // Create and initialize new client, then 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}, checking for existing clients", LootLockerLogger.LogLevel.Debug); + + // Check if we have existing clients (active or disconnected) + bool hasExistingClient = false; + lock (_activeClientsLock) + { + hasExistingClient = _activeClients.ContainsKey(playerData.ULID) || _disconnectedClients.ContainsKey(playerData.ULID); + } + + if (hasExistingClient) + { + // Update existing client with new session token and reconnect + _UpdateClientSessionTokenAndReconnect(playerData); + } + else + { + // Create and initialize new client, then 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 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}, destroying presence client", LootLockerLogger.LogLevel.Debug); + _DestroyPresenceClientForUlid(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}, destroying presence client", LootLockerLogger.LogLevel.Debug); + _DestroyPresenceClientForUlid(eventData.playerUlid); + } + } + + /// + /// Handle local session deactivated events + /// Note: If this is part of a session end flow, presence will already be destroyed by _HandleSessionEndedEvent + /// This handler destroys presence client 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}, destroying presence client", LootLockerLogger.LogLevel.Debug); + _DestroyPresenceClientForUlid(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.Destroyed) + { + 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); + } + else if (_disconnectedClients.TryGetValue(eventData.playerUlid, out clientToCleanup)) + { + _disconnectedClients.Remove(eventData.playerUlid); + } + } + + // Destroy the GameObject to fully clean up resources + if (clientToCleanup != null) + { + UnityEngine.Object.Destroy(clientToCleanup.gameObject); + } + } + else if (eventData.newState == LootLockerPresenceConnectionState.Disconnected) + { + // Move client from active to disconnected state (don't destroy) + LootLockerPresenceClient clientToMove = null; + lock (_activeClientsLock) + { + if (_activeClients.TryGetValue(eventData.playerUlid, out clientToMove)) + { + _activeClients.Remove(eventData.playerUlid); + _disconnectedClients[eventData.playerUlid] = clientToMove; + LootLockerLogger.Log($"Moved presence client for {eventData.playerUlid} to disconnected state", LootLockerLogger.LogLevel.Debug); + } + } + } + else if (eventData.newState == LootLockerPresenceConnectionState.Failed) + { + // For failed states, we need to check if it's an authentication failure or network failure + // Authentication failures should destroy, network failures should move to disconnected + LootLockerPresenceClient clientToHandle = null; + lock (_activeClientsLock) + { + if (_activeClients.TryGetValue(eventData.playerUlid, out clientToHandle)) + { + _activeClients.Remove(eventData.playerUlid); + } + } + + if (clientToHandle != null) + { + // If the error indicates authentication failure, destroy the client + // Otherwise, move to disconnected state for potential reconnection + if (eventData.errorMessage != null && (eventData.errorMessage.Contains("authentication") || eventData.errorMessage.Contains("unauthorized") || eventData.errorMessage.Contains("invalid token"))) + { + LootLockerLogger.Log($"Destroying presence client for {eventData.playerUlid} due to authentication failure: {eventData.errorMessage}", LootLockerLogger.LogLevel.Debug); + UnityEngine.Object.Destroy(clientToHandle.gameObject); + } + else + { + // Network or other failure - move to disconnected for potential reconnection + lock (_activeClientsLock) + { + _disconnectedClients[eventData.playerUlid] = clientToHandle; + } + LootLockerLogger.Log($"Moved presence client for {eventData.playerUlid} to disconnected state due to failure: {eventData.errorMessage}", LootLockerLogger.LogLevel.Debug); + } + } + } + } + + #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) + { + string errorMessage = "Presence is disabled. Enable it in Project Settings > LootLocker SDK > Presence Settings or use _SetPresenceEnabled(true)."; + LootLockerLogger.Log(errorMessage, LootLockerLogger.LogLevel.Debug); + onComplete?.Invoke(false, errorMessage); + return; + } + + if (string.IsNullOrEmpty(playerUlid)) + { + playerUlid = LootLockerStateData.GetDefaultPlayerULID(); + } + + // Get player data + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(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; + } + + // Check for active client first + 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; + } + + // Check for disconnected client that can be reused + if (instance._disconnectedClients.ContainsKey(ulid)) + { + var disconnectedClient = instance._disconnectedClients[ulid]; + + // Check if the session token needs to be updated + if (disconnectedClient.SessionToken != playerData.SessionToken) + { + LootLockerLogger.Log($"Session token changed for {ulid}, updating token on existing client", LootLockerLogger.LogLevel.Debug); + // Update the session token on the existing client + disconnectedClient.UpdateSessionToken(playerData.SessionToken); + } + + // Reuse the disconnected client (with updated token if needed) + LootLockerLogger.Log($"Reusing disconnected presence client for {ulid}", LootLockerLogger.LogLevel.Debug); + instance._disconnectedClients.Remove(ulid); + instance._activeClients[ulid] = disconnectedClient; + instance._connectingClients.Add(ulid); + + // Reconnect the existing client outside the lock + instance._ConnectPresenceClient(ulid, disconnectedClient, onComplete); + 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)) + { + ulid = LootLockerStateData.GetDefaultPlayerULID(); + } + + // 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) + { + // Return empty stats during shutdown to prevent service access + if (!Application.isPlaying) + { + return new LootLockerPresenceConnectionStats(); + } + + 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) + { + bool changingState = _isEnabled != enabled; + _isEnabled = enabled; + if(changingState && enabled && _autoConnectEnabled) + { + _SubscribeToEvents(null); + StartCoroutine(_AutoConnectExistingSessions()); + } + else if (changingState && !enabled) + { + _UnsubscribeFromEvents(); + _DisconnectAll(); + } + } + + private void _SetAutoConnectEnabled(bool enabled) + { + bool changingState = _autoConnectEnabled != enabled; + _autoConnectEnabled = enabled; + if(changingState && _isEnabled && enabled) + { + _SubscribeToEvents(null); + StartCoroutine(_AutoConnectExistingSessions()); + } + else if (changingState && !enabled) + { + _UnsubscribeFromEvents(); + _DisconnectAll(); + } + } + + /// + /// Destroy a presence client immediately (for session ending scenarios) + /// + private void _DestroyPresenceClientForUlid(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 clientToDestroy = null; + + lock (_activeClientsLock) + { + // Remove from both active and disconnected clients + if (_activeClients.TryGetValue(playerUlid, out clientToDestroy)) + { + _activeClients.Remove(playerUlid); + } + else if (_disconnectedClients.TryGetValue(playerUlid, out clientToDestroy)) + { + _disconnectedClients.Remove(playerUlid); + } + + // Also remove from connecting clients if it's there + _connectingClients.Remove(playerUlid); + } + + // Destroy the client + if (clientToDestroy != null) + { + UnityEngine.Object.Destroy(clientToDestroy.gameObject); + onComplete?.Invoke(true); + } + else + { + onComplete?.Invoke(true); + } + } + + /// + /// Destroy all presence clients (for shutdown scenarios) + /// + private void _DestroyAllClients() + { + List clientsToDestroy = new List(); + + lock (_activeClientsLock) + { + // Collect all clients from both active and disconnected collections + clientsToDestroy.AddRange(_activeClients.Values); + clientsToDestroy.AddRange(_disconnectedClients.Values); + + // Clear all collections + _activeClients.Clear(); + _disconnectedClients.Clear(); + _connectingClients.Clear(); + } + + // During Unity shutdown, don't destroy objects manually to avoid conflicts with LifecycleManager + if (!Application.isPlaying || _isShuttingDown) + { + // Just clear the collections, let Unity handle object destruction during shutdown + return; + } + + // Destroy all clients outside the lock (only during normal operation) + foreach (var client in clientsToDestroy) + { + if (client != null) + { + UnityEngine.Object.Destroy(client.gameObject); + } + } + } + + /// + /// Update session token on existing client and reconnect + /// + private void _UpdateClientSessionTokenAndReconnect(LootLockerPlayerData playerData) + { + if (playerData == null || string.IsNullOrEmpty(playerData.ULID) || string.IsNullOrEmpty(playerData.SessionToken)) + { + LootLockerLogger.Log("Cannot update client session token: Invalid player data", LootLockerLogger.LogLevel.Warning); + return; + } + + LootLockerPresenceClient clientToUpdate = null; + bool wasActiveClient = false; + bool wasDisconnectedClient = false; + + lock (_activeClientsLock) + { + // Find client in active clients + if (_activeClients.TryGetValue(playerData.ULID, out clientToUpdate)) + { + wasActiveClient = true; + } + // Or in disconnected clients + else if (_disconnectedClients.TryGetValue(playerData.ULID, out clientToUpdate)) + { + wasDisconnectedClient = true; + } + } + + if (clientToUpdate != null) + { + // Capture current status before any operations + string lastStatus = clientToUpdate.LastSentStatus; + + // Update the session token + clientToUpdate.UpdateSessionToken(playerData.SessionToken); + + if (wasActiveClient) + { + // For active clients: disconnect first, then reconnect + LootLockerLogger.Log($"Disconnecting active client for {playerData.ULID} to update session token", LootLockerLogger.LogLevel.Debug); + + DisconnectPresence(playerData.ULID, (disconnectSuccess, disconnectError) => { + if (disconnectSuccess) + { + // After disconnect, the client should be in disconnected state + // Now reconnect with the updated token + LootLockerLogger.Log($"Reconnecting presence for {playerData.ULID} with updated session token", LootLockerLogger.LogLevel.Debug); + ConnectPresence(playerData.ULID); + } + else + { + LootLockerLogger.Log($"Failed to disconnect presence for session token update: {disconnectError}", LootLockerLogger.LogLevel.Warning); + } + }); + } + else if (wasDisconnectedClient && _autoConnectEnabled) + { + // For disconnected clients: just reconnect with new token + LootLockerLogger.Log($"Reconnecting disconnected client for {playerData.ULID} with updated session token", LootLockerLogger.LogLevel.Debug); + ConnectPresence(playerData.ULID); + } + + LootLockerLogger.Log($"Updated session token for presence client {playerData.ULID}, last status was: {lastStatus}", LootLockerLogger.LogLevel.Debug); + } + else + { + // No existing client, create new one if auto-connect is enabled + if (_autoConnectEnabled) + { + LootLockerLogger.Log($"No existing client found for {playerData.ULID}, creating new one", LootLockerLogger.LogLevel.Debug); + ConnectPresence(playerData.ULID); + } + } + } + + 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)) + { + continue; + } + + var state = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(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) && !_disconnectedClients.ContainsKey(state.ULID)) + { + shouldConnect = true; + } + else if (_activeClients.ContainsKey(state.ULID)) + { + // Check if existing active 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; + } + } + else if (_disconnectedClients.ContainsKey(state.ULID)) + { + // Have disconnected client - should reconnect + 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)) + { + // Check if already in disconnected state + if (_disconnectedClients.ContainsKey(playerUlid)) + { + onComplete?.Invoke(true); + return; + } + onComplete?.Invoke(true); + return; + } + + // Check connection state to prevent multiple disconnect attempts + var connectionState = client.ConnectionState; + if (connectionState == LootLockerPresenceConnectionState.Disconnected || + connectionState == LootLockerPresenceConnectionState.Failed || + connectionState == LootLockerPresenceConnectionState.Destroying || + connectionState == LootLockerPresenceConnectionState.Destroyed) + { + 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) + { + // Move to disconnected clients instead of destroying + lock (_activeClientsLock) + { + if (!_disconnectedClients.ContainsKey(playerUlid)) + { + _disconnectedClients[playerUlid] = client; + } + } + onComplete?.Invoke(true); + } + else + { + client.Disconnect((success, error) => { + if (!success) + { + LootLockerLogger.Log($"Error disconnecting presence for {playerUlid}: {error}", LootLockerLogger.LogLevel.Debug); + } + // Move to disconnected clients instead of destroying + lock (_activeClientsLock) + { + if (!_disconnectedClients.ContainsKey(playerUlid)) + { + _disconnectedClients[playerUlid] = 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) => + { + lock (_activeClientsLock) + { + // Remove from connecting clients + _connectingClients.Remove(ulid); + } + 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 = string.IsNullOrEmpty(playerUlid) ? LootLockerStateData.GetDefaultPlayerULID() : playerUlid; + if (string.IsNullOrEmpty(ulid)) + { + return null; + } + + lock (_activeClientsLock) + { + // Check active clients first + if (_activeClients.TryGetValue(ulid, out LootLockerPresenceClient activeClient)) + { + return activeClient; + } + + // Then check disconnected clients + if (_disconnectedClients.TryGetValue(ulid, out LootLockerPresenceClient disconnectedClient)) + { + return disconnectedClient; + } + + return null; + } + } + + #endregion + + #region Unity Lifecycle Events + + private void OnDestroy() + { + // During Unity shutdown, avoid any complex operations + if (!Application.isPlaying) + { + return; + } + + if (!_isShuttingDown) + { + _isShuttingDown = true; + _UnsubscribeFromEvents(); + + // Only destroy clients if we're not in Unity shutdown + _DestroyAllClients(); + } + + // Skip lifecycle manager operations during shutdown + if(!LootLockerLifecycleManager.IsReady) return; + + // 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..d01216f22 100644 --- a/Runtime/Client/LootLockerRateLimiter.cs +++ b/Runtime/Client/LootLockerRateLimiter.cs @@ -1,18 +1,82 @@  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() + { + // 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 +92,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 +124,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 +188,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 +199,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..ead9bc72e 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) + { + // 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() { - LoadMetaDataFromPlayerPrefsIfNeeded(); + 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,35 +293,45 @@ 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(); + return null; } - LoadMetaDataFromPlayerPrefsIfNeeded(); + _LoadMetaDataFromPlayerPrefsIfNeeded(); if (ActiveMetaData == null) { - return new LootLockerPlayerData(); + return null; } - if (!SaveStateExistsForPlayer(playerULID)) + if (!_SaveStateExistsForPlayer(playerULID)) { - return new LootLockerPlayerData(); + return null; } if (ActivePlayerData.TryGetValue(playerULID, out var data)) @@ -175,15 +342,15 @@ public static LootLockerPlayerData GetPlayerDataForPlayerWithUlidWithoutChanging string playerDataJson = _stateWriter.GetString($"{PlayerDataSaveSlot}_{playerULID}"); if (!LootLockerJson.TryDeserializeObject(playerDataJson, out LootLockerPlayerData parsedPlayerData)) { - return new LootLockerPlayerData(); + return null; } return parsedPlayerData; } [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); + } + + [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..e5ed63193 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,62 @@ 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() + { + EditorGUILayout.LabelField("Presence Settings", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + if(gameSettings.enablePresence) + { + EditorGUILayout.HelpBox("This may incur additional costs and needs to be enabled for your game. \nContact us to enable presence features.", MessageType.Info); + 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 Pause Presence")); + if (EditorGUI.EndChangeCheck()) + { + gameSettings.enablePresenceAutoDisconnectOnFocusChange = m_CustomSettings.FindProperty("enablePresenceAutoDisconnectOnFocusChange").boolValue; + } + + EditorGUILayout.Space(); + + // Enable presence in editor toggle + EditorGUI.BeginChangeCheck(); + EditorGUILayout.PropertyField(m_CustomSettings.FindProperty("enablePresenceInEditor"), new GUIContent("Enable Presence in Editor")); + if (EditorGUI.EndChangeCheck()) + { + gameSettings.enablePresenceInEditor = m_CustomSettings.FindProperty("enablePresenceInEditor").boolValue; + } + + EditorGUILayout.Space(); + } + + EditorGUILayout.Space(); + } + [SettingsProvider] public static SettingsProvider CreateProvider() { diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index c937a108c..da59067fa 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(); } /// @@ -54,29 +47,38 @@ static bool Init() /// The current version of the game in the format 1.2.3.4 (the 3 and 4 being optional but recommended) /// Extra key needed for some endpoints, can be found by going to https://console.lootlocker.com/settings/api-keys and click on the API-tab /// What log level to use for the SDKs internal logging + /// If true, logs will also be printed in builds. If false, logs will only be printed in the editor. + /// If true, errors will be logged as warnings instead of errors. + /// If true, the SDK will attempt to refresh tokens automatically. + /// If true, JSON logs will be prettified. + /// If true, sensitive information in logs will be obfuscated. + /// If true, presence features will be enabled. + /// If true, presence will auto-connect. + /// If true, presence will auto-disconnect on focus change. + /// If true, presence will be enabled in the editor. + /// /// 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() + public static bool Init(string apiKey, string gameVersion, string domainKey, LootLockerLogger.LogLevel logLevel = LootLockerLogger.LogLevel.Info, + bool logInBuilds = false, bool errorsAsWarnings = false, bool allowTokenRefresh = true, bool prettifyJson = false, bool obfuscateLogs = true, + bool enablePresence = false, bool enablePresenceAutoConnect = true, bool enablePresenceAutoDisconnectOnFocusChange = false, bool enablePresenceInEditor = true) { - initialized = false; - if (LootLockerConfig.current == null) + // Create new settings first + bool configResult = LootLockerConfig.CreateNewSettings(apiKey, gameVersion, domainKey, + logLevel, logInBuilds, errorsAsWarnings, allowTokenRefresh, prettifyJson, obfuscateLogs, + enablePresence, enablePresenceAutoConnect, enablePresenceAutoDisconnectOnFocusChange, enablePresenceInEditor); + if (!configResult) { - 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)) - { - 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(); } /// @@ -86,7 +88,11 @@ static bool LoadConfig() /// True if a token is found, false otherwise. private static bool CheckActiveSession(string forPlayerWithUlid = null) { - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); + if(string.IsNullOrEmpty(forPlayerWithUlid)) + { + forPlayerWithUlid = GetDefaultPlayerUlid(); + } + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(forPlayerWithUlid); return !string.IsNullOrEmpty(playerData?.SessionToken); } @@ -97,13 +103,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 +127,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 +149,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 @@ -143,7 +171,7 @@ public static void SetStateWriter(ILootLockerStateWriter stateWriter) /// /// Get the information from the stored state for the player with the specified ULID. /// - /// The data stored for the specified player. Will be empty if no data is found. + /// The data stored for the specified player. Will be null if no data is found. public static LootLockerPlayerData GetPlayerDataForPlayerWithUlid(string playerUlid) { return LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(playerUlid); @@ -158,6 +186,15 @@ public static List GetActivePlayerUlids() return LootLockerStateData.GetActivePlayerULIDs(); } + /// + /// Make the state for the player with the specified ULID to be "active". + /// + /// Whether the player was successfully activated or not + public static bool MakePlayerActive(string playerUlid) + { + return !string.IsNullOrEmpty(LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(playerUlid)?.ULID); + } + /// /// Make the state for the player with the specified ULID to be "inactive". /// @@ -226,7 +263,7 @@ public static bool SetDefaultPlayerUlid(string playerUlid) /// The player state for the specified player, or the default player state if the supplied ULID is empty or could not be found, or an empty state if none of the previous are valid. public static LootLockerPlayerData GetSavedStateOrDefaultOrEmptyForPlayer(string playerUlid) { - return LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(playerUlid); + return LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(playerUlid) ?? new LootLockerPlayerData(); } /// @@ -281,7 +318,12 @@ public static void VerifyID(string deviceId, Action on return; } - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); + if (string.IsNullOrEmpty(forPlayerWithUlid)) + { + forPlayerWithUlid = GetDefaultPlayerUlid(); + } + + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(forPlayerWithUlid); if (playerData == null || !playerData.Identifier.Equals(deviceId)) { onComplete?.Invoke(LootLockerResponseFactory.ClientError($"The provided deviceId did not match the identifier on player with ulid {forPlayerWithUlid}", forPlayerWithUlid)); @@ -314,7 +356,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 +371,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 +437,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 +476,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 +492,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 +523,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 +539,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 +588,8 @@ public static void StartAmazonLunaSession(string amazonLunaGuid, Action onCo // Start a new guest session with a new identifier if there is no default player to use or if that player is already playing StartGuestSession(null, onComplete); return; - } - else if (LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(defaultPlayerUlid)?.CurrentPlatform.Platform != LL_AuthPlatforms.Guest) + } + + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(defaultPlayerUlid); + if (playerData?.CurrentPlatform.Platform != LL_AuthPlatforms.Guest) { // Also start a new guest session with a new identifier if the default player is not playing but isn't a guest user - LootLockerStateData.SetPlayerULIDToInactive(defaultPlayerUlid); StartGuestSession(null, onComplete); return; } - StartGuestSession(LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(defaultPlayerUlid)?.Identifier, onComplete, Optionals); + StartGuestSession(playerData?.Identifier, onComplete, Optionals ?? playerData?.SessionOptionals); } /// @@ -601,7 +649,9 @@ public static void StartGuestSessionForPlayer(string forPlayerWithUlid, Action @@ -626,7 +676,7 @@ public static void StartGuestSession(string identifier, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + var playerData = new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = "", @@ -642,7 +692,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 +739,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 +769,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 +785,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 +834,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 +850,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 +900,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 +950,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 +1002,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); @@ -982,7 +1046,12 @@ public static void RefreshGoogleSession(string refresh_token, Action(playerData?.ULID)); @@ -994,7 +1063,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 +1090,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 +1142,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 +1195,9 @@ public static void RefreshGooglePlayGamesSession(string refreshToken, Action(null)); return; } - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); + + if (string.IsNullOrEmpty(forPlayerWithUlid)) + { + forPlayerWithUlid = GetDefaultPlayerUlid(); + } + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(forPlayerWithUlid); if (string.IsNullOrEmpty(playerData?.RefreshToken)) { onComplete?.Invoke(LootLockerResponseFactory.TokenExpiredError(playerData?.ULID)); @@ -1179,7 +1259,7 @@ 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, @@ -1195,7 +1275,9 @@ public static void StartAppleSession(string authorization_code, Action(playerData?.ULID)); @@ -1249,7 +1336,7 @@ 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, @@ -1276,7 +1363,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 +1418,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); @@ -1354,7 +1445,12 @@ public static void RefreshAppleGameCenterSession(Action(playerData?.ULID)); @@ -1363,7 +1459,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 +1518,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 +1534,9 @@ public static void StartEpicSession(string id_token, Action(playerData?.ULID)); @@ -1492,7 +1595,7 @@ public static void RefreshEpicSession(string refresh_token, Action(serverResponse); if (response.success) { - LootLockerStateData.SetPlayerData(new LootLockerPlayerData + LootLockerEventSystem.TriggerSessionRefreshed(new LootLockerPlayerData { SessionToken = response.session_token, RefreshToken = response.refresh_token, @@ -1557,7 +1660,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, @@ -1613,7 +1716,11 @@ public static void RefreshMetaSession(string refresh_token, Action(playerData?.ULID)); @@ -1625,7 +1732,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 +1793,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, @@ -1720,7 +1827,12 @@ public static void StartDiscordSession(string accessToken, ActionOptional : Execute the request for the specified player. If not supplied, the default player will be used. public static void RefreshDiscordSession(Action onComplete, string forPlayerWithUlid = null, LootLockerSessionOptionals Optionals = null) { - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); + + if (string.IsNullOrEmpty(forPlayerWithUlid)) + { + forPlayerWithUlid = GetDefaultPlayerUlid(); + } + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(forPlayerWithUlid); if (string.IsNullOrEmpty(playerData?.RefreshToken)) { onComplete?.Invoke(LootLockerResponseFactory.TokenExpiredError(playerData?.ULID)); @@ -1729,7 +1841,7 @@ public static void RefreshDiscordSession(Action(playerData?.ULID)); @@ -1767,7 +1884,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 +1946,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 +1960,227 @@ 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 + /// + /// 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 rich 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 rich 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; + } + #endregion #region Connected Accounts @@ -2106,20 +2435,26 @@ public static void TransferIdentityProvidersBetweenAccounts(string FromPlayerWit return; } - var fromPlayer = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(FromPlayerWithUlid); + var fromPlayer = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(FromPlayerWithUlid); if (string.IsNullOrEmpty(fromPlayer?.SessionToken)) { onComplete?.Invoke(LootLockerResponseFactory.ClientError("No valid session token found for source player", FromPlayerWithUlid)); return; } - var toPlayer = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(ToPlayerWithUlid); + var toPlayer = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(ToPlayerWithUlid); if (string.IsNullOrEmpty(toPlayer?.SessionToken)) { onComplete?.Invoke(LootLockerResponseFactory.ClientError("No valid session token found for target player", ToPlayerWithUlid)); return; } + if(fromPlayer.Equals(toPlayer)) + { + onComplete?.Invoke(LootLockerResponseFactory.ClientError("Source player and target player can not be the same", FromPlayerWithUlid)); + return; + } + if (ProvidersToTransfer.Count == 0) { onComplete?.Invoke(LootLockerResponseFactory.ClientError("No providers submitted", FromPlayerWithUlid)); @@ -2244,7 +2579,12 @@ public static void RefreshRemoteSession(string refreshToken, Action(playerData?.ULID)); @@ -2262,7 +2602,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, @@ -2442,7 +2782,12 @@ public static void CheckWhiteLabelSession(Action onComplete, string forPla return; } - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); + if (string.IsNullOrEmpty(forPlayerWithUlid)) + { + forPlayerWithUlid = GetDefaultPlayerUlid(); + } + + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(forPlayerWithUlid); string existingSessionEmail = playerData?.WhiteLabelEmail; string existingSessionToken = playerData?.WhiteLabelToken; if (string.IsNullOrEmpty(existingSessionToken) || string.IsNullOrEmpty(existingSessionEmail)) @@ -2479,7 +2824,7 @@ public static void CheckWhiteLabelSession(string email, Action onComplete) string token = null; if (!string.IsNullOrEmpty(playerUlid)) { - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(playerUlid); + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(playerUlid); token = playerData?.WhiteLabelToken; } else @@ -2554,9 +2899,14 @@ public static void StartWhiteLabelSession(Action onCo return; } + if (string.IsNullOrEmpty(forPlayerWithUlid)) + { + forPlayerWithUlid = GetDefaultPlayerUlid(); + } + string email = null; string token = null; - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(forPlayerWithUlid); if (playerData == null || string.IsNullOrEmpty(playerData.WhiteLabelEmail)) { if (_wllProcessesDictionary.Count == 0) @@ -2618,7 +2968,7 @@ public static void StartWhiteLabelSession(string email, Action($"No White Label data stored for {email}", null)); return; } - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(playerUlidInStateData); + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(playerUlidInStateData); token = playerData.WhiteLabelToken; if(Optionals == null) @@ -2664,7 +3014,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 = "", @@ -3049,17 +3399,23 @@ public static void SetPlayerName(string name, Action onCompl return; } - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); + if (name.ToLower().Contains("player")) + { + onComplete?.Invoke(LootLockerResponseFactory.ClientError("Setting the Player name to 'Player' is not allowed", forPlayerWithUlid)); + return; + + } - if (playerData != null && playerData.CurrentPlatform.Platform == LL_AuthPlatforms.Guest) + if (string.IsNullOrEmpty(forPlayerWithUlid)) { - if (name.ToLower().Contains("player")) - { - onComplete?.Invoke(LootLockerResponseFactory.ClientError("Setting the Player name to 'Player' is not allowed", forPlayerWithUlid)); - return; + forPlayerWithUlid = GetDefaultPlayerUlid(); + } - } - else if (name.ToLower().Contains(playerData.Identifier.ToLower())) + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(forPlayerWithUlid); + + if (playerData != null && playerData.CurrentPlatform.Platform == LL_AuthPlatforms.Guest) + { + if (name.ToLower().Contains(playerData.Identifier.ToLower())) { onComplete?.Invoke(LootLockerResponseFactory.ClientError("Setting the Player name to the Identifier is not allowed", forPlayerWithUlid)); return; @@ -4326,7 +4682,12 @@ public static void GetClassLoadout(Action onComp /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. public static void GetOtherPlayersClassLoadout(string playerID, Action onComplete, string forPlayerWithUlid = null) { - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); + + if (string.IsNullOrEmpty(forPlayerWithUlid)) + { + forPlayerWithUlid = GetDefaultPlayerUlid(); + } + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(forPlayerWithUlid); GetOtherPlayersClassLoadout(playerID, playerData == null ? LL_AuthPlatforms.None : playerData.CurrentPlatform.Platform, onComplete, forPlayerWithUlid); } @@ -4560,7 +4921,12 @@ public static void GetCurrentLoadoutToDefaultClass(ActionOptional : Execute the request for the specified player. If not supplied, the default player will be used. public static void GetCurrentLoadoutToOtherClass(string playerID, Action onComplete, string forPlayerWithUlid = null) { - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); + + if (string.IsNullOrEmpty(forPlayerWithUlid)) + { + forPlayerWithUlid = GetDefaultPlayerUlid(); + } + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(forPlayerWithUlid); GetCurrentLoadoutToOtherClass(playerID, playerData == null ? LL_AuthPlatforms.None : playerData.CurrentPlatform.Platform, onComplete, forPlayerWithUlid); } @@ -7642,7 +8008,7 @@ public static void ListBlockedPlayersPaginated(int PerPage, int Page, Action 0) queryParams.Add("per_page", PerPage.ToString()); - string endpointWithParams = LootLockerEndPoints.listOutgoingFriendRequests.endPoint + queryParams.ToString(); + string endpointWithParams = LootLockerEndPoints.listBlockedPlayers.endPoint + queryParams.ToString(); LootLockerServerRequest.CallAPI(forPlayerWithUlid, endpointWithParams, LootLockerEndPoints.listBlockedPlayers.httpMethod, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } @@ -7728,7 +8094,12 @@ public static void DeleteFriend(string playerID, ActionOptional : Execute the request for the specified player. If not supplied, the default player will be used. public static void ListFollowers(Action onComplete, string forPlayerWithUlid = null) { - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); + + if (string.IsNullOrEmpty(forPlayerWithUlid)) + { + forPlayerWithUlid = GetDefaultPlayerUlid(); + } + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(forPlayerWithUlid); ListFollowers(playerData.PublicUID, onComplete, forPlayerWithUlid); } /// @@ -7740,7 +8111,12 @@ public static void ListFollowers(Action onCompl /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. public static void ListFollowersPaginated(string Cursor, int Count, Action onComplete, string forPlayerWithUlid = null) { - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); + + if (string.IsNullOrEmpty(forPlayerWithUlid)) + { + forPlayerWithUlid = GetDefaultPlayerUlid(); + } + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(forPlayerWithUlid); ListFollowersPaginated(playerData.PublicUID, Cursor, Count, onComplete, forPlayerWithUlid); } @@ -7790,7 +8166,12 @@ public static void ListFollowersPaginated(string playerPublicUID, string Cursor, /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. public static void ListFollowing(Action onComplete, string forPlayerWithUlid = null) { - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); + + if (string.IsNullOrEmpty(forPlayerWithUlid)) + { + forPlayerWithUlid = GetDefaultPlayerUlid(); + } + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(forPlayerWithUlid); ListFollowing(playerData.PublicUID, onComplete, forPlayerWithUlid); } @@ -7803,7 +8184,11 @@ public static void ListFollowing(Action onCompl /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. public static void ListFollowingPaginated(string Cursor, int Count, Action onComplete, string forPlayerWithUlid = null) { - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); + if (string.IsNullOrEmpty(forPlayerWithUlid)) + { + forPlayerWithUlid = GetDefaultPlayerUlid(); + } + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(forPlayerWithUlid); ListFollowingPaginated(playerData.PublicUID, Cursor, Count, onComplete, forPlayerWithUlid); } @@ -8629,7 +9014,11 @@ public static void GetGameInfo(Action onComplete) /// The platform that was last used by the user public static LL_AuthPlatforms GetLastActivePlatform(string forPlayerWithUlid = null) { - var playerData = LootLockerStateData.GetStateForPlayerOrDefaultStateOrEmpty(forPlayerWithUlid); + if (string.IsNullOrEmpty(forPlayerWithUlid)) + { + forPlayerWithUlid = GetDefaultPlayerUlid(); + } + var playerData = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(forPlayerWithUlid); if (playerData == null) { return LL_AuthPlatforms.None; 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; @@ -279,6 +361,10 @@ public static bool ClearSettings() _current.obfuscateLogs = true; _current.allowTokenRefresh = true; _current.domainKey = null; + _current.enablePresence = false; + _current.enablePresenceAutoConnect = true; + _current.enablePresenceAutoDisconnectOnFocusChange = false; + _current.enablePresenceInEditor = true; #if UNITY_EDITOR _current.adminToken = null; #endif //UNITY_EDITOR @@ -289,13 +375,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 +412,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 +439,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; @@ -358,6 +449,17 @@ public static bool IsTargetingProductionEnvironment() public bool logErrorsAsWarnings = false; public bool logInBuilds = false; public bool allowTokenRefresh = true; + [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; + + [Tooltip("Enable presence functionality while in the Unity Editor. Disable this if you don't want development to affect presence data.")] + public bool enablePresenceInEditor = true; #if UNITY_EDITOR [InitializeOnEnterPlayMode] 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..de9aa7a77 100644 --- a/Tests/LootLockerTestUtils/LootLockerTestConfigurationGame.cs +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationGame.cs @@ -134,8 +134,9 @@ public void DeleteGame(Action onCompl public bool InitializeLootLockerSDK() { string adminToken = LootLockerConfig.current.adminToken; - bool result = LootLockerSDKManager.Init(GetApiKeyForActiveEnvironment(), GameVersion, GameDomainKey, LootLockerLogger.LogLevel.Debug); + bool result = LootLockerSDKManager.Init(GetApiKeyForActiveEnvironment(), GameVersion, GameDomainKey, LootLockerLogger.LogLevel.Debug, false, false, true, true, false, false, true, false, true); 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 enableRichPresence, Action onComplete) + { + LootLockerTestConfigurationTitleConfig.UpdateGameConfig(LootLockerTestConfigurationTitleConfig.TitleConfigKeys.global_player_presence, true, enableRichPresence, 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..6d5dc6d66 --- /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 EnableRichPresence, 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 = EnableRichPresence + }; + 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/AssetTests.cs b/Tests/LootLockerTests/PlayMode/AssetTests.cs index fc1dc6c97..51b737b9c 100644 --- a/Tests/LootLockerTests/PlayMode/AssetTests.cs +++ b/Tests/LootLockerTests/PlayMode/AssetTests.cs @@ -229,8 +229,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } diff --git a/Tests/LootLockerTests/PlayMode/FollowersTests.cs b/Tests/LootLockerTests/PlayMode/FollowersTests.cs index 7db0e819e..267121a38 100644 --- a/Tests/LootLockerTests/PlayMode/FollowersTests.cs +++ b/Tests/LootLockerTests/PlayMode/FollowersTests.cs @@ -91,8 +91,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } diff --git a/Tests/LootLockerTests/PlayMode/FriendsTests.cs b/Tests/LootLockerTests/PlayMode/FriendsTests.cs index c3af4145d..07f13c472 100644 --- a/Tests/LootLockerTests/PlayMode/FriendsTests.cs +++ b/Tests/LootLockerTests/PlayMode/FriendsTests.cs @@ -92,8 +92,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } diff --git a/Tests/LootLockerTests/PlayMode/GuestSessionTest.cs b/Tests/LootLockerTests/PlayMode/GuestSessionTest.cs index 4c858b140..e7026db76 100644 --- a/Tests/LootLockerTests/PlayMode/GuestSessionTest.cs +++ b/Tests/LootLockerTests/PlayMode/GuestSessionTest.cs @@ -95,8 +95,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } @@ -294,7 +293,7 @@ public IEnumerator StartGuestSession_MultipleSessionStartsWithoutIdentifierWithD }); yield return new WaitUntil(() => guestSessionCompleted); guestSessionCompleted = false; - LootLockerStateData.Reset(); + LootLockerStateData.UnloadState(); LootLockerSDKManager.StartGuestSession((response) => { @@ -303,7 +302,7 @@ public IEnumerator StartGuestSession_MultipleSessionStartsWithoutIdentifierWithD }); yield return new WaitUntil(() => guestSessionCompleted); guestSessionCompleted = false; - LootLockerStateData.Reset(); + LootLockerStateData.UnloadState(); LootLockerSDKManager.StartGuestSession((response) => { @@ -312,7 +311,7 @@ public IEnumerator StartGuestSession_MultipleSessionStartsWithoutIdentifierWithD }); yield return new WaitUntil(() => guestSessionCompleted); guestSessionCompleted = false; - LootLockerStateData.Reset(); + LootLockerStateData.UnloadState(); //Then Assert.IsNotNull(player1Ulid); @@ -341,7 +340,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 +352,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/LeaderboardTest.cs b/Tests/LootLockerTests/PlayMode/LeaderboardTest.cs index ff5aac31f..dc26565ad 100644 --- a/Tests/LootLockerTests/PlayMode/LeaderboardTest.cs +++ b/Tests/LootLockerTests/PlayMode/LeaderboardTest.cs @@ -127,8 +127,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } diff --git a/Tests/LootLockerTests/PlayMode/MultiUserTests.cs b/Tests/LootLockerTests/PlayMode/MultiUserTests.cs index fe2517281..4592d59e3 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 #####"); } @@ -135,9 +145,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, - configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); #if LOOTLOCKER_ENABLE_OVERRIDABLE_STATE_WRITER LootLockerSDKManager.SetStateWriter(new LootLockerPlayerPrefsStateWriter()); @@ -770,7 +778,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 +976,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/NotificationTests.cs b/Tests/LootLockerTests/PlayMode/NotificationTests.cs index e18200259..690de3e74 100644 --- a/Tests/LootLockerTests/PlayMode/NotificationTests.cs +++ b/Tests/LootLockerTests/PlayMode/NotificationTests.cs @@ -129,8 +129,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } @@ -562,8 +561,6 @@ public IEnumerator Notifications_MarkAllNotificationsAsReadUsingConvenienceMetho Assert.AreEqual(CreatedTriggers.Count - notificationIdsToMarkAsRead.Length, listUnreadNotificationsAfterMarkAsReadResponse.Notifications.Length, "Not all notifications that were marked as read actually were"); } - - //TODO: Populate with new types [UnityTest, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] public IEnumerator Notifications_ConvenienceLookupTable_CanLookUpAllNotificationTypes() { diff --git a/Tests/LootLockerTests/PlayMode/PingTest.cs b/Tests/LootLockerTests/PlayMode/PingTest.cs index aa97e024c..1b8ee17a7 100644 --- a/Tests/LootLockerTests/PlayMode/PingTest.cs +++ b/Tests/LootLockerTests/PlayMode/PingTest.cs @@ -91,8 +91,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } diff --git a/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs b/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs index 38b167633..24b94a55f 100644 --- a/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs +++ b/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs @@ -106,8 +106,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } diff --git a/Tests/LootLockerTests/PlayMode/PlayerInfoTest.cs b/Tests/LootLockerTests/PlayMode/PlayerInfoTest.cs index 41691e94d..d7e5960bf 100644 --- a/Tests/LootLockerTests/PlayMode/PlayerInfoTest.cs +++ b/Tests/LootLockerTests/PlayMode/PlayerInfoTest.cs @@ -94,8 +94,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } diff --git a/Tests/LootLockerTests/PlayMode/PlayerStorageTest.cs b/Tests/LootLockerTests/PlayMode/PlayerStorageTest.cs index 26bbfd3e0..58d560d90 100644 --- a/Tests/LootLockerTests/PlayMode/PlayerStorageTest.cs +++ b/Tests/LootLockerTests/PlayMode/PlayerStorageTest.cs @@ -99,8 +99,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } diff --git a/Tests/LootLockerTests/PlayMode/PresenceTests.cs b/Tests/LootLockerTests/PlayMode/PresenceTests.cs new file mode 100644 index 000000000..497b21741 --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/PresenceTests.cs @@ -0,0 +1,625 @@ +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); + + 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 presenceConnectCallSucceeded = false; + string presenceConnectionMessage = null; + bool presenceConnectCallCompleted = false; + LootLockerSDKManager.ForceStartPresenceConnection((success, error) => + { + presenceConnectCallSucceeded = success; + presenceConnectionMessage = error; + presenceConnectCallCompleted = true; + }); + + yield return new WaitUntil(() => presenceConnectCallCompleted); + yield return new WaitForSeconds(1f); + + Assert.IsTrue(presenceConnectCallSucceeded, $"Presence connection should succeed before disconnect test. Error: {presenceConnectionMessage}"); + + // 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(); + foreach (var client in activeClients) + { + Assert.AreEqual(LootLockerSDKManager.GetPresenceConnectionState(client), LootLockerPresenceConnectionState.Disconnected, $"Client {client} should not be connected 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; + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PresenceConnection_DisconnectVsDestroy_PreservesClientAndAutoResendsStatus() + { + 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); + + // Set a status to test auto-resend + bool statusUpdated = false; + bool updateSuccess = false; + const string testStatus = "testing_disconnect_vs_destroy"; + + 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 + var statsBeforeDisconnect = LootLockerSDKManager.GetPresenceConnectionStats(null); + Assert.AreEqual(testStatus, statsBeforeDisconnect.lastSentStatus, "Status should be set before disconnect"); + + // Get initial client count (should be tracked even when disconnected) + var clientsBeforeDisconnect = LootLockerSDKManager.ListPresenceConnections().ToList(); + int initialClientCount = clientsBeforeDisconnect.Count; + Assert.Greater(initialClientCount, 0, "Should have clients before disconnect"); + + // Test disconnection (should preserve client) + bool presenceDisconnected = false; + bool disconnectSuccess = false; + + LootLockerSDKManager.ForceStopPresenceConnection((success, error) => + { + disconnectSuccess = success; + presenceDisconnected = true; + }); + + yield return new WaitUntil(() => presenceDisconnected); + Assert.IsTrue(disconnectSuccess, "Presence disconnection should succeed"); + + // Wait for disconnection to process + yield return new WaitForSeconds(1f); + + // Verify disconnection state + Assert.IsFalse(LootLockerSDKManager.IsPresenceConnected(), "Should not be connected after disconnect"); + + // Check that client is still tracked (preserved but disconnected) + var clientsAfterDisconnect = LootLockerSDKManager.ListPresenceConnections().ToList(); + Assert.AreEqual(initialClientCount, clientsAfterDisconnect.Count, "Client count should remain the same after disconnect (client preserved)"); + + // Verify we can still get stats from the disconnected client + var statsAfterDisconnect = LootLockerSDKManager.GetPresenceConnectionStats(null); + Assert.IsNotNull(statsAfterDisconnect, "Should still be able to get stats from disconnected client"); + Assert.AreEqual(testStatus, statsAfterDisconnect.lastSentStatus, "Last sent status should be preserved in disconnected client"); + + // Test reconnection (should reuse the same client) + bool reconnected = false; + bool reconnectionSuccess = false; + string errorMessage = null; + + LootLockerSDKManager.ForceStartPresenceConnection((success, error) => + { + reconnectionSuccess = success; + errorMessage = error; + reconnected = true; + }); + + yield return new WaitUntil(() => reconnected); + Assert.IsTrue(reconnectionSuccess, $"Reconnection failed: {errorMessage}"); + + // Wait for reconnection to stabilize and auto-resend to complete + yield return new WaitForSeconds(3f); + + // Verify reconnection state + Assert.IsTrue(LootLockerSDKManager.IsPresenceConnected(), "Should be connected after reconnection"); + + // Verify client was reused (not recreated) + var clientsAfterReconnect = LootLockerSDKManager.ListPresenceConnections().ToList(); + Assert.AreEqual(initialClientCount, clientsAfterReconnect.Count, "Client count should remain the same after reconnect (client reused)"); + + // Verify status was automatically resent + var statsAfterReconnect = LootLockerSDKManager.GetPresenceConnectionStats(null); + Assert.IsNotNull(statsAfterReconnect, "Should be able to get stats after reconnect"); + Assert.AreEqual(testStatus, statsAfterReconnect.lastSentStatus, "Status should be auto-resent after reconnection"); + + yield return null; + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PresenceConnection_SessionRefresh_ReconnectsWithNewToken() + { + if (SetupFailed) + { + yield break; + } + + LootLockerConfig.current.allowTokenRefresh = true; + // Setup session and presence connection + LootLockerSDKManager.SetPresenceEnabled(true); + LootLockerSDKManager.SetPresenceAutoConnectEnabled(true); // Enable auto-connect for session refresh test + + 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-connection + yield return new WaitForSeconds(3f); + Assert.IsTrue(LootLockerSDKManager.IsPresenceConnected(), "Presence should auto-connect"); + + // Set a status + bool statusUpdated = false; + const string testStatus = "before_session_refresh"; + + LootLockerSDKManager.UpdatePresenceStatus(testStatus, null, (success) => + { + statusUpdated = true; + }); + + yield return new WaitUntil(() => statusUpdated); + + // Get stats before refresh + var statsBeforeRefresh = LootLockerSDKManager.GetPresenceConnectionStats(null); + Assert.AreEqual(testStatus, statsBeforeRefresh.lastSentStatus, "Status should be set before refresh"); + + var playerDataBeforeRefresh = LootLockerStateData.GetPlayerDataForPlayerWithUlidWithoutChangingState(sessionResponse.player_ulid); + string oldSessionToken = playerDataBeforeRefresh.SessionToken; + playerDataBeforeRefresh.SessionToken = "invalid_token_for_test"; // Invalidate token to force refresh + LootLockerStateData.SetPlayerData(playerDataBeforeRefresh); + + // End current session first + bool getPlayerNameCompleted = false; + PlayerNameResponse playerNameResponse = null; + LootLockerSDKManager.GetPlayerName((response) => + { + playerNameResponse = response; + getPlayerNameCompleted = true; + }); + yield return new WaitUntil(() => getPlayerNameCompleted); + Assert.IsTrue(playerNameResponse.success, "Get player name succeeded despite invalid token (refresh was performed)"); + + // Wait for auto-reconnect with new token + yield return new WaitForSeconds(3f); + + // Verify new connection + Assert.IsTrue(LootLockerSDKManager.IsPresenceConnected(), "Should reconnect after session refresh"); + + // Verify client was preserved and status was auto-resent (new behavior) + var statsAfterRefresh = LootLockerSDKManager.GetPresenceConnectionStats(null); + Assert.IsNotNull(statsAfterRefresh, "Should have stats for preserved client"); + + // The client should have auto-resent the previous status after token refresh + Assert.AreEqual(testStatus, statsAfterRefresh.lastSentStatus, + "Client should auto-resend previous status after session token refresh"); + + yield return null; + } + } +} 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/SessionRefreshTest.cs b/Tests/LootLockerTests/PlayMode/SessionRefreshTest.cs index 2610d0c90..57e7f3a8f 100644 --- a/Tests/LootLockerTests/PlayMode/SessionRefreshTest.cs +++ b/Tests/LootLockerTests/PlayMode/SessionRefreshTest.cs @@ -116,8 +116,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } diff --git a/Tests/LootLockerTests/PlayMode/SubmitScoreTest.cs b/Tests/LootLockerTests/PlayMode/SubmitScoreTest.cs index 40994c9a0..e70508a90 100644 --- a/Tests/LootLockerTests/PlayMode/SubmitScoreTest.cs +++ b/Tests/LootLockerTests/PlayMode/SubmitScoreTest.cs @@ -151,8 +151,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } diff --git a/Tests/LootLockerTests/PlayMode/TriggerTests.cs b/Tests/LootLockerTests/PlayMode/TriggerTests.cs index 71896fc43..e99991d35 100644 --- a/Tests/LootLockerTests/PlayMode/TriggerTests.cs +++ b/Tests/LootLockerTests/PlayMode/TriggerTests.cs @@ -107,8 +107,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } diff --git a/Tests/LootLockerTests/PlayMode/WhiteLabelLoginTest.cs b/Tests/LootLockerTests/PlayMode/WhiteLabelLoginTest.cs index 97a2395e3..9ebb58a8e 100644 --- a/Tests/LootLockerTests/PlayMode/WhiteLabelLoginTest.cs +++ b/Tests/LootLockerTests/PlayMode/WhiteLabelLoginTest.cs @@ -87,8 +87,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } public string GetRandomName() @@ -285,7 +284,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 +340,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; diff --git a/Tests/LootLockerTests/PlayMode/leaderboardDetailsTest.cs b/Tests/LootLockerTests/PlayMode/leaderboardDetailsTest.cs index 875bca5d8..91efb6478 100644 --- a/Tests/LootLockerTests/PlayMode/leaderboardDetailsTest.cs +++ b/Tests/LootLockerTests/PlayMode/leaderboardDetailsTest.cs @@ -131,8 +131,7 @@ public IEnumerator TearDown() LootLockerStateData.ClearAllSavedStates(); - LootLockerConfig.CreateNewSettings(configCopy.apiKey, configCopy.game_version, configCopy.domainKey, - configCopy.logLevel, configCopy.logInBuilds, configCopy.logErrorsAsWarnings, configCopy.allowTokenRefresh); + LootLockerConfig.CreateNewSettings(configCopy); Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } diff --git a/package.json b/package.json index f2f48d630..4c0e4cec6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.lootlocker.lootlockersdk", - "version": "6.5.0", + "version": "7.0.0", "displayName": "LootLocker", "description": "LootLocker is a game backend-as-a-service with plug and play tools to upgrade your game and give your players the best experience possible. Designed for teams of all shapes and sizes, on mobile, PC and console. From solo developers, indie teams, AAA studios, and publishers. Built with cross-platform in mind.\n\n▪ Manage your game\nSave time and upgrade your game with leaderboards, progression, and more. Completely off-the-shelf features, built to work with any game and platform.\n\n▪ Manage your content\nTake charge of your game's content on all platforms, in one place. Sort, edit and manage everything, from cosmetics to currencies, UGC to DLC. Without breaking a sweat.\n\n▪ Manage your players\nStore your players' data together in one place. Access their profile and friends list cross-platform. Manage reports, messages, refunds and gifts to keep them hooked.\n", "unity": "2019.2",