diff --git a/.doxygen/groups.dox b/.doxygen/groups.dox index 2ce28bc3a..8e14fdf4f 100644 --- a/.doxygen/groups.dox +++ b/.doxygen/groups.dox @@ -214,6 +214,15 @@ /// /// See the [LootLocker documentation](https://docs.lootlocker.com/commerce/wallets). +/// @defgroup PlatformKeys Platform Keys +/// @brief List platform keys redeemed by the authenticated player. +/// +/// Platform keys are keys distributed through LootLocker's Campaign system +/// (e.g. Steam keys, Discord keys). This endpoint returns all keys redeemed +/// by the currently authenticated player, grouped by campaign. +/// +/// See the [LootLocker documentation](https://docs.lootlocker.com/campaigns/overview). + /// @defgroup Catalog Catalog /// @brief Browse item listings and prices in the LootLocker storefront catalog. /// diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index e154e3879..be408cb57 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -59,7 +59,7 @@ jobs: PR_JSON=$(gh pr view "$PR_NUMBER" --json number,body,labels,title,state --jq '.') PR_TITLE=$(echo "$PR_JSON" | jq -r '.title') - HAS_RC_LABEL=$(echo "$PR_JSON" | jq -r '[.labels[].name] | index("release candidate") != null') + HAS_RC_LABEL=$(echo "$PR_JSON" | jq -r '.labels[].name' | tr '[:upper:]' '[:lower:]' | grep -q 'release candidate' && echo "true" || echo "false") PR_STATE=$(echo "$PR_JSON" | jq -r '.state') echo "Found PR #${PR_NUMBER}: ${PR_TITLE} (state: ${PR_STATE})" diff --git a/.github/workflows/enforce-release-pr.yml b/.github/workflows/enforce-release-pr.yml index 941b60fb4..cf451973a 100644 --- a/.github/workflows/enforce-release-pr.yml +++ b/.github/workflows/enforce-release-pr.yml @@ -16,7 +16,7 @@ jobs: name: Validate release candidate PR runs-on: ubuntu-latest timeout-minutes: 3 - if: contains(github.event.pull_request.labels.*.name, 'release candidate') + if: contains(github.event.pull_request.labels.*.name, 'Release Candidate') || contains(github.event.pull_request.labels.*.name, 'release candidate') steps: - name: Checkout diff --git a/.github/workflows/open-release-pr.yml b/.github/workflows/open-release-pr.yml index 1857a7fa7..c96edf457 100644 --- a/.github/workflows/open-release-pr.yml +++ b/.github/workflows/open-release-pr.yml @@ -41,19 +41,21 @@ jobs: id: current run: | VERSION=$(jq -r '.version' package.json) + if [ -z "$VERSION" ] || [ "$VERSION" = "null" ]; then + echo "::error::Could not read version from package.json" + exit 1 + fi echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" echo "Current version: ${VERSION}" - name: Get previous release tag and commit log id: prev-tag run: | - LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") echo "TAG=${LATEST_TAG}" >> "$GITHUB_OUTPUT" - echo "Previous tag: ${LATEST_TAG:-none}" - - # Collect commits since last tag (or all commits if no tag yet) + echo "Previous tag: ${LATEST_TAG}" REV_RANGE="" - if [ -n "$LATEST_TAG" ]; then + if [ -n "$LATEST_TAG" ] && [ "$LATEST_TAG" != "v0.0.0" ]; then REV_RANGE="${LATEST_TAG}..HEAD" else REV_RANGE="HEAD" @@ -81,23 +83,24 @@ jobs: if [ "$BUMP" = "auto" ] && [ "$ALREADY_CALCULATED" = "false" ]; then echo "Auto-detecting version from OpenRouter..." if [ -z "${{ secrets.OPENROUTER_API_KEY }}" ]; then - echo "::error::OPENROUTER_API_KEY secret not set. Use manual bump or set secret." - exit 1 - fi - COMMITS=$(head -100 /tmp/commits.log) - printf 'Based on these commit messages since the last release (v%s), determine the correct semver version for the next release. Analyze breaking changes, new features, and fixes. Respond with only the version number (e.g., 9.0.0).\n\n%s\n' "$CURRENT" "$COMMITS" > /tmp/version-prompt.txt - RESPONSE=$(curl -s "https://openrouter.ai/api/v1/chat/completions" \ - -H "Authorization: Bearer ${{ secrets.OPENROUTER_API_KEY }}" \ - -H "Content-Type: application/json" \ - -d "$(jq -n --rawfile prompt /tmp/version-prompt.txt '{model:"deepseek/deepseek-v4-pro",messages:[{role:"user",content:$prompt}],reasoning:{effort:"low"},max_tokens:2000,temperature:0}')") - NEW=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // ""' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) - if [ -z "$NEW" ]; then - echo "::warning::AI returned no parseable version, falling back to patch bump." - PATCH=$(echo "$CURRENT" | cut -d. -f3) - NEW="$(echo "$CURRENT" | cut -d. -f1).$(echo "$CURRENT" | cut -d. -f2).$((PATCH + 1))" + echo "::warning::OPENROUTER_API_KEY not set. Falling back to patch bump." + BUMP="patch" + else + COMMITS=$(head -100 /tmp/commits.log) + printf 'The current version is v%s. Based on these commit messages since the last release, determine the correct semver version for the next release. Analyze breaking changes, new features, and fixes. Respond with only the version number (e.g., 9.0.0).\n\n%s\n' "$CURRENT" "$COMMITS" > /tmp/version-prompt.txt + RESPONSE=$(curl -s "https://openrouter.ai/api/v1/chat/completions" \ + -H "Authorization: Bearer ${{ secrets.OPENROUTER_API_KEY }}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n --rawfile prompt /tmp/version-prompt.txt '{model:"deepseek/deepseek-v4-pro",messages:[{role:"user",content:$prompt}],reasoning:{effort:"low"},max_tokens:2000,temperature:0}')") + NEW=$(echo "$RESPONSE" | jq -r '.choices[0].message.content // ""' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1) + if [ -z "$NEW" ]; then + echo "::warning::AI returned no parseable version. Falling back to patch bump." + BUMP="patch" + else + echo "AI suggested version: ${NEW}" + ALREADY_CALCULATED=true + fi fi - echo "AI suggested version: ${NEW}" - ALREADY_CALCULATED=true fi if [ "$ALREADY_CALCULATED" = "false" ]; then diff --git a/.github/workflows/package-sdk.yml b/.github/workflows/package-sdk.yml index 53b640e33..ad87fd30c 100644 --- a/.github/workflows/package-sdk.yml +++ b/.github/workflows/package-sdk.yml @@ -68,7 +68,8 @@ jobs: - name: Attach .unitypackage to release if: github.event_name == 'release' run: | - for f in unity-sdk-packager/LootLockerSDK*.unitypackage; do + cd unity-sdk + for f in ../unity-sdk-packager/LootLockerSDK*.unitypackage; do echo "Uploading $f" gh release upload --clobber "${{ github.ref_name }}" "$f" done diff --git a/Runtime/Client/LootLockerEndPoints.cs b/Runtime/Client/LootLockerEndPoints.cs index 1bbf294f3..6a5aee8f8 100644 --- a/Runtime/Client/LootLockerEndPoints.cs +++ b/Runtime/Client/LootLockerEndPoints.cs @@ -43,6 +43,7 @@ public class LootLockerEndPoints // White Label Login [Header("White Label Login")] public static EndPointClass whiteLabelSignUp = new EndPointClass("white-label-login/sign-up", LootLockerHTTPMethod.POST, LootLockerEnums.LootLockerCallerRole.Base); + public static EndPointClass whiteLabelSignUpFields = new EndPointClass("white-label-login/sign-up/fields", LootLockerHTTPMethod.GET, LootLockerEnums.LootLockerCallerRole.Base); public static EndPointClass whiteLabelLogin = new EndPointClass("white-label-login/login", LootLockerHTTPMethod.POST, LootLockerEnums.LootLockerCallerRole.Base); public static EndPointClass whiteLabelVerifySession = new EndPointClass("white-label-login/verify-session", LootLockerHTTPMethod.POST, LootLockerEnums.LootLockerCallerRole.Base); public static EndPointClass whiteLabelRequestPasswordReset = new EndPointClass("white-label-login/request-reset-password", LootLockerHTTPMethod.POST, LootLockerEnums.LootLockerCallerRole.Base); @@ -73,6 +74,14 @@ public class LootLockerEndPoints public static EndPointClass uploadPlayerFile = new EndPointClass("player/files", LootLockerHTTPMethod.UPLOAD_FILE); public static EndPointClass updatePlayerFile = new EndPointClass("/player/files/{0}", LootLockerHTTPMethod.UPDATE_FILE); public static EndPointClass deletePlayerFile = new EndPointClass("/player/files/{0}", LootLockerHTTPMethod.DELETE); + public static EndPointClass listPlayerFileRevisions = new EndPointClass("player/files/{0}/revisions", LootLockerHTTPMethod.GET); + public static EndPointClass getPlayerFileRevision = new EndPointClass("player/files/{0}/revisions/{1}", LootLockerHTTPMethod.GET); + public static EndPointClass promotePlayerFileRevision = new EndPointClass("player/files/{0}/revisions/{1}/current", LootLockerHTTPMethod.POST); + public static EndPointClass getPlayerFileByKey = new EndPointClass("player/files/key/{0}", LootLockerHTTPMethod.GET); + public static EndPointClass listPlayerFileRevisionsByKey = new EndPointClass("player/files/key/{0}/revisions", LootLockerHTTPMethod.GET); + public static EndPointClass getPlayerFileRevisionByKey = new EndPointClass("player/files/key/{0}/revisions/{1}", LootLockerHTTPMethod.GET); + public static EndPointClass promotePlayerFileRevisionByKey = new EndPointClass("player/files/key/{0}/revisions/{1}/current", LootLockerHTTPMethod.POST); + public static EndPointClass deletePlayerFileByKey = new EndPointClass("player/files/key/{0}", LootLockerHTTPMethod.DELETE); // Player Progressions [Header("Player Progressions")] @@ -253,6 +262,10 @@ public class LootLockerEndPoints public static EndPointClass getCurrencyDetails = new EndPointClass("currency/code/{0}", LootLockerHTTPMethod.GET); public static EndPointClass getCurrencyDenominationsByCode = new EndPointClass("currency/code/{0}/denominations", LootLockerHTTPMethod.GET); + // Platform Keys + [Header("Platform Keys")] + public static EndPointClass listPlatformKeys = new EndPointClass("platform-keys/v1", LootLockerHTTPMethod.GET); + // Balances [Header("Balances")] public static EndPointClass listBalancesInWallet = new EndPointClass("balances/wallet/{0}", LootLockerHTTPMethod.GET); diff --git a/Runtime/Client/LootLockerLifecycleManager.cs b/Runtime/Client/LootLockerLifecycleManager.cs index 9bef1cb77..65a2c6fb5 100644 --- a/Runtime/Client/LootLockerLifecycleManager.cs +++ b/Runtime/Client/LootLockerLifecycleManager.cs @@ -572,7 +572,7 @@ private IEnumerator ServiceHealthMonitor() while (_serviceHealthMonitoringEnabled && Application.isPlaying) { - yield return new WaitForSeconds(healthCheckInterval); + yield return new WaitForSecondsRealtime(healthCheckInterval); if (_state != LifecycleManagerState.Ready) { diff --git a/Runtime/Client/LootLockerPresenceClient.cs b/Runtime/Client/LootLockerPresenceClient.cs index 716cc373d..f242075b5 100644 --- a/Runtime/Client/LootLockerPresenceClient.cs +++ b/Runtime/Client/LootLockerPresenceClient.cs @@ -478,7 +478,7 @@ private IEnumerator WaitForConnectionAndUpdateStatus(string status, DictionaryThe key to set the value for. /// The value to set. void SetString(string key, string value); + /// + /// Get an int from persistent storage. If the key does not exist then return the provided default value. + /// + /// The key to retrieve the value for. + /// The value to return if the key does not exist. + /// The value associated with the key, or the default value if the key does not exist. + int GetInt(string key, int defaultValue = 0); + /// + /// Set an integer in persistent storage. + /// + /// The key to set the value for. + /// The value to set. + void SetInt(string key, int value); /// /// Delete a key from persistent storage. @@ -64,6 +77,17 @@ public string GetString(string key, string defaultValue = "") return PlayerPrefs.GetString(key, defaultValue); } + /// + /// Gets an int from PlayerPrefs. + /// + /// The key to retrieve the value for. + /// The value to return if the key does not exist. + /// The value associated with the key, or the default value if the key does not exist. + public int GetInt(string key, int defaultValue = 0) + { + return PlayerPrefs.GetInt(key, defaultValue); + } + /// /// Checks if a key exists in PlayerPrefs. /// @@ -74,6 +98,17 @@ public bool HasKey(string key) return PlayerPrefs.HasKey(key); } + /// + /// Sets an int in PlayerPrefs and saves the changes. + /// + /// The key to set the value for. + /// The value to set. + public void SetInt(string key, int value) + { + PlayerPrefs.SetInt(key, value); + PlayerPrefs.Save(); + } + /// /// Sets a string in PlayerPrefs and saves the changes. /// @@ -98,6 +133,11 @@ public string GetString(string key, string defaultValue = "") return defaultValue; } + public int GetInt(string key, int defaultValue = 0) + { + return defaultValue; + } + public bool HasKey(string key) { return false; @@ -107,5 +147,10 @@ public void SetString(string key, string value) { // Do nothing } + + public void SetInt(string key, int value) + { + // Do nothing + } } } diff --git a/Runtime/Editor/Editor UI/LootLockerAdminExtension.cs b/Runtime/Editor/Editor UI/LootLockerAdminExtension.cs index d96a792eb..4027034d2 100644 --- a/Runtime/Editor/Editor UI/LootLockerAdminExtension.cs +++ b/Runtime/Editor/Editor UI/LootLockerAdminExtension.cs @@ -96,7 +96,7 @@ public partial class LootLockerAdminExtension : EditorWindow [MenuItem("Window/" + LootLockerConfig.PackageName + "/Tools/Clear Local Player Data", true, 101)] public static bool ValidateClearLocalPlayerData() { - return LootLockerConfig.current.enableEditorAdminExtension; + return true; } [MenuItem("Window/" + LootLockerConfig.PackageName + "/Tools/Clear Local Player Data", false, 101)] diff --git a/Runtime/Editor/ProjectSettings.cs b/Runtime/Editor/ProjectSettings.cs index 2591fbc1c..a9cf4458d 100644 --- a/Runtime/Editor/ProjectSettings.cs +++ b/Runtime/Editor/ProjectSettings.cs @@ -14,6 +14,10 @@ public class ProjectSettings : SettingsProvider public delegate void SendAttributionDelegate(); public static event SendAttributionDelegate APIKeyEnteredEvent; + + public static bool logSettingsFoldout = true; + public static bool presenceSettingsFoldout = true; + internal static SerializedObject GetSerializedSettings() { if (gameSettings == null) @@ -22,6 +26,7 @@ internal static SerializedObject GetSerializedSettings() } return new SerializedObject(gameSettings); } + public ProjectSettings(string path, SettingsScope scopes, IEnumerable keywords = null) : base(path, scopes, keywords) { } @@ -73,11 +78,13 @@ public override void OnGUI(string searchContext) private void DrawGameSettings() { +#pragma warning disable 0162 if (LootLockerConfig.PackageName != "LootLocker") { EditorGUILayout.HelpBox(LootLockerConfig.PackageName + " SDK is powered by LootLocker. Settings here configure the underlying LootLocker integration.", MessageType.Info); EditorGUILayout.Space(); } +#pragma warning restore 0162 if (LootLockerConfig.IsFileConfigActive) { @@ -147,64 +154,92 @@ private void DrawGameSettings() } EditorGUI.BeginChangeCheck(); - EditorGUILayout.PropertyField(m_CustomSettings.FindProperty("logLevel")); + EditorGUILayout.PropertyField(m_CustomSettings.FindProperty("allowTokenRefresh")); if (EditorGUI.EndChangeCheck()) { - gameSettings.logLevel = (LootLockerLogger.LogLevel)m_CustomSettings.FindProperty("logLevel").enumValueIndex; + gameSettings.allowTokenRefresh = m_CustomSettings.FindProperty("allowTokenRefresh").boolValue; } EditorGUILayout.Space(); EditorGUI.BeginChangeCheck(); - EditorGUILayout.PropertyField(m_CustomSettings.FindProperty("logErrorsAsWarnings")); + EditorGUILayout.PropertyField(m_CustomSettings.FindProperty("multiUserSessionMode")); if (EditorGUI.EndChangeCheck()) { - gameSettings.logErrorsAsWarnings = m_CustomSettings.FindProperty("logErrorsAsWarnings").boolValue; + gameSettings.multiUserSessionMode = (LootLockerMultiUserSessionMode)m_CustomSettings.FindProperty("multiUserSessionMode").enumValueIndex; } EditorGUILayout.Space(); + DrawLogSettings(); + + DrawPresenceSettings(); + + EditorGUI.EndDisabledGroup(); + } + + private static bool IsSemverString(string str) + { + return Regex.IsMatch(str, + @"^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?$"); + } + + private void DrawLogSettings() + { +#if UNITY_2019_3_OR_NEWER + logSettingsFoldout = EditorGUILayout.Foldout(logSettingsFoldout, "Log Settings", true, EditorStyles.foldoutHeader); +#else + logSettingsFoldout = EditorGUILayout.Foldout(logSettingsFoldout, "Log Settings", true, EditorStyles.foldout); +#endif + if (!logSettingsFoldout) return; + EditorGUILayout.Space(); + EditorGUI.BeginChangeCheck(); - EditorGUILayout.PropertyField(m_CustomSettings.FindProperty("logInBuilds")); + EditorGUILayout.PropertyField(m_CustomSettings.FindProperty("logLevel")); if (EditorGUI.EndChangeCheck()) { - gameSettings.logInBuilds = m_CustomSettings.FindProperty("logInBuilds").boolValue; + gameSettings.logLevel = (LootLockerLogger.LogLevel)m_CustomSettings.FindProperty("logLevel").enumValueIndex; } EditorGUILayout.Space(); EditorGUI.BeginChangeCheck(); - EditorGUILayout.PropertyField(m_CustomSettings.FindProperty("prettifyJson"), new GUIContent("Log JSON Formatted")); + EditorGUILayout.PropertyField(m_CustomSettings.FindProperty("logErrorsAsWarnings")); if (EditorGUI.EndChangeCheck()) { - gameSettings.prettifyJson = m_CustomSettings.FindProperty("prettifyJson").boolValue; + gameSettings.logErrorsAsWarnings = m_CustomSettings.FindProperty("logErrorsAsWarnings").boolValue; } EditorGUILayout.Space(); EditorGUI.BeginChangeCheck(); - EditorGUILayout.PropertyField(m_CustomSettings.FindProperty("allowTokenRefresh")); + EditorGUILayout.PropertyField(m_CustomSettings.FindProperty("logInBuilds")); if (EditorGUI.EndChangeCheck()) { - gameSettings.allowTokenRefresh = m_CustomSettings.FindProperty("allowTokenRefresh").boolValue; + gameSettings.logInBuilds = m_CustomSettings.FindProperty("logInBuilds").boolValue; } EditorGUILayout.Space(); - DrawPresenceSettings(); + EditorGUI.BeginChangeCheck(); + EditorGUILayout.PropertyField(m_CustomSettings.FindProperty("prettifyJson"), new GUIContent("Log JSON Formatted")); - EditorGUI.EndDisabledGroup(); - } + if (EditorGUI.EndChangeCheck()) + { + gameSettings.prettifyJson = m_CustomSettings.FindProperty("prettifyJson").boolValue; + } - private static bool IsSemverString(string str) - { - return Regex.IsMatch(str, - @"^(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?(?:\.(0|[1-9]\d*))?$"); + EditorGUILayout.Space(); } private void DrawPresenceSettings() { - EditorGUILayout.LabelField("Presence Settings", EditorStyles.boldLabel); +#if UNITY_2019_3_OR_NEWER + presenceSettingsFoldout = EditorGUILayout.Foldout(presenceSettingsFoldout, "Presence Settings", true, EditorStyles.foldoutHeader); +#else + presenceSettingsFoldout = EditorGUILayout.Foldout(presenceSettingsFoldout, "Presence Settings", true, EditorStyles.foldout); +#endif + if (!presenceSettingsFoldout) return; EditorGUILayout.Space(); if(gameSettings.enablePresence) @@ -220,39 +255,32 @@ private void DrawPresenceSettings() { 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()) { - 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; - } + 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(); + 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(); diff --git a/Runtime/Editor/UpdateChecker/LootLockerUpdateChecker.cs b/Runtime/Editor/UpdateChecker/LootLockerUpdateChecker.cs index 7a3a121a9..8fa91eb3b 100644 --- a/Runtime/Editor/UpdateChecker/LootLockerUpdateChecker.cs +++ b/Runtime/Editor/UpdateChecker/LootLockerUpdateChecker.cs @@ -284,12 +284,14 @@ private void OnGUI() if (GUILayout.Button("See What's New \u2197")) Application.OpenURL(_releaseUrl); +#pragma warning disable 0162 if (LootLockerConfig.PackageName != "LootLocker") { EditorGUILayout.Space(4); var noticeStyle = new GUIStyle(EditorStyles.miniLabel) { fontStyle = FontStyle.Italic }; EditorGUILayout.LabelField(LootLockerConfig.PackageName + " SDK is powered by LootLocker \u2014 release notes are on the LootLocker GitHub page.", noticeStyle); } +#pragma warning restore 0162 EditorGUILayout.Space(8); EditorGUILayout.BeginHorizontal(); diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index 75b96fc51..03d7ef35d 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -152,11 +152,26 @@ public static void _OverrideLootLockerCertificateHandler(CertificateHandler cert #region SDK Customization #if LOOTLOCKER_ENABLE_OVERRIDABLE_STATE_WRITER /// @ingroup SDKCustomization + /// + /// Override the default state writer used by the SDK. This allows you to customize how the SDK saves and loads player state data. + /// The default is the default Unity Player Prefs implementation, but you can provide your own implementation of ILootLockerStateWriter to save state data in a different way (e.g. to a file, to a database, etc.). + /// + /// The state writer to use for saving and loading player state data. public static void SetStateWriter(ILootLockerStateWriter stateWriter) { LootLockerStateData.overrideStateWriter(stateWriter); } #endif + + /// @ingroup SDKCustomization + /// + /// Get the current state writer used by the SDK. This allows you to access the current implementation of ILootLockerStateWriter used for saving and loading player state data. + /// + /// The current state writer used by the SDK. + public static ILootLockerStateWriter GetStateWriter() + { + return LootLockerStateData.GetStateWriter(); + } /// @ingroup SDKCustomization /// @@ -2459,6 +2474,102 @@ public static void ConnectTwitchAccount(string authorizationCode, Action { LootLockerResponse.Deserialize(onComplete, response); }); } + /// @ingroup ConnectedAccounts + /// + /// Connect a Steam account to the currently logged in LootLocker account using a raw Steam session ticket (byte array). + /// Internally converts the ticket to hex-encoded format before sending. + /// IMPORTANT: If you are using multiple users, be very sure to pass in the correct `forPlayerWithUlid` parameter as that will be the account that the Steam account is linked into + /// + /// The raw Steam session ticket byte array + /// The size of the ticket + /// onComplete Action for handling the response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void ConnectSteamAccount(ref byte[] ticket, uint ticketSize, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + string hexTicket = _SteamSessionTicket(ref ticket, ticketSize); + + string endpoint = LootLockerEndPoints.connectProviderToAccount.WithPathParameter("steam"); + + string data = LootLockerJson.SerializeObject(new LootLockerConnectSteamProviderToAccountRequest() { steam_ticket = hexTicket }); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endpoint, LootLockerEndPoints.connectProviderToAccount.httpMethod, data, (response) => { LootLockerResponse.Deserialize(onComplete, response); }); + } + + /// @ingroup ConnectedAccounts + /// + /// Connect an Xbox account to the currently logged in LootLocker account allowing that Xbox account to start sessions for this player + /// IMPORTANT: If you are using multiple users, be very sure to pass in the correct `forPlayerWithUlid` parameter as that will be the account that the Xbox account is linked into + /// + /// The Xbox user token + /// onComplete Action for handling the response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void ConnectXboxAccount(string xboxUserToken, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + string endpoint = LootLockerEndPoints.connectProviderToAccount.WithPathParameter("xbox"); + + string data = LootLockerJson.SerializeObject(new LootLockerConnectXboxProviderToAccountRequest() { xbox_user_token = xboxUserToken }); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endpoint, LootLockerEndPoints.connectProviderToAccount.httpMethod, data, (response) => { LootLockerResponse.Deserialize(onComplete, response); }); + } + + /// @ingroup ConnectedAccounts + /// + /// Connect a Nintendo Switch account to the currently logged in LootLocker account allowing that Nintendo Switch account to start sessions for this player + /// IMPORTANT: If you are using multiple users, be very sure to pass in the correct `forPlayerWithUlid` parameter as that will be the account that the Nintendo Switch account is linked into + /// + /// The NSA ID token from Nintendo Switch sign in + /// onComplete Action for handling the response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void ConnectNintendoAccount(string nsaIdToken, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + string endpoint = LootLockerEndPoints.connectProviderToAccount.WithPathParameter("nintendo"); + + string data = LootLockerJson.SerializeObject(new LootLockerConnectNintendoProviderToAccountRequest() { nsa_id_token = nsaIdToken }); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endpoint, LootLockerEndPoints.connectProviderToAccount.httpMethod, data, (response) => { LootLockerResponse.Deserialize(onComplete, response); }); + } + + /// @ingroup ConnectedAccounts + /// + /// Connect a Google Play Games account to the currently logged in LootLocker account allowing that Google Play Games account to start sessions for this player + /// IMPORTANT: If you are using multiple users, be very sure to pass in the correct `forPlayerWithUlid` parameter as that will be the account that the Google Play Games account is linked into + /// + /// The auth code from Google Play Games sign in + /// onComplete Action for handling the response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void ConnectGooglePlayGamesAccount(string authCode, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + string endpoint = LootLockerEndPoints.connectProviderToAccount.WithPathParameter("google-play-games"); + + string data = LootLockerJson.SerializeObject(new LootLockerConnectGooglePlayGamesProviderToAccountRequest() { auth_code = authCode }); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endpoint, LootLockerEndPoints.connectProviderToAccount.httpMethod, data, (response) => { LootLockerResponse.Deserialize(onComplete, response); }); + } + /// @ingroup ConnectedAccounts /// /// Connect an Epic Account to the currently logged in LootLocker account allowing that Epic account to start sessions for this player @@ -2884,7 +2995,7 @@ public static void WhiteLabelSignUp(string email, string password, Action + /// Create new user using the White Label login system, optionally including answers to custom sign-up fields. + /// Call first to retrieve the fields configured for this game, + /// then pass the player's answers as . + /// White Label platform must be enabled in the web console for this to work. + /// + /// E-mail for the new user + /// Password for the new user + /// + /// Answers to the custom sign-up fields configured in the web console. + /// Each entry must include the metadata_key matching a configured field and the value as a JSON string in value_json. + /// Pass null or an empty array if there are no custom fields. + /// + /// onComplete Action for handling the response of type LootLockerWhiteLabelSignupResponse + public static void WhiteLabelSignUp(string email, string password, LootLockerWhiteLabelCustomFieldValue[] customFields, Action onComplete) + { + if (!CheckInitialized(true)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(null)); + return; + } + + LootLockerWhiteLabelSignUpRequest input = new LootLockerWhiteLabelSignUpRequest + { + email = email, + password = password, + custom_fields = customFields + }; + + LootLockerAPIManager.WhiteLabelSignUp(input, onComplete); + } + + /// @ingroup WhiteLabel + /// + /// Retrieve the list of custom sign-up fields configured for this game. + /// Use the returned fields to build a sign-up form, then pass the player's answers to + /// . + /// White Label platform must be enabled in the web console for this to work. + /// + /// onComplete Action for handling the response of type LootLockerWhiteLabelSignUpFieldsResponse + public static void WhiteLabelGetSignUpFields(Action onComplete) + { + if (!CheckInitialized(true)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(null)); + return; + } + + LootLockerAPIManager.WhiteLabelGetSignUpFields(onComplete); + } + /// @ingroup WhiteLabel /// /// Request a password reset email for the given email address. @@ -4043,7 +4206,6 @@ public static void UploadPlayerFile(string pathToFile, string filePurpose, bool { "public", isPublic.ToString().ToLower() } }; - var fileBytes = new byte[] { }; try { @@ -4127,7 +4289,7 @@ public static void UploadPlayerFile(FileStream fileStream, string filePurpose, b /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. public static void UploadPlayerFile(FileStream fileStream, string filePurpose, Action onComplete, string forPlayerWithUlid = null) { - UploadPlayerFile(fileStream, filePurpose, false, onComplete, forPlayerWithUlid); + UploadPlayerFile(fileStream, filePurpose, isPublic: false, onComplete, forPlayerWithUlid: forPlayerWithUlid); } /// @ingroup PlayerFiles @@ -4172,7 +4334,185 @@ public static void UploadPlayerFile(byte[] fileBytes, string fileName, string fi /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. public static void UploadPlayerFile(byte[] fileBytes, string fileName, string filePurpose, Action onComplete, string forPlayerWithUlid = null) { - UploadPlayerFile(fileBytes, fileName, filePurpose, false, onComplete, forPlayerWithUlid); + UploadPlayerFile(fileBytes, fileName, filePurpose, isPublic: false, onComplete, forPlayerWithUlid: forPlayerWithUlid); + } + + /// @ingroup PlayerFiles + /////////////////////////////////////////////////////////////////////////////// + + // ================================================================ + // UploadPlayerFileByKey — dedicated overloads for upsert-by-key + // ================================================================ + + /// @ingroup PlayerFiles + /// + /// Upload a file with the provided name and content, using a key for upsert behavior. + /// If a file with the given key already exists for this player, it will be updated. + /// + /// Path to the file, example: Application.persistentDataPath + "/" + fileName; + /// Purpose of the file, example: savefile/config + /// Should this file be viewable by other players? + /// Key for upsert behavior. If a file with this key already exists, it will be updated. + /// onComplete Action for handling the response of type LootLockerPlayerFile + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void UploadPlayerFileByKey(string pathToFile, string filePurpose, bool isPublic, string key, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + var body = new Dictionary() + { + { "purpose", filePurpose }, + { "public", isPublic.ToString().ToLower() }, + { "key", key } + }; + + var fileBytes = new byte[] { }; + try + { + fileBytes = File.ReadAllBytes(pathToFile); + } + catch (Exception e) + { + LootLockerLogger.Log($"File error: {e.Message}", LootLockerLogger.LogLevel.Error); + return; + } + + LootLockerServerRequest.UploadFile(forPlayerWithUlid, LootLockerEndPoints.uploadPlayerFile, fileBytes, Path.GetFileName(pathToFile), "multipart/form-data", body, + onComplete: (serverResponse) => + { + LootLockerResponse.Deserialize(onComplete, serverResponse); + }); + } + + /// @ingroup PlayerFiles + /// + /// Upload a file with the provided name and content, using a key for upsert behavior. + /// If a file with the given key already exists for this player, it will be updated. + /// The file will not be viewable by other players. + /// + /// Path to the file, example: Application.persistentDataPath + "/" + fileName; + /// Purpose of the file, example: savefile/config + /// Key for upsert behavior. If a file with this key already exists, it will be updated. + /// onComplete Action for handling the response of type LootLockerPlayerFile + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void UploadPlayerFileByKey(string pathToFile, string filePurpose, string key, Action onComplete, string forPlayerWithUlid = null) + { + UploadPlayerFileByKey(pathToFile, filePurpose, isPublic: false, key, onComplete, forPlayerWithUlid: forPlayerWithUlid); + } + + /// @ingroup PlayerFiles + /// + /// Upload a file using a Filestream, using a key for upsert behavior. + /// If a file with the given key already exists for this player, it will be updated. + /// + /// Filestream to upload + /// Purpose of the file, example: savefile/config + /// Should this file be viewable by other players? + /// Key for upsert behavior. If a file with this key already exists, it will be updated. + /// onComplete Action for handling the response of type LootLockerPlayerFile + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void UploadPlayerFileByKey(FileStream fileStream, string filePurpose, bool isPublic, string key, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + var body = new Dictionary() + { + { "purpose", filePurpose }, + { "public", isPublic.ToString().ToLower() }, + { "key", key } + }; + + var fileBytes = new byte[fileStream.Length]; + try + { + fileStream.Read(fileBytes, 0, Convert.ToInt32(fileStream.Length)); + } + catch (Exception e) + { + LootLockerLogger.Log($"File error: {e.Message}", LootLockerLogger.LogLevel.Error); + return; + } + + LootLockerServerRequest.UploadFile(forPlayerWithUlid, LootLockerEndPoints.uploadPlayerFile, fileBytes, Path.GetFileName(fileStream.Name), "multipart/form-data", body, + onComplete: (serverResponse) => + { + LootLockerResponse.Deserialize(onComplete, serverResponse); + }); + } + + /// @ingroup PlayerFiles + /// + /// Upload a file using a Filestream, using a key for upsert behavior. + /// If a file with the given key already exists for this player, it will be updated. + /// The file will not be viewable by other players. + /// + /// Filestream to upload + /// Purpose of the file, example: savefile/config + /// Key for upsert behavior. If a file with this key already exists, it will be updated. + /// onComplete Action for handling the response of type LootLockerPlayerFile + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void UploadPlayerFileByKey(FileStream fileStream, string filePurpose, string key, Action onComplete, string forPlayerWithUlid = null) + { + UploadPlayerFileByKey(fileStream, filePurpose, isPublic: false, key, onComplete, forPlayerWithUlid: forPlayerWithUlid); + } + + /// @ingroup PlayerFiles + /// + /// Upload a file using a byte array, using a key for upsert behavior. + /// If a file with the given key already exists for this player, it will be updated. + /// + /// Byte array to upload + /// Name of the file on LootLocker + /// Purpose of the file, example: savefile/config + /// Should this file be viewable by other players? + /// Key for upsert behavior. If a file with this key already exists, it will be updated. + /// onComplete Action for handling the response of type LootLockerPlayerFile + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void UploadPlayerFileByKey(byte[] fileBytes, string fileName, string filePurpose, bool isPublic, string key, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + var body = new Dictionary() + { + { "purpose", filePurpose }, + { "public", isPublic.ToString().ToLower() }, + { "key", key } + }; + + LootLockerServerRequest.UploadFile(forPlayerWithUlid, LootLockerEndPoints.uploadPlayerFile, fileBytes, Path.GetFileName(fileName), "multipart/form-data", body, + onComplete: (serverResponse) => + { + LootLockerResponse.Deserialize(onComplete, serverResponse); + }); + } + + /// @ingroup PlayerFiles + /// + /// Upload a file using a byte array, using a key for upsert behavior. + /// If a file with the given key already exists for this player, it will be updated. + /// The file will not be viewable by other players. + /// + /// Byte array to upload + /// Name of the file on LootLocker + /// Purpose of the file, example: savefile/config + /// Key for upsert behavior. If a file with this key already exists, it will be updated. + /// onComplete Action for handling the response of type LootLockerPlayerFile + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void UploadPlayerFileByKey(byte[] fileBytes, string fileName, string filePurpose, string key, Action onComplete, string forPlayerWithUlid = null) + { + UploadPlayerFileByKey(fileBytes, fileName, filePurpose, isPublic: false, key, onComplete, forPlayerWithUlid: forPlayerWithUlid); } /// @ingroup PlayerFiles @@ -4293,6 +4633,154 @@ public static void DeletePlayerFile(int fileId, Action onCom LootLockerServerRequest.CallAPI(forPlayerWithUlid, endpoint, LootLockerHTTPMethod.DELETE, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } + + /// @ingroup PlayerFiles + /// + /// List all revisions for a player file. + /// + /// Id of the file. + /// onComplete Action for handling the response of type LootLockerPlayerFileRevisionsResponse + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void GetPlayerFileRevisions(int fileId, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerAPIManager.ListPlayerFileRevisions(forPlayerWithUlid, fileId, onComplete); + } + + /// @ingroup PlayerFiles + /// + /// Get a specific revision of a player file by its revision id. + /// + /// Id of the file. + /// The ULID of the revision to retrieve. + /// onComplete Action for handling the response of type LootLockerPlayerFileContent + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void GetPlayerFileRevision(int fileId, string revisionId, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerAPIManager.GetPlayerFileRevision(forPlayerWithUlid, fileId, revisionId, onComplete); + } + + /// @ingroup PlayerFiles + /// + /// Promote a specific revision to be the current (active) revision of a player file. + /// + /// Id of the file. + /// The ULID of the revision to promote. + /// onComplete Action for handling the response of type LootLockerResponse + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void PromotePlayerFileRevision(int fileId, string revisionId, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerAPIManager.PromotePlayerFileRevision(forPlayerWithUlid, fileId, revisionId, onComplete); + } + + /// @ingroup PlayerFiles + /// + /// Get a player file by its key. + /// + /// The key of the file. + /// onComplete Action for handling the response of type LootLockerPlayerFile + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void GetPlayerFileByKey(string key, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerAPIManager.GetPlayerFileByKey(forPlayerWithUlid, key, onComplete); + } + + /// @ingroup PlayerFiles + /// + /// List all revisions for a player file identified by its key. + /// + /// The key of the file. + /// onComplete Action for handling the response of type LootLockerPlayerFileRevisionsResponse + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void GetPlayerFileRevisionsByKey(string key, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerAPIManager.ListPlayerFileRevisionsByKey(forPlayerWithUlid, key, onComplete); + } + + /// @ingroup PlayerFiles + /// + /// Get a specific revision of a player file by its key and revision id. + /// + /// The key of the file. + /// The ULID of the revision to retrieve. + /// onComplete Action for handling the response of type LootLockerPlayerFileContent + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void GetPlayerFileRevisionByKey(string key, string revisionId, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerAPIManager.GetPlayerFileRevisionByKey(forPlayerWithUlid, key, revisionId, onComplete); + } + + /// @ingroup PlayerFiles + /// + /// Promote a specific revision to be the current (active) revision of a player file identified by its key. + /// + /// The key of the file. + /// The ULID of the revision to promote. + /// onComplete Action for handling the response of type LootLockerResponse + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void PromotePlayerFileRevisionByKey(string key, string revisionId, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerAPIManager.PromotePlayerFileRevisionByKey(forPlayerWithUlid, key, revisionId, onComplete); + } + + /// @ingroup PlayerFiles + /// + /// Delete a player file by its key. + /// + /// The key of the file to delete. + /// onComplete Action for handling the response of type LootLockerResponse + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void DeletePlayerFileByKey(string key, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerAPIManager.DeletePlayerFileByKey(forPlayerWithUlid, key, onComplete); + } #endregion #region Player progressions @@ -9046,6 +9534,25 @@ public static void GetCurrencyDenominationsByCode(string currencyCode, Action + /// Get a list of the platform keys redeemed by the authenticated player + /// + /// onComplete Action for handling the response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void ListPlatformKeys(Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, LootLockerEndPoints.listPlatformKeys.endPoint, LootLockerEndPoints.listPlatformKeys.httpMethod, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + #endregion + #region Balances /// @ingroup Balances /// diff --git a/Runtime/Game/Requests/ConnectedAccountRequest.cs b/Runtime/Game/Requests/ConnectedAccountRequest.cs index 458b11d1a..20f51fb4f 100644 --- a/Runtime/Game/Requests/ConnectedAccountRequest.cs +++ b/Runtime/Game/Requests/ConnectedAccountRequest.cs @@ -17,7 +17,8 @@ public enum LootLockerAccountProvider xbox = 7, playstation = 8, twitch = 9, - discord = 10 + discord = 10, + google_play_games = 11 } /// @@ -173,6 +174,50 @@ public class LootLockerConnectTwitchProviderToAccountRequest public string authorization_code { get; set; } } + /// + /// Request to link a Steam account to the current player's LootLocker account using a steam session ticket. + /// + public class LootLockerConnectSteamProviderToAccountRequest + { + /// + /// The Steam session ticket (hex-encoded) + /// + public string steam_ticket { get; set; } + } + + /// + /// Request to link an Xbox account to the current player's LootLocker account using an Xbox user token. + /// + public class LootLockerConnectXboxProviderToAccountRequest + { + /// + /// The Xbox user token + /// + public string xbox_user_token { get; set; } + } + + /// + /// Request to link a Nintendo Switch account to the current player's LootLocker account using an NSA ID token. + /// + public class LootLockerConnectNintendoProviderToAccountRequest + { + /// + /// The NSA ID token from Nintendo Switch sign in + /// + public string nsa_id_token { get; set; } + } + + /// + /// Request to link a Google Play Games account to the current player's LootLocker account using an auth code. + /// + public class LootLockerConnectGooglePlayGamesProviderToAccountRequest + { + /// + /// The auth code from Google Play Games sign in + /// + public string auth_code { get; set; } + } + //================================================== // Response Definitions //================================================== diff --git a/Runtime/Game/Requests/LootLockerSessionRequest.cs b/Runtime/Game/Requests/LootLockerSessionRequest.cs index 74f779bec..3a362dc22 100644 --- a/Runtime/Game/Requests/LootLockerSessionRequest.cs +++ b/Runtime/Game/Requests/LootLockerSessionRequest.cs @@ -19,6 +19,11 @@ public class LootLockerSessionOptionals /// The name of the player (same as set by SetPlayerName). If not supplied, will be left blank. /// public string player_name { get; set; } = null; + /// + /// Whether to automatically create a profile for the player if one does not exist. Defaults to true. + /// Set to false if you want to ensure that a profile is not created for the player if one does not exist. In this case, the session will fail with a 404 Player Not Found error if the player does not have a profile. + /// + public bool auto_create_profile { get; set; } = true; } public class LootLockerSteamSessionRequest diff --git a/Runtime/Game/Requests/PlatformKeyRequests.cs b/Runtime/Game/Requests/PlatformKeyRequests.cs new file mode 100644 index 000000000..7dfbfe287 --- /dev/null +++ b/Runtime/Game/Requests/PlatformKeyRequests.cs @@ -0,0 +1,52 @@ +namespace LootLocker.Requests +{ + //================================================== + // Data Definitions + //================================================== + + /// + /// Information about the campaign associated with a platform key + /// + public class LootLockerPlatformKeyCampaign + { + /// + /// The name of the campaign that issued this key + /// + public string name { get; set; } + /// + /// The platform this key is for (e.g. "steam", "discord") + /// + public string platform { get; set; } + }; + + /// + /// A platform key redeemed by the player + /// + public class LootLockerPlatformKey + { + /// + /// Information about the campaign that issued this key + /// + public LootLockerPlatformKeyCampaign campaign { get; set; } + /// + /// The redeemed key value + /// + public string key { get; set; } + }; + + //================================================== + // Response Definitions + //================================================== + + /// + /// Response containing all platform keys redeemed by the authenticated player. + /// + public class LootLockerListPlatformKeysResponse : LootLockerResponse + { + /// + /// List of platform keys redeemed by the player + /// + public LootLockerPlatformKey[] platform_keys { get; set; } + }; + +} diff --git a/Runtime/Game/Requests/PlatformKeyRequests.cs.meta b/Runtime/Game/Requests/PlatformKeyRequests.cs.meta new file mode 100644 index 000000000..a94a78316 --- /dev/null +++ b/Runtime/Game/Requests/PlatformKeyRequests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: db18f465c3edca84dacafe89af3a3ab3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Runtime/Game/Requests/PlayerRequest.cs b/Runtime/Game/Requests/PlayerRequest.cs index 22783a442..3680fa732 100644 --- a/Runtime/Game/Requests/PlayerRequest.cs +++ b/Runtime/Game/Requests/PlayerRequest.cs @@ -380,6 +380,8 @@ public class LootLockerPlayerFile : LootLockerResponse public string revision_id { get; set; } /// The file name. public string name { get; set; } + /// The optional key for upsert operations. + public string key { get; set; } /// The file size in bytes. public int size { get; set; } /// The purpose or category tag for this file. @@ -400,6 +402,53 @@ public class LootLockerPlayerFile : LootLockerResponse public DateTime created_at { get; set; } } + /// + /// Response containing a list of revisions for a player file. + /// + public class LootLockerPlayerFileRevisionsResponse : LootLockerResponse + { + /// The list of revisions. + public LootLockerPlayerFileContent[] revisions { get; set; } + /// Metadata about the file. + public LootLockerPlayerFileMetadata file { get; set; } + /// The ULID of the current (active) revision. + public string current_revision_id { get; set; } + } + + /// + /// Metadata about a player file, returned as part of the revisions response. + /// + public class LootLockerPlayerFileMetadata + { + /// When the file was created. + public DateTime created_at { get; set; } + /// The file name. + public string name { get; set; } + /// The optional key for upsert operations. + public string key { get; set; } + /// The purpose or category tag for this file. + public string purpose { get; set; } + /// The unique identifier of this player file. + public int id { get; set; } + /// Whether this file is publicly accessible. + public bool is_public { get; set; } + } + + /// + /// A single file revision with download URL and metadata. + /// + public class LootLockerPlayerFileContent : LootLockerResponse + { + /// The ULID of this revision. + public string id { get; set; } + /// The signed URL to download this revision. + public string url { get; set; } + /// The file size in bytes. + public int size { get; set; } + /// When this revision was created. + public DateTime created_at { get; set; } + } + /// /// Response containing asset reward notifications for the current player. /// @@ -470,5 +519,53 @@ public static void LookupPlayer1stPartyPlatformIDs(string forPlayerWithUlid, Loo LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint + queryParams.Build(), endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } + + public static void ListPlayerFileRevisions(string forPlayerWithUlid, int fileId, Action onComplete) + { + var endpoint = LootLockerEndPoints.listPlayerFileRevisions.WithPathParameter(fileId); + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endpoint, LootLockerHTTPMethod.GET, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + public static void GetPlayerFileRevision(string forPlayerWithUlid, int fileId, string revisionId, Action onComplete) + { + var endpoint = LootLockerEndPoints.getPlayerFileRevision.WithPathParameters(fileId, revisionId); + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endpoint, LootLockerHTTPMethod.GET, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + public static void PromotePlayerFileRevision(string forPlayerWithUlid, int fileId, string revisionId, Action onComplete) + { + var endpoint = LootLockerEndPoints.promotePlayerFileRevision.WithPathParameters(fileId, revisionId); + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endpoint, LootLockerHTTPMethod.POST, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + public static void GetPlayerFileByKey(string forPlayerWithUlid, string key, Action onComplete) + { + var endpoint = LootLockerEndPoints.getPlayerFileByKey.WithPathParameter(key); + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endpoint, LootLockerHTTPMethod.GET, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + public static void ListPlayerFileRevisionsByKey(string forPlayerWithUlid, string key, Action onComplete) + { + var endpoint = LootLockerEndPoints.listPlayerFileRevisionsByKey.WithPathParameter(key); + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endpoint, LootLockerHTTPMethod.GET, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + public static void GetPlayerFileRevisionByKey(string forPlayerWithUlid, string key, string revisionId, Action onComplete) + { + var endpoint = LootLockerEndPoints.getPlayerFileRevisionByKey.WithPathParameters(key, revisionId); + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endpoint, LootLockerHTTPMethod.GET, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + public static void PromotePlayerFileRevisionByKey(string forPlayerWithUlid, string key, string revisionId, Action onComplete) + { + var endpoint = LootLockerEndPoints.promotePlayerFileRevisionByKey.WithPathParameters(key, revisionId); + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endpoint, LootLockerHTTPMethod.POST, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + public static void DeletePlayerFileByKey(string forPlayerWithUlid, string key, Action onComplete) + { + var endpoint = LootLockerEndPoints.deletePlayerFileByKey.WithPathParameter(key); + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endpoint, LootLockerHTTPMethod.DELETE, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } } } diff --git a/Runtime/Game/Requests/PurchaseRequest.cs b/Runtime/Game/Requests/PurchaseRequest.cs index 40fc17d2c..48e716338 100644 --- a/Runtime/Game/Requests/PurchaseRequest.cs +++ b/Runtime/Game/Requests/PurchaseRequest.cs @@ -748,7 +748,7 @@ private IEnumerator ContinualPollAction(Guid processGuid) { yield break; } - yield return new WaitForSeconds(preProcess.PollingIntervalSeconds); + yield return new WaitForSecondsRealtime(preProcess.PollingIntervalSeconds); while (_asyncPurchaseProcesses.TryGetValue(processGuid, out var process)) { @@ -788,7 +788,7 @@ private IEnumerator ContinualPollAction(Guid processGuid) if (statusResponse.statusCode >= 500 && statusResponse.statusCode <= 599 && processAfterPoll.Retries < _asyncPurchasePollingRetryLimit) { processAfterPoll.Retries++; - yield return new WaitForSeconds(processAfterPoll.PollingIntervalSeconds); + yield return new WaitForSecondsRealtime(processAfterPoll.PollingIntervalSeconds); continue; } processAfterPoll.CompletedCallback?.Invoke(statusResponse); @@ -807,7 +807,7 @@ private IEnumerator ContinualPollAction(Guid processGuid) // Still pending — notify and wait processAfterPoll.StatusUpdateCallback?.Invoke(statusResponse); - yield return new WaitForSeconds(processAfterPoll.PollingIntervalSeconds); + yield return new WaitForSecondsRealtime(processAfterPoll.PollingIntervalSeconds); } } diff --git a/Runtime/Game/Requests/RemoteSessionRequest.cs b/Runtime/Game/Requests/RemoteSessionRequest.cs index e536b8374..e218623e4 100644 --- a/Runtime/Game/Requests/RemoteSessionRequest.cs +++ b/Runtime/Game/Requests/RemoteSessionRequest.cs @@ -56,12 +56,22 @@ public class LootLockerLeaseRemoteSessionRequest /// The Game Version configured for the game /// public string game_version { get; set; } + /// + /// Optional list of identity providers to restrict the remote session to (e.g., "steam", "apple") + /// + public string[] providers { get; set; } public LootLockerLeaseRemoteSessionRequest(string titleId, string environmentId) + : this(titleId, environmentId, null) + { + } + + public LootLockerLeaseRemoteSessionRequest(string titleId, string environmentId, string[] providers) { title_id = titleId; environment_id = environmentId; game_version = LootLockerConfig.current.game_version; + this.providers = providers; } } @@ -379,7 +389,7 @@ protected IEnumerator ContinualPollingAction(Guid processGuid) { yield break; } - yield return new WaitForSeconds(preProcess.PollingIntervalSeconds); + yield return new WaitForSecondsRealtime(preProcess.PollingIntervalSeconds); while (_remoteSessionsProcesses.TryGetValue(processGuid, out var process)) { // Check if we should continue the polling @@ -418,13 +428,21 @@ protected IEnumerator ContinualPollingAction(Guid processGuid) yield break; } + // If the process was cancelled while the HTTP poll was in-flight, skip + // the status-update callback and let the next while-iteration handle it + // via the ShouldCancel check at the top of the loop. + if (processAfterStatusCheck.ShouldCancel) + { + continue; + } + if (!startSessionResponse.success) { if (startSessionResponse.statusCode >= 500 && startSessionResponse.statusCode <= 599 && processAfterStatusCheck.Retries <= _leasingProcessPollingRetryLimit) { // Recoverable error processAfterStatusCheck.Retries++; - yield return new WaitForSeconds(processAfterStatusCheck.PollingIntervalSeconds); + yield return new WaitForSecondsRealtime(processAfterStatusCheck.PollingIntervalSeconds); continue; } @@ -451,7 +469,7 @@ protected IEnumerator ContinualPollingAction(Guid processGuid) processAfterStatusCheck.LastUpdatedStatus = pollingResponse.lease_status; // Sleep for a bit before checking again - yield return new WaitForSeconds(processAfterStatusCheck.PollingIntervalSeconds); + yield return new WaitForSecondsRealtime(processAfterStatusCheck.PollingIntervalSeconds); } } @@ -549,29 +567,18 @@ private void LeaseRemoteSession( Action onComplete, string providerUrlParam = null) { + string[] providers = string.IsNullOrEmpty(providerUrlParam) ? null : new[] { providerUrlParam }; LootLockerLeaseRemoteSessionRequest leaseRemoteSessionRequest = - new LootLockerLeaseRemoteSessionRequest(titleId, environmentId); + new LootLockerLeaseRemoteSessionRequest(titleId, environmentId, providers); EndPointClass endPoint = leaseIntent == LootLockerRemoteSessionLeaseIntent.login ? LootLockerEndPoints.leaseRemoteSession : LootLockerEndPoints.leaseRemoteSessionForLinking; + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint, endPoint.httpMethod, LootLockerJson.SerializeObject(leaseRemoteSessionRequest), (serverResponse) => { var response = LootLockerResponse.Deserialize(serverResponse); - if (!string.IsNullOrEmpty(providerUrlParam) && response != null) - { - if (response.redirect_url != null) - { - string separator = response.redirect_url.Contains("?") ? "&" : "?"; - response.redirect_url = response.redirect_url + separator + "provider=" + providerUrlParam; - } - if (response.display_url != null) - { - string separator = response.display_url.Contains("?") ? "&" : "?"; - response.display_url = response.display_url + separator + "provider=" + providerUrlParam; - } - } onComplete?.Invoke(response); }, leaseIntent == LootLockerRemoteSessionLeaseIntent.link); diff --git a/Runtime/Game/Requests/WhiteLabelRequest.cs b/Runtime/Game/Requests/WhiteLabelRequest.cs index 68a2d6b13..47ceff158 100644 --- a/Runtime/Game/Requests/WhiteLabelRequest.cs +++ b/Runtime/Game/Requests/WhiteLabelRequest.cs @@ -4,6 +4,31 @@ namespace LootLocker.Requests { + public class LootLockerWhiteLabelCustomFieldValue + { + public string metadata_key { get; set; } + /// + /// The value as a raw JSON primitive matching the field's configured type: + /// - text/select/date: a JSON string (e.g. "2000-01-15") + /// - number: a JSON number (e.g. 42) + /// - checkbox: a JSON boolean (e.g. true) + /// Pass the value as its native C# type (string, int, bool, etc.) — + /// the serializer will emit the correct JSON primitive automatically. + /// + public object value_json { get; set; } + } + + public class LootLockerWhiteLabelCustomField + { + public string question_text { get; set; } + public string metadata_key { get; set; } + public string field_type { get; set; } + public string @params { get; set; } + public bool required { get; set; } + public bool sensitive { get; set; } + public int sort_order { get; set; } + } + public class LootLockerWhiteLabelUserRequest { public string email { get; set; } @@ -11,6 +36,11 @@ public class LootLockerWhiteLabelUserRequest public bool remember { get; set; } } + public class LootLockerWhiteLabelSignUpRequest : LootLockerWhiteLabelUserRequest + { + public LootLockerWhiteLabelCustomFieldValue[] custom_fields { get; set; } + } + public class LootLockerWhiteLabelVerifySessionRequest { public string email { get; set; } @@ -41,6 +71,12 @@ public class LootLockerWhiteLabelLoginResponse : LootLockerWhiteLabelSignupRespo public string SessionToken { get; set; } } + [Serializable] + public class LootLockerWhiteLabelSignUpFieldsResponse : LootLockerResponse + { + public LootLockerWhiteLabelCustomField[] fields { get; set; } + } + [Serializable] public class LootLockerWhiteLabelLoginAndStartSessionResponse : LootLockerResponse { @@ -120,7 +156,7 @@ public static void WhiteLabelVerifySession(LootLockerWhiteLabelVerifySessionRequ LootLockerServerRequest.CallAPI(null, endPoint.endPoint, endPoint.httpMethod, json, (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }, useAuthToken: false, callerRole: endPoint.callerRole, additionalHeaders: GetDomainHeaders()); } - public static void WhiteLabelSignUp(LootLockerWhiteLabelUserRequest input, Action onComplete) + public static void WhiteLabelSignUp(LootLockerWhiteLabelSignUpRequest input, Action onComplete) { EndPointClass endPoint = LootLockerEndPoints.whiteLabelSignUp; @@ -191,6 +227,21 @@ public static void WhiteLabelRequestAccountVerification(string email, Action onComplete) + { + EndPointClass endPoint = LootLockerEndPoints.whiteLabelSignUpFields; + + if (LootLockerConfig.current.domainKey.Length == 0) + { + LootLockerLogger.Log("Domain key must be set in settings", LootLockerLogger.LogLevel.Error); + onComplete?.Invoke(LootLockerResponseFactory.ClientError("Domain key must be set in settings", null)); + + return; + } + + LootLockerServerRequest.CallAPI(null, endPoint.endPoint, endPoint.httpMethod, null, (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }, useAuthToken: false, callerRole: endPoint.callerRole, additionalHeaders: GetDomainHeaders()); + } + public static Dictionary GetDomainHeaders() { Dictionary headers = new Dictionary(); diff --git a/Runtime/Game/Resources/LootLockerConfig.cs b/Runtime/Game/Resources/LootLockerConfig.cs index 1e533dce0..ee177dd45 100644 --- a/Runtime/Game/Resources/LootLockerConfig.cs +++ b/Runtime/Game/Resources/LootLockerConfig.cs @@ -21,6 +21,9 @@ public enum LootLockerMultiUserSessionMode /// or on existing installs the first time the Unity Editor loads this project. /// This value should never be set manually — it exists solely for pre-migration compatibility. /// +#if UNITY_2020_1_OR_NEWER + [InspectorName(null)] +#endif NotSet = 0, /// diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationTitleConfig.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationTitleConfig.cs index 6d5dc6d66..751990bd3 100644 --- a/Tests/LootLockerTestUtils/LootLockerTestConfigurationTitleConfig.cs +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationTitleConfig.cs @@ -8,7 +8,8 @@ public static class LootLockerTestConfigurationTitleConfig public enum TitleConfigKeys { - global_player_presence + global_player_presence, + white_label_custom_signup_fields } public class PresenceTitleConfigRequest @@ -17,6 +18,21 @@ public class PresenceTitleConfigRequest public bool advanced_mode { get; set; } } + public class WhiteLabelCustomSignUpFieldDefinition + { + public string question_text { get; set; } + public string metadata_key { get; set; } + public string field_type { get; set; } + public bool required { get; set; } + public bool sensitive { get; set; } + public int sort_order { get; set; } + } + + public class WhiteLabelCustomSignUpFieldsConfigRequest + { + public WhiteLabelCustomSignUpFieldDefinition[] fields { get; set; } + } + public static void GetGameConfig(TitleConfigKeys ConfigKey, Action onComplete) { if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) @@ -52,5 +68,29 @@ public static void UpdateGameConfig(TitleConfigKeys ConfigKey, bool Enabled, boo onComplete?.Invoke(serverResponse); }, true); } + + public static void SetCustomSignUpFields(WhiteLabelCustomSignUpFieldDefinition[] fields, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(false, "Not logged in"); + return; + } + + var request = new WhiteLabelCustomSignUpFieldsConfigRequest + { + fields = fields + }; + string json = LootLockerJson.SerializeObject(request); + LootLockerTestGameAdmin.SetGameConfig("white_label_custom_signup_fields", json, response => + { + if (response == null) + { + onComplete?.Invoke(false, "Null response from SetGameConfig"); + return; + } + onComplete?.Invoke(response.success, response.errorData?.message); + }); + } } } diff --git a/Tests/LootLockerTests/PlayMode/MultiUserTests.cs b/Tests/LootLockerTests/PlayMode/MultiUserTests.cs index 5f2998649..3e429091f 100644 --- a/Tests/LootLockerTests/PlayMode/MultiUserTests.cs +++ b/Tests/LootLockerTests/PlayMode/MultiUserTests.cs @@ -15,6 +15,7 @@ namespace LootLockerTests.PlayMode public class InMemoryTestStateWriter : ILootLockerStateWriter { private Dictionary _storage = new Dictionary(); + private Dictionary _intStorage = new Dictionary(); public void DeleteKey(string key) { @@ -22,6 +23,10 @@ public void DeleteKey(string key) { _storage.Remove(key); } + if (_intStorage.ContainsKey(key)) + { + _intStorage.Remove(key); + } } public string GetString(string key, string defaultValue = "") @@ -33,14 +38,28 @@ public string GetString(string key, string defaultValue = "") return defaultValue; } + public int GetInt(string key, int defaultValue = 0) + { + if (_intStorage.ContainsKey(key)) + { + return _intStorage[key]; + } + return defaultValue; + } + public void SetString(string key, string value) { _storage[key] = value; } + public void SetInt(string key, int value) + { + _intStorage[key] = value; + } + public bool HasKey(string key) { - return _storage.ContainsKey(key); + return _storage.ContainsKey(key) || _intStorage.ContainsKey(key); } } diff --git a/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs b/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs index 24b94a55f..af193ca09 100644 --- a/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs +++ b/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs @@ -6,7 +6,6 @@ using System; using System.Collections; using System.IO; -using System.Net; using UnityEngine; using UnityEngine.TestTools; @@ -78,10 +77,7 @@ public IEnumerator Setup() }); yield return new WaitUntil(() => guestLoginCompleted); - - Debug.Log($"##### Start of {this.GetType().Name} test no.{TestCounter} test case #####"); - } [UnityTearDown] @@ -110,17 +106,28 @@ public IEnumerator TearDown() Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); } + // --- Helpers --- - [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + private string CreateTempFile(string content, string suffix = ".txt") + { + string path = Application.temporaryCachePath + $"/{this.GetType().Name}{TestCounter}-{Guid.NewGuid()}{suffix}"; + using (TextWriter writer = new StreamWriter(path)) + { + writer.WriteLine(content); + } + return path; + } + + // ================================================================ + // Phase 1: Core Upload & Key Tests + // ================================================================ + + [UnityTest, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] public IEnumerator PlayerFiles_UploadSimplePublicFile_Succeeds() { Assert.IsFalse(SetupFailed, "Failed to setup game"); // Given - string path = Application.temporaryCachePath + "/PlayerFileCanBeCreatedWithPathUpdatedAndThenDeleted-creation.txt"; - string content = "First added line"; - TextWriter writer = new StreamWriter(path); - writer.WriteLine(content); - writer.Close(); + string path = CreateTempFile("First added line"); // When LootLockerPlayerFile actualResponse = new LootLockerPlayerFile(); @@ -132,7 +139,6 @@ public IEnumerator PlayerFiles_UploadSimplePublicFile_Succeeds() playerFileUploadCompleted = true; }); - // Wait for response yield return new WaitUntil(() => playerFileUploadCompleted); // Then @@ -140,5 +146,842 @@ public IEnumerator PlayerFiles_UploadSimplePublicFile_Succeeds() Assert.Greater(actualResponse.size, 0, "File Size was 0"); Assert.AreEqual(setToPublic, actualResponse.is_public, "File does not have the same public setting"); } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_UploadWithKey_ReturnsKeyInResponse() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string path = CreateTempFile("Content with key"); + string fileKey = "test-key-" + TestCounter; + + // When + LootLockerPlayerFile actualResponse = new LootLockerPlayerFile(); + bool completed = false; + LootLockerSDKManager.UploadPlayerFileByKey(path, "test", true, fileKey, fileResponse => + { + actualResponse = fileResponse; + completed = true; + }); + + yield return new WaitUntil(() => completed); + + // Then + Assert.IsTrue(actualResponse.success, "File upload with key failed"); + Assert.AreEqual(fileKey, actualResponse.key, "Key in response does not match"); + Assert.Greater(actualResponse.size, 0, "File Size was 0"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_UploadWithSameKeyTwice_UpdatesExistingFile() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string fileKey = "upsert-key-" + TestCounter; + string pathA = CreateTempFile("Original content"); + string pathB = CreateTempFile("Updated content that is longer"); + + // When — first upload + LootLockerPlayerFile firstResponse = new LootLockerPlayerFile(); + bool firstDone = false; + LootLockerSDKManager.UploadPlayerFileByKey(pathA, "test", true, fileKey, fileResponse => + { + firstResponse = fileResponse; + firstDone = true; + }); + yield return new WaitUntil(() => firstDone); + Assert.IsTrue(firstResponse.success, "First upload failed"); + + // When — second upload with same key + LootLockerPlayerFile secondResponse = new LootLockerPlayerFile(); + bool secondDone = false; + LootLockerSDKManager.UploadPlayerFileByKey(pathB, "test", true, fileKey, fileResponse => + { + secondResponse = fileResponse; + secondDone = true; + }); + yield return new WaitUntil(() => secondDone); + + // Then + Assert.IsTrue(secondResponse.success, "Second upload (upsert) failed"); + Assert.AreEqual(firstResponse.id, secondResponse.id, "File ID should be the same after upsert"); + Assert.AreNotEqual(firstResponse.size, secondResponse.size, "File size should differ after upsert with different content"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_UploadWithoutKey_ReturnsEmptyKey() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string path = CreateTempFile("No key content"); + + // When + LootLockerPlayerFile actualResponse = new LootLockerPlayerFile(); + bool completed = false; + LootLockerSDKManager.UploadPlayerFile(path, "test", true, fileResponse => + { + actualResponse = fileResponse; + completed = true; + }); + + yield return new WaitUntil(() => completed); + + // Then + Assert.IsTrue(actualResponse.success, "File upload without key failed"); + Assert.IsTrue(string.IsNullOrEmpty(actualResponse.key), "Key should be null or empty when not provided"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_UploadPrivateFile_Succeeds() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string path = CreateTempFile("Private content"); + + // When + LootLockerPlayerFile actualResponse = new LootLockerPlayerFile(); + bool completed = false; + LootLockerSDKManager.UploadPlayerFile(path, "test", false, fileResponse => + { + actualResponse = fileResponse; + completed = true; + }); + + yield return new WaitUntil(() => completed); + + // Then + Assert.IsTrue(actualResponse.success, "Private file upload failed"); + Assert.IsFalse(actualResponse.is_public, "File should not be public"); + } + + // ================================================================ + // Phase 2: Key-Based Lookup & Delete + // ================================================================ + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_GetFileByKey_ReturnsCorrectFile() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string fileKey = "lookup-key-" + TestCounter; + string path = CreateTempFile("Lookup by key content"); + LootLockerPlayerFile uploadedFile = new LootLockerPlayerFile(); + bool uploadDone = false; + LootLockerSDKManager.UploadPlayerFileByKey(path, "test", true, fileKey, fileResponse => + { + uploadedFile = fileResponse; + uploadDone = true; + }); + yield return new WaitUntil(() => uploadDone); + Assert.IsTrue(uploadedFile.success, "Upload for lookup test failed"); + + // When + LootLockerPlayerFile fetchedFile = new LootLockerPlayerFile(); + bool fetchDone = false; + LootLockerSDKManager.GetPlayerFileByKey(fileKey, fileResponse => + { + fetchedFile = fileResponse; + fetchDone = true; + }); + yield return new WaitUntil(() => fetchDone); + + // Then + Assert.IsTrue(fetchedFile.success, "GetPlayerFileByKey failed"); + Assert.AreEqual(uploadedFile.id, fetchedFile.id, "File ID should match"); + Assert.AreEqual(fileKey, fetchedFile.key, "Key should match"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_GetFileByKey_NonExistentKey_Fails() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // When + bool preLogErrorsAsWarningsSetting = LootLockerConfig.current.logErrorsAsWarnings; + LootLockerConfig.current.logErrorsAsWarnings = true; // Suppress error logs for expected failure + LootLockerPlayerFile fetchedFile = new LootLockerPlayerFile(); + bool fetchDone = false; + LootLockerSDKManager.GetPlayerFileByKey("nonexistent-key-" + TestCounter, fileResponse => + { + fetchedFile = fileResponse; + fetchDone = true; + }); + yield return new WaitUntil(() => fetchDone); + LootLockerConfig.current.logErrorsAsWarnings = preLogErrorsAsWarningsSetting; + + // Then + Assert.IsFalse(fetchedFile.success, "GetPlayerFileByKey should fail for non-existent key"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_DeleteFileByKey_RemovesFile() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string fileKey = "delete-key-" + TestCounter; + string path = CreateTempFile("To be deleted by key"); + LootLockerPlayerFile uploadedFile = new LootLockerPlayerFile(); + bool uploadDone = false; + LootLockerSDKManager.UploadPlayerFileByKey(path, "test", true, fileKey, fileResponse => + { + uploadedFile = fileResponse; + uploadDone = true; + }); + yield return new WaitUntil(() => uploadDone); + Assert.IsTrue(uploadedFile.success, "Upload for delete-by-key test failed"); + + // When — delete by key + LootLockerResponse deleteResponse = new LootLockerResponse(); + bool deleteDone = false; + LootLockerSDKManager.DeletePlayerFileByKey(fileKey, response => + { + deleteResponse = response; + deleteDone = true; + }); + yield return new WaitUntil(() => deleteDone); + + // Then — verify deletion + Assert.IsTrue(deleteResponse.success, "DeletePlayerFileByKey failed"); + + bool preLogErrorsAsWarningsSetting = LootLockerConfig.current.logErrorsAsWarnings; + LootLockerConfig.current.logErrorsAsWarnings = true; // Suppress error logs for expected failure + LootLockerPlayerFile fetchedFileResponse = new LootLockerPlayerFile(); + bool fetchDone = false; + LootLockerSDKManager.GetPlayerFileByKey(fileKey, fileResponse => + { + fetchedFileResponse = fileResponse; + fetchDone = true; + }); + yield return new WaitUntil(() => fetchDone); + Assert.IsFalse(fetchedFileResponse.success, "File should no longer exist after deletion by key"); + LootLockerConfig.current.logErrorsAsWarnings = preLogErrorsAsWarningsSetting; + } + + // ================================================================ + // Phase 3: Revisions by ID + // ================================================================ + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_UpdateFile_CreatesNewRevision() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string pathA = CreateTempFile("Original revision content"); + string pathB = CreateTempFile("Updated revision content"); + + LootLockerPlayerFile uploadedFile = new LootLockerPlayerFile(); + bool uploadDone = false; + LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, fileResponse => + { + uploadedFile = fileResponse; + uploadDone = true; + }); + yield return new WaitUntil(() => uploadDone); + Assert.IsTrue(uploadedFile.success, "Initial upload failed"); + + // When — update the file + LootLockerPlayerFile updatedFile = new LootLockerPlayerFile(); + bool updateDone = false; + LootLockerSDKManager.UpdatePlayerFile(uploadedFile.id, pathB, fileResponse => + { + updatedFile = fileResponse; + updateDone = true; + }); + yield return new WaitUntil(() => updateDone); + Assert.IsTrue(updatedFile.success, "Update failed"); + + // Then — list revisions + LootLockerPlayerFileRevisionsResponse revisionsResponse = new LootLockerPlayerFileRevisionsResponse(); + bool revisionsDone = false; + LootLockerSDKManager.GetPlayerFileRevisions(uploadedFile.id, response => + { + revisionsResponse = response; + revisionsDone = true; + }); + yield return new WaitUntil(() => revisionsDone); + + Assert.IsTrue(revisionsResponse.success, "List revisions failed"); + Assert.GreaterOrEqual(revisionsResponse.revisions.Length, 2, "Should have at least 2 revisions after update"); + Assert.IsNotNull(revisionsResponse.current_revision_id, "Current revision ID should be set"); + // Revisions are returned newest-first (created_at DESC), so the current revision is the first element. + Assert.AreEqual(revisionsResponse.current_revision_id, revisionsResponse.revisions[0].id, + "Current revision should be the latest"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_GetFileRevision_ReturnsSpecificRevision() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string pathA = CreateTempFile("First revision"); + string pathB = CreateTempFile("Second revision"); + + LootLockerPlayerFile uploadedFile = new LootLockerPlayerFile(); + bool uploadDone = false; + LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, fileResponse => + { + uploadedFile = fileResponse; + uploadDone = true; + }); + yield return new WaitUntil(() => uploadDone); + Assert.IsTrue(uploadedFile.success, "Initial upload failed"); + + // Update to create a second revision + LootLockerPlayerFile updateResponse = new LootLockerPlayerFile(); + bool updateDone = false; + LootLockerSDKManager.UpdatePlayerFile(uploadedFile.id, pathB, response => + { + updateResponse = response; + updateDone = true; + }); + yield return new WaitUntil(() => updateDone); + Assert.IsTrue(updateResponse.success, "Update to create revision 2 failed"); + + // Get revision list to find the first revision ID + LootLockerPlayerFileRevisionsResponse revisionsResponse = new LootLockerPlayerFileRevisionsResponse(); + bool revisionsDone = false; + LootLockerSDKManager.GetPlayerFileRevisions(uploadedFile.id, response => + { + revisionsResponse = response; + revisionsDone = true; + }); + yield return new WaitUntil(() => revisionsDone); + Assert.IsTrue(revisionsResponse.success, "List revisions failed"); + Assert.GreaterOrEqual(revisionsResponse.revisions.Length, 2, "Should have at least 2 revisions"); + + // When — get a specific revision (revisions are newest-first, so index 0 is the current one) + string firstRevisionId = revisionsResponse.revisions[0].id; + LootLockerPlayerFileContent revisionContent = new LootLockerPlayerFileContent(); + bool getRevisionDone = false; + LootLockerSDKManager.GetPlayerFileRevision(uploadedFile.id, firstRevisionId, response => + { + revisionContent = response; + getRevisionDone = true; + }); + yield return new WaitUntil(() => getRevisionDone); + + // Then + Assert.IsTrue(revisionContent.success, "GetPlayerFileRevision failed"); + Assert.AreEqual(firstRevisionId, revisionContent.id, "Revision ID should match"); + Assert.Greater(revisionContent.size, 0, "Revision size should be > 0"); + // Note: the URL is only populated when a CDN/file storage backend is configured + // (e.g. production). In local CI it may be empty, so we don't assert on it here. + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_PromoteFileRevision_RestoresOldRevision() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string pathA = CreateTempFile("First revision content"); + string pathB = CreateTempFile("Second revision content"); + + LootLockerPlayerFile uploadedFile = new LootLockerPlayerFile(); + bool uploadDone = false; + LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, fileResponse => + { + uploadedFile = fileResponse; + uploadDone = true; + }); + yield return new WaitUntil(() => uploadDone); + Assert.IsTrue(uploadedFile.success, "Initial upload failed"); + + // Update to create revision 2 + LootLockerPlayerFile updateResponse = new LootLockerPlayerFile(); + bool updateDone = false; + LootLockerSDKManager.UpdatePlayerFile(uploadedFile.id, pathB, response => + { + updateResponse = response; + updateDone = true; + }); + yield return new WaitUntil(() => updateDone); + Assert.IsTrue(updateResponse.success, "Update to create revision 2 failed"); + + // Get revision list to find the first revision ID + LootLockerPlayerFileRevisionsResponse revisionsResponse = new LootLockerPlayerFileRevisionsResponse(); + bool revisionsDone = false; + LootLockerSDKManager.GetPlayerFileRevisions(uploadedFile.id, response => + { + revisionsResponse = response; + revisionsDone = true; + }); + yield return new WaitUntil(() => revisionsDone); + Assert.IsTrue(revisionsResponse.success, "List revisions failed"); + // Revisions are returned newest-first (created_at DESC), so the oldest + // (original) revision is the last element. + string firstRevisionId = revisionsResponse.revisions[revisionsResponse.revisions.Length - 1].id; + + // When — promote the first revision back to current + LootLockerResponse promoteResponse = new LootLockerResponse(); + bool promoteDone = false; + LootLockerSDKManager.PromotePlayerFileRevision(uploadedFile.id, firstRevisionId, response => + { + promoteResponse = response; + promoteDone = true; + }); + yield return new WaitUntil(() => promoteDone); + + // Then + Assert.IsTrue(promoteResponse.success, "Promote revision failed"); + + // Verify the current revision changed + LootLockerPlayerFile refreshedFile = new LootLockerPlayerFile(); + bool refreshDone = false; + LootLockerSDKManager.GetPlayerFile(uploadedFile.id, fileResponse => + { + refreshedFile = fileResponse; + refreshDone = true; + }); + yield return new WaitUntil(() => refreshDone); + Assert.IsTrue(refreshedFile.success, "GetPlayerFile after promote failed"); + Assert.AreEqual(firstRevisionId, refreshedFile.revision_id, "Current revision should be the promoted one"); + } + + // ================================================================ + // Phase 4: Revisions by Key + // ================================================================ + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_GetFileRevisionsByKey_ReturnsRevisions() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string fileKey = "rev-key-" + TestCounter; + string pathA = CreateTempFile("Revision A by key"); + string pathB = CreateTempFile("Revision B by key"); + + // Upload with key (creates revision 1) + LootLockerPlayerFile firstUpload = new LootLockerPlayerFile(); + bool firstDone = false; + LootLockerSDKManager.UploadPlayerFileByKey(pathA, "test", true, fileKey, response => + { + firstUpload = response; + firstDone = true; + }); + yield return new WaitUntil(() => firstDone); + Assert.IsTrue(firstUpload.success, "First upload for revisions by key test failed"); + + // Upsert with same key (creates revision 2) + LootLockerPlayerFile secondUpload = new LootLockerPlayerFile(); + bool secondDone = false; + LootLockerSDKManager.UploadPlayerFileByKey(pathB, "test", true, fileKey, response => + { + secondUpload = response; + secondDone = true; + }); + yield return new WaitUntil(() => secondDone); + Assert.IsTrue(secondUpload.success, "Second upload for revisions by key test failed"); + + // When + LootLockerPlayerFileRevisionsResponse revisionsResponse = new LootLockerPlayerFileRevisionsResponse(); + bool revisionsDone = false; + LootLockerSDKManager.GetPlayerFileRevisionsByKey(fileKey, response => + { + revisionsResponse = response; + revisionsDone = true; + }); + yield return new WaitUntil(() => revisionsDone); + + // Then + Assert.IsTrue(revisionsResponse.success, "GetPlayerFileRevisionsByKey failed"); + Assert.GreaterOrEqual(revisionsResponse.revisions.Length, 2, "Should have at least 2 revisions"); + Assert.AreEqual(fileKey, revisionsResponse.file.key, "File metadata key should match"); + Assert.IsNotNull(revisionsResponse.current_revision_id, "Current revision ID should be set"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_GetFileRevisionByKey_ReturnsSpecificRevision() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string fileKey = "rev-get-key-" + TestCounter; + string pathA = CreateTempFile("First revision by key"); + string pathB = CreateTempFile("Second revision by key"); + + LootLockerPlayerFile firstUpload = new LootLockerPlayerFile(); + bool firstDone = false; + LootLockerSDKManager.UploadPlayerFileByKey(pathA, "test", true, fileKey, response => + { + firstUpload = response; + firstDone = true; + }); + yield return new WaitUntil(() => firstDone); + Assert.IsTrue(firstUpload.success, "First upload for get revision by key test failed"); + + LootLockerPlayerFile secondUpload = new LootLockerPlayerFile(); + bool secondDone = false; + LootLockerSDKManager.UploadPlayerFileByKey(pathB, "test", true, fileKey, response => + { + secondUpload = response; + secondDone = true; + }); + yield return new WaitUntil(() => secondDone); + Assert.IsTrue(secondUpload.success, "Second upload for get revision by key test failed"); + + // Get revision list to find a revision ID + LootLockerPlayerFileRevisionsResponse revisionsResponse = new LootLockerPlayerFileRevisionsResponse(); + bool revisionsDone = false; + LootLockerSDKManager.GetPlayerFileRevisionsByKey(fileKey, response => + { + revisionsResponse = response; + revisionsDone = true; + }); + yield return new WaitUntil(() => revisionsDone); + Assert.IsTrue(revisionsResponse.success, "List revisions by key failed"); + string firstRevisionId = revisionsResponse.revisions[0].id; + + // When + LootLockerPlayerFileContent revisionContent = new LootLockerPlayerFileContent(); + bool getRevisionDone = false; + LootLockerSDKManager.GetPlayerFileRevisionByKey(fileKey, firstRevisionId, response => + { + revisionContent = response; + getRevisionDone = true; + }); + yield return new WaitUntil(() => getRevisionDone); + + // Then + Assert.IsTrue(revisionContent.success, "GetPlayerFileRevisionByKey failed"); + Assert.AreEqual(firstRevisionId, revisionContent.id, "Revision ID should match"); + Assert.Greater(revisionContent.size, 0, "Revision size should be > 0"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_PromoteFileRevisionByKey_PromotesRevision() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string fileKey = "promote-key-" + TestCounter; + string pathA = CreateTempFile("First revision for promote by key"); + string pathB = CreateTempFile("Second revision for promote by key"); + + LootLockerPlayerFile firstUpload = new LootLockerPlayerFile(); + bool firstDone = false; + LootLockerSDKManager.UploadPlayerFileByKey(pathA, "test", true, fileKey, response => + { + firstUpload = response; + firstDone = true; + }); + yield return new WaitUntil(() => firstDone); + Assert.IsTrue(firstUpload.success, "First upload for promote by key test failed"); + + LootLockerPlayerFile secondUpload = new LootLockerPlayerFile(); + bool secondDone = false; + LootLockerSDKManager.UploadPlayerFileByKey(pathB, "test", true, fileKey, response => + { + secondUpload = response; + secondDone = true; + }); + yield return new WaitUntil(() => secondDone); + Assert.IsTrue(secondUpload.success, "Second upload for promote by key test failed"); + + // Get revision list to find the first revision ID + LootLockerPlayerFileRevisionsResponse revisionsResponse = new LootLockerPlayerFileRevisionsResponse(); + bool revisionsDone = false; + LootLockerSDKManager.GetPlayerFileRevisionsByKey(fileKey, response => + { + revisionsResponse = response; + revisionsDone = true; + }); + yield return new WaitUntil(() => revisionsDone); + Assert.IsTrue(revisionsResponse.success, "List revisions by key failed"); + // Revisions are returned newest-first (created_at DESC), so the oldest + // (original) revision is the last element. + string firstRevisionId = revisionsResponse.revisions[revisionsResponse.revisions.Length - 1].id; + + // When — promote the first revision + LootLockerResponse promoteResponse = new LootLockerResponse(); + bool promoteDone = false; + LootLockerSDKManager.PromotePlayerFileRevisionByKey(fileKey, firstRevisionId, response => + { + promoteResponse = response; + promoteDone = true; + }); + yield return new WaitUntil(() => promoteDone); + + // Then + Assert.IsTrue(promoteResponse.success, "PromotePlayerFileRevisionByKey failed"); + + // Verify the current revision changed + LootLockerPlayerFile refreshedFile = new LootLockerPlayerFile(); + bool refreshDone = false; + LootLockerSDKManager.GetPlayerFileByKey(fileKey, fileResponse => + { + refreshedFile = fileResponse; + refreshDone = true; + }); + yield return new WaitUntil(() => refreshDone); + Assert.IsTrue(refreshedFile.success, "GetPlayerFileByKey after promote failed"); + Assert.AreEqual(firstRevisionId, refreshedFile.revision_id, "Current revision should be the promoted one"); + } + + // ================================================================ + // Phase 5: Existing Operations Backfill + // ================================================================ + + [UnityTest, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public IEnumerator PlayerFiles_GetPlayerFile_ReturnsCorrectFile() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string path = CreateTempFile("Get by ID content"); + LootLockerPlayerFile uploadedFile = new LootLockerPlayerFile(); + bool uploadDone = false; + LootLockerSDKManager.UploadPlayerFile(path, "test", true, fileResponse => + { + uploadedFile = fileResponse; + uploadDone = true; + }); + yield return new WaitUntil(() => uploadDone); + Assert.IsTrue(uploadedFile.success, "Upload for get test failed"); + + // When + LootLockerPlayerFile fetchedFile = new LootLockerPlayerFile(); + bool fetchDone = false; + LootLockerSDKManager.GetPlayerFile(uploadedFile.id, fileResponse => + { + fetchedFile = fileResponse; + fetchDone = true; + }); + yield return new WaitUntil(() => fetchDone); + + // Then + Assert.IsTrue(fetchedFile.success, "GetPlayerFile failed"); + Assert.AreEqual(uploadedFile.id, fetchedFile.id, "File ID should match"); + Assert.AreEqual(uploadedFile.name, fetchedFile.name, "File name should match"); + Assert.Greater(fetchedFile.size, 0, "File size should be > 0"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_GetAllPlayerFiles_ReturnsFiles() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given — upload two files + string pathA = CreateTempFile("First list file"); + string pathB = CreateTempFile("Second list file"); + + LootLockerPlayerFile uploadA = new LootLockerPlayerFile(); + bool uploadADone = false; + LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, response => + { + uploadA = response; + uploadADone = true; + }); + yield return new WaitUntil(() => uploadADone); + Assert.IsTrue(uploadA.success, "First upload for list test failed"); + + LootLockerPlayerFile uploadB = new LootLockerPlayerFile(); + bool uploadBDone = false; + LootLockerSDKManager.UploadPlayerFile(pathB, "test", true, response => + { + uploadB = response; + uploadBDone = true; + }); + yield return new WaitUntil(() => uploadBDone); + Assert.IsTrue(uploadB.success, "Second upload for list test failed"); + + // When + LootLockerPlayerFilesResponse listResponse = new LootLockerPlayerFilesResponse(); + bool listDone = false; + LootLockerSDKManager.GetAllPlayerFiles(response => + { + listResponse = response; + listDone = true; + }); + yield return new WaitUntil(() => listDone); + + // Then + Assert.IsTrue(listResponse.success, "GetAllPlayerFiles failed"); + Assert.GreaterOrEqual(listResponse.items.Length, 2, "Should have at least 2 files"); + foreach (var item in listResponse.items) + { + Assert.Greater(item.id, 0, "Each file should have a positive ID"); + Assert.IsFalse(string.IsNullOrEmpty(item.name), "Each file should have a name"); + // Note: the URL is only populated when a CDN/file storage backend is configured + // (e.g. production). In local CI it may be empty, so we don't assert on it here. + } + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_DeletePlayerFile_RemovesFile() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string path = CreateTempFile("To be deleted"); + LootLockerPlayerFile uploadedFile = new LootLockerPlayerFile(); + bool uploadDone = false; + LootLockerSDKManager.UploadPlayerFile(path, "test", true, fileResponse => + { + uploadedFile = fileResponse; + uploadDone = true; + }); + yield return new WaitUntil(() => uploadDone); + Assert.IsTrue(uploadedFile.success, "Upload for delete test failed"); + + // When + LootLockerResponse deleteResponse = new LootLockerResponse(); + bool deleteDone = false; + LootLockerSDKManager.DeletePlayerFile(uploadedFile.id, response => + { + deleteResponse = response; + deleteDone = true; + }); + yield return new WaitUntil(() => deleteDone); + + // Then + Assert.IsTrue(deleteResponse.success, "DeletePlayerFile failed"); + + // Verify deletion + bool preLogErrorsAsWarningsSetting = LootLockerConfig.current.logErrorsAsWarnings; + LootLockerConfig.current.logErrorsAsWarnings = true; // Suppress error logs for expected failure + LootLockerPlayerFile fetchedFile = new LootLockerPlayerFile(); + bool fetchDone = false; + LootLockerSDKManager.GetPlayerFile(uploadedFile.id, fileResponse => + { + fetchedFile = fileResponse; + fetchDone = true; + }); + yield return new WaitUntil(() => fetchDone); + Assert.IsFalse(fetchedFile.success, "File should no longer exist after deletion"); + LootLockerConfig.current.logErrorsAsWarnings = preLogErrorsAsWarningsSetting; + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_UpdatePlayerFile_ChangesContent() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string pathA = CreateTempFile("Original content for update"); + string pathB = CreateTempFile("Updated content for update"); + + LootLockerPlayerFile uploadedFile = new LootLockerPlayerFile(); + bool uploadDone = false; + LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, fileResponse => + { + uploadedFile = fileResponse; + uploadDone = true; + }); + yield return new WaitUntil(() => uploadDone); + Assert.IsTrue(uploadedFile.success, "Initial upload failed"); + int originalSize = uploadedFile.size; + string originalRevisionId = uploadedFile.revision_id; + + // When + LootLockerPlayerFile updatedFile = new LootLockerPlayerFile(); + bool updateDone = false; + LootLockerSDKManager.UpdatePlayerFile(uploadedFile.id, pathB, fileResponse => + { + updatedFile = fileResponse; + updateDone = true; + }); + yield return new WaitUntil(() => updateDone); + + // Then + Assert.IsTrue(updatedFile.success, "UpdatePlayerFile failed"); + Assert.AreNotEqual(originalRevisionId, updatedFile.revision_id, "Revision ID should change after update"); + Assert.AreNotEqual(originalSize, updatedFile.size, "File size should change after update with different content"); + } + + // ================================================================ + // Phase 6: Response Field Verification + // ================================================================ + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_ListResponse_IncludesKeyField() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string fileKey = "list-key-" + TestCounter; + string path = CreateTempFile("List response key check"); + + LootLockerPlayerFile uploadedFile = new LootLockerPlayerFile(); + bool uploadDone = false; + LootLockerSDKManager.UploadPlayerFileByKey(path, "test", true, fileKey, response => + { + uploadedFile = response; + uploadDone = true; + }); + yield return new WaitUntil(() => uploadDone); + Assert.IsTrue(uploadedFile.success, "Upload for list response key test failed"); + + // When + LootLockerPlayerFilesResponse listResponse = new LootLockerPlayerFilesResponse(); + bool listDone = false; + LootLockerSDKManager.GetAllPlayerFiles(response => + { + listResponse = response; + listDone = true; + }); + yield return new WaitUntil(() => listDone); + + // Then + Assert.IsTrue(listResponse.success, "GetAllPlayerFiles failed"); + bool foundKey = false; + foreach (var item in listResponse.items) + { + if (item.key == fileKey) + { + foundKey = true; + break; + } + } + Assert.IsTrue(foundKey, "List response should contain an item with the uploaded key"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_RevisionsResponse_FileMetadataHasKey() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string fileKey = "meta-key-" + TestCounter; + string pathA = CreateTempFile("Metadata key revision A"); + string pathB = CreateTempFile("Metadata key revision B"); + + LootLockerPlayerFile firstUpload = new LootLockerPlayerFile(); + bool firstDone = false; + LootLockerSDKManager.UploadPlayerFileByKey(pathA, "test", true, fileKey, response => + { + firstUpload = response; + firstDone = true; + }); + yield return new WaitUntil(() => firstDone); + Assert.IsTrue(firstUpload.success, "First upload for metadata key test failed"); + + LootLockerPlayerFile secondUpload = new LootLockerPlayerFile(); + bool secondDone = false; + LootLockerSDKManager.UploadPlayerFileByKey(pathB, "test", true, fileKey, response => + { + secondUpload = response; + secondDone = true; + }); + yield return new WaitUntil(() => secondDone); + Assert.IsTrue(secondUpload.success, "Second upload for metadata key test failed"); + + // Get file ID for the ID-based revisions call + LootLockerPlayerFile fetchedFile = new LootLockerPlayerFile(); + bool fetchDone = false; + LootLockerSDKManager.GetPlayerFileByKey(fileKey, fileResponse => + { + fetchedFile = fileResponse; + fetchDone = true; + }); + yield return new WaitUntil(() => fetchDone); + Assert.IsTrue(fetchedFile.success, "GetPlayerFileByKey failed"); + + // When — get revisions by ID + LootLockerPlayerFileRevisionsResponse revisionsResponse = new LootLockerPlayerFileRevisionsResponse(); + bool revisionsDone = false; + LootLockerSDKManager.GetPlayerFileRevisions(fetchedFile.id, response => + { + revisionsResponse = response; + revisionsDone = true; + }); + yield return new WaitUntil(() => revisionsDone); + + // Then + Assert.IsTrue(revisionsResponse.success, "GetPlayerFileRevisions failed"); + Assert.AreEqual(fileKey, revisionsResponse.file.key, "File metadata should contain the key"); + Assert.AreEqual(fetchedFile.id, revisionsResponse.file.id, "File metadata ID should match"); + Assert.IsFalse(string.IsNullOrEmpty(revisionsResponse.file.name), "File metadata should have a name"); + } } } diff --git a/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs b/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs new file mode 100644 index 000000000..d4670583b --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs @@ -0,0 +1,289 @@ +using System.Collections; +using LootLocker; +using LootLocker.Requests; +using LootLockerTestConfigurationUtils; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace LootLockerTests.PlayMode +{ + public class WhiteLabelSignUpFieldsTest + { + 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 white label login + bool enableWLCompleted = false; + gameUnderTest?.EnableWhiteLabelLogin((success, errorMessage) => + { + if (!success) + { + Debug.LogError(errorMessage); + SetupFailed = true; + } + enableWLCompleted = true; + }); + yield return new WaitUntil(() => enableWLCompleted); + if (SetupFailed) + { + yield break; + } + + // Configure custom sign-up fields on the game + bool fieldsConfigured = false; + LootLockerTestConfigurationTitleConfig.SetCustomSignUpFields( + new LootLockerTestConfigurationTitleConfig.WhiteLabelCustomSignUpFieldDefinition[] + { + new LootLockerTestConfigurationTitleConfig.WhiteLabelCustomSignUpFieldDefinition + { + question_text = "When were you born?", + metadata_key = "birth_date", + field_type = "date", + required = true, + sensitive = false, + sort_order = 1 + }, + new LootLockerTestConfigurationTitleConfig.WhiteLabelCustomSignUpFieldDefinition + { + question_text = "Do you agree to the terms?", + metadata_key = "tos_agree", + field_type = "checkbox", + required = true, + sensitive = false, + sort_order = 2 + } + }, + (success, errorMessage) => + { + if (!success) + { + Debug.LogError($"Failed to configure custom sign-up fields: {errorMessage}"); + SetupFailed = true; + } + fieldsConfigured = true; + }); + yield return new WaitUntil(() => fieldsConfigured); + if (SetupFailed) + { + yield break; + } + + Assert.IsTrue(gameUnderTest?.InitializeLootLockerSDK(), "Failed to initialize LootLockerSDK"); + + Debug.Log($"##### Start of {this.GetType().Name} test no.{TestCounter} test case #####"); + } + + [UnityTearDown] + public IEnumerator TearDown() + { + Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} test case #####"); + if (gameUnderTest != null) + { + bool gameDeletionCallCompleted = false; + gameUnderTest.DeleteGame(((success, errorMessage) => + { + if (!success) + { + Debug.LogError(errorMessage); + } + + gameUnderTest = null; + gameDeletionCallCompleted = true; + })); + yield return new WaitUntil(() => gameDeletionCallCompleted); + } + + LootLockerStateData.ClearAllSavedStates(); + + LootLockerConfig.CreateNewSettings(configCopy); + Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public IEnumerator GetSignUpFields_WithWhiteLabelEnabled_ReturnsFieldsResponse() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When + LootLockerWhiteLabelSignUpFieldsResponse actualResponse = null; + bool getFieldsCallCompleted = false; + LootLockerSDKManager.WhiteLabelGetSignUpFields(response => + { + actualResponse = response; + getFieldsCallCompleted = true; + }); + yield return new WaitUntil(() => getFieldsCallCompleted); + + // Then + Assert.IsTrue(actualResponse.success, "GetSignUpFields returned unsuccessful: " + actualResponse.errorData?.message); + Assert.IsNotNull(actualResponse.fields, "Fields array should not be null"); + Assert.AreEqual(2, actualResponse.fields.Length, "Expected 2 custom sign-up fields to be configured"); + + // Verify the configured fields round-trip correctly (order-agnostic) + var fieldsByKey = new System.Collections.Generic.Dictionary(); + foreach (var field in actualResponse.fields) + { + fieldsByKey[field.metadata_key] = field; + } + + Assert.IsTrue(fieldsByKey.ContainsKey("birth_date"), "Expected birth_date field in response"); + Assert.AreEqual("date", fieldsByKey["birth_date"].field_type, "birth_date field_type mismatch"); + Assert.AreEqual("When were you born?", fieldsByKey["birth_date"].question_text, "birth_date question_text mismatch"); + + Assert.IsTrue(fieldsByKey.ContainsKey("tos_agree"), "Expected tos_agree field in response"); + Assert.AreEqual("checkbox", fieldsByKey["tos_agree"].field_type, "tos_agree field_type mismatch"); + Assert.AreEqual("Do you agree to the terms?", fieldsByKey["tos_agree"].question_text, "tos_agree question_text mismatch"); + } + + // Verifies serialization round-trip for the @params keyword-escaped property + [Test, Category("LootLocker"), Category("LootLockerCI")] + public void CustomField_SerializeDeserialize_HandlesParamsKeywordProperty() + { + // Given — a custom field with the @params property set + var original = new LootLockerWhiteLabelCustomField + { + question_text = "When were you born?", + metadata_key = "birth_date", + field_type = "date", + required = true, + sensitive = false, + sort_order = 1 + }; + + // Assign via the @params property (C# verbatim identifier for the keyword 'params') + original.@params = "{\"min\":\"1900-01-01\",\"max\":\"2026-01-01\"}"; + + // When — serialize to JSON + string json = LootLockerJson.SerializeObject(original); + Debug.Log($"Serialized custom field: {json}"); + + // Then — the @params property serialized as "params" in JSON + Assert.IsTrue(json.Contains("\"params\""), + $"JSON must contain the key \"params\", got:\n{json}"); + // The @params value is a JSON string, so inner quotes will be escaped in the serialized output + Assert.IsTrue(json.Contains("\\\"min\\\""), + $"JSON must contain the escaped nested JSON payload, got:\n{json}"); + + // When — deserialize back + var deserialized = LootLockerJson.DeserializeObject(json); + + // Then — the @params value round-trips + Assert.AreEqual(original.question_text, deserialized.question_text, "question_text should round-trip"); + Assert.AreEqual(original.metadata_key, deserialized.metadata_key, "metadata_key should round-trip"); + Assert.AreEqual(original.field_type, deserialized.field_type, "field_type should round-trip"); + Assert.AreEqual(original.required, deserialized.required, "required should round-trip"); + Assert.AreEqual(original.@params, deserialized.@params, "@params should round-trip through serialize/deserialize"); + Assert.AreEqual(original.sort_order, deserialized.sort_order, "sort_order should round-trip"); + } + + // Verifies serialization of request body with custom_fields array + [Test, Category("LootLocker"), Category("LootLockerCI")] + public void UserRequest_SerializeDeserialize_IncludesCustomFields() + { + // Given + var customFieldValue = new LootLockerWhiteLabelCustomFieldValue + { + metadata_key = "tos_agree", + value_json = true + }; + + var request = new LootLockerWhiteLabelSignUpRequest + { + email = "player@example.com", + password = "s3cur3p4ssw0rd", + remember = false, + custom_fields = new[] { customFieldValue } + }; + + // When + string json = LootLockerJson.SerializeObject(request); + + // Then — verify custom_fields appear in JSON with correct keys + Assert.IsTrue(json.Contains("\"custom_fields\""), + $"JSON must contain \"custom_fields\", got:\n{json}"); + Assert.IsTrue(json.Contains("\"metadata_key\":\"tos_agree\""), + $"JSON must contain metadata_key, got:\n{json}"); + // value_json should serialize as a raw boolean, not a quoted string + Assert.IsTrue(json.Contains("\"value_json\":true"), + $"JSON must contain raw boolean value_json:true, got:\n{json}"); + // Verify existing fields still serialize + Assert.IsTrue(json.Contains("\"email\":\"player@example.com\""), + $"JSON must contain email, got:\n{json}"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public IEnumerator SignUp_WithCustomFields_Succeeds() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // Given — a unique email so we don't conflict with repeated test runs + string email = $"test-{TestCounter}-{System.Guid.NewGuid():N}@example.com"; + string password = "TestPassword123!"; + + LootLockerWhiteLabelCustomFieldValue[] customFields = new LootLockerWhiteLabelCustomFieldValue[] + { + new LootLockerWhiteLabelCustomFieldValue + { + metadata_key = "birth_date", + value_json = "2000-01-15" + }, + new LootLockerWhiteLabelCustomFieldValue + { + metadata_key = "tos_agree", + value_json = true + } + }; + + // When + LootLockerWhiteLabelSignupResponse actualResponse = null; + bool signUpCallCompleted = false; + LootLockerSDKManager.WhiteLabelSignUp(email, password, customFields, response => + { + actualResponse = response; + signUpCallCompleted = true; + }); + yield return new WaitUntil(() => signUpCallCompleted); + + // Then + Assert.IsTrue(actualResponse.success, "WhiteLabelSignUp with custom fields failed: " + actualResponse.errorData?.message); + Assert.IsNotNull(actualResponse.Email, "Email should be present in response"); + } + } +} diff --git a/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs.meta b/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs.meta new file mode 100644 index 000000000..7257c52f1 --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9a180f9addc1f8a459512a0ef43ef405 \ No newline at end of file diff --git a/package.json b/package.json index dd7fc7c03..6c839702f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "com.lootlocker.lootlockersdk", - "version": "8.1.1", + "version": "8.2.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",