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/scripts/run-tests.ps1 b/.github/scripts/run-tests.ps1 index fe5154e33..18acde9c3 100644 --- a/.github/scripts/run-tests.ps1 +++ b/.github/scripts/run-tests.ps1 @@ -124,6 +124,10 @@ function Initialize-TestProject { if (-not [string]::IsNullOrEmpty($scriptingDefines)) { $psContent += $nl + ' scriptingDefineSymbols:' + $nl + ' 1: ' + $scriptingDefines } + if ($UseLocalEnv) { + # The local devenv is plain http; Unity blocks insecure connections unless this is set. + $psContent += $nl + ' insecureHttpOption: 2' + } [IO.File]::WriteAllText((Join-Path $TempProject 'ProjectSettings\ProjectSettings.asset'), $psContent) } @@ -144,6 +148,46 @@ if (-not [string]::IsNullOrWhiteSpace($CustomProject) -and (Test-Path $CustomPro $ProjectPath = $TempProject } +# Reused and custom projects need the same local settings as freshly initialized projects. +if ($UseLocalEnv) { + $ProjectSettingsFile = Join-Path $ProjectPath "ProjectSettings\ProjectSettings.asset" + if (Test-Path $ProjectSettingsFile) { + $nl = [char]10 + $settingsContent = [IO.File]::ReadAllText($ProjectSettingsFile) + if ($settingsContent -notmatch 'LOOTLOCKER_COMMANDLINE_SETTINGS') { + $defineProperty = [Regex]::Match($settingsContent, '(?m)^ scriptingDefineSymbols:[^\r\n]*(?:\r?\n|$)') + if ($defineProperty.Success) { + $groupOne = [Regex]::Match($settingsContent.Substring($defineProperty.Index + $defineProperty.Length), '\A(?: [^\r\n]*\r?\n)*? 1:[ \t]*[^\r\n]*') + if ($groupOne.Success) { + $start = $defineProperty.Index + $defineProperty.Length + $groupOne.Index + $newGroup = if ($groupOne.Value -match '^ 1:[ \t]*$') { + ' 1: LOOTLOCKER_COMMANDLINE_SETTINGS' + } else { + $groupOne.Value + ';LOOTLOCKER_COMMANDLINE_SETTINGS' + } + $settingsContent = $settingsContent.Remove($start, $groupOne.Length).Insert($start, $newGroup) + } else { + $settingsContent = $settingsContent.Insert($defineProperty.Index + $defineProperty.Length, ' 1: LOOTLOCKER_COMMANDLINE_SETTINGS' + $nl) + } + } else { + $settingsContent = $settingsContent.TrimEnd() + $nl + ' scriptingDefineSymbols:' + $nl + ' 1: LOOTLOCKER_COMMANDLINE_SETTINGS' + $nl + } + } + if ($settingsContent -match '(?m)^[ \t]*insecureHttpOption:') { + $updatedContent = [Regex]::Replace($settingsContent, '(?m)^[ \t]*insecureHttpOption:.*$', ' insecureHttpOption: 2') + } else { + $updatedContent = $settingsContent.TrimEnd() + $nl + ' insecureHttpOption: 2' + $nl + } + if ($updatedContent -ne $settingsContent) { + [IO.File]::WriteAllText($ProjectSettingsFile, $updatedContent) + Write-Step "Enabled insecure http in $ProjectSettingsFile" + } + } else { + Write-Fail "ProjectSettings.asset not found at $ProjectSettingsFile; cannot enable local http." + exit 1 + } +} + $ResultsFile = Join-Path $ProjectPath "TestResults.xml" $LogFile = Join-Path $ProjectPath "test-run.log" 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..499611a39 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); @@ -336,6 +349,16 @@ public class LootLockerEndPoints // Presence (WebSocket) [Header("Presence")] public static EndPointClass presenceWebSocket = new EndPointClass("presence/v1", LootLockerHTTPMethod.GET); + + // Items + [Header("Items")] + public static EndPointClass listItemTemplates = new EndPointClass("player/inventory/v1", LootLockerHTTPMethod.GET); + public static EndPointClass listPlayerInventoryItems = new EndPointClass("player/inventory/v1/my", LootLockerHTTPMethod.GET); + public static EndPointClass getPlayerInventoryItem = new EndPointClass("player/inventory/v1/{0}", LootLockerHTTPMethod.GET); + public static EndPointClass deletePlayerInventoryItem = new EndPointClass("player/inventory/v1/{0}", LootLockerHTTPMethod.DELETE); + public static EndPointClass consumePlayerInventoryItem = new EndPointClass("player/inventory/v1/{0}/consume", LootLockerHTTPMethod.POST); + public static EndPointClass splitPlayerInventoryItemStack = new EndPointClass("player/inventory/v1/{0}/split", LootLockerHTTPMethod.POST); + public static EndPointClass mergePlayerInventoryItemStacks = new EndPointClass("player/inventory/v1/merge", LootLockerHTTPMethod.POST); } [Serializable] diff --git a/Runtime/Client/LootLockerHTTPClient.cs b/Runtime/Client/LootLockerHTTPClient.cs index 4a166ff74..d2bab3fea 100644 --- a/Runtime/Client/LootLockerHTTPClient.cs +++ b/Runtime/Client/LootLockerHTTPClient.cs @@ -747,12 +747,10 @@ private void CallListenersAndMarkDone(LootLockerHTTPExecutionQueueItem execution response.requestContext = new LootLockerRequestContext(executionItem.RequestData.ForPlayerWithUlid, executionItem.RequestData.RequestStartTime, executionItem.RequestData.RequestId); -#if LOOTLOCKER_BETA_ENABLE_ERROR_REPORTING if (response != null && !response.success) { StoreFailedRequestReport(response, executionItem); } -#endif CurrentlyOngoingRequests.Remove(executionItem.RequestData.RequestId); executionItem.IsWaitingForSessionRefresh = false; diff --git a/Runtime/Client/LootLockerLifecycleManager.cs b/Runtime/Client/LootLockerLifecycleManager.cs index 9bef1cb77..f63dd2f39 100644 --- a/Runtime/Client/LootLockerLifecycleManager.cs +++ b/Runtime/Client/LootLockerLifecycleManager.cs @@ -46,7 +46,11 @@ public class LootLockerLifecycleManager : MonoBehaviour #region Instance Handling private static LootLockerLifecycleManager _instance; - private static int _instanceId = 0; +#if UNITY_6000_5_OR_NEWER + private static EntityId _instanceId = default; +#else + private static int _instanceId = default; +#endif private static GameObject _hostingGameObject = null; private static readonly object _instanceLock = new object(); @@ -70,7 +74,7 @@ private static void AutoInitialize() { Debug.LogWarning($"[LootLocker] LifecycleManager auto-initialization failed: {ex.Message}. Services will be initialized on first use."); _instance = null; - _instanceId = 0; + _instanceId = default; if (_hostingGameObject != null) { Destroy(_hostingGameObject); @@ -115,7 +119,11 @@ private static void Instantiate() { var gameObject = new GameObject("LootLockerLifecycleManager"); _instance = gameObject.AddComponent(); +#if UNITY_6000_5_OR_NEWER + _instanceId = _instance.GetEntityId(); +#else _instanceId = _instance.GetInstanceID(); +#endif _hostingGameObject = gameObject; if (Application.isPlaying) @@ -150,7 +158,7 @@ private static void TeardownInstance() #endif _instance = null; - _instanceId = 0; + _instanceId = default; _hostingGameObject = null; } @@ -168,13 +176,22 @@ public static IEnumerator CleanUpOldInstances() #endif foreach (LootLockerLifecycleManager manager in managers) { - if (manager != null && _instanceId != manager.GetInstanceID() && manager.gameObject != null && ((LootLockerLifecycleManager)manager)._isInitialized) + if (manager != null && manager.gameObject != null && ((LootLockerLifecycleManager)manager)._isInitialized) { + bool isCurrentInstance; +#if UNITY_6000_5_OR_NEWER + isCurrentInstance = _instanceId == manager.GetEntityId(); +#else + isCurrentInstance = _instanceId == manager.GetInstanceID(); +#endif + if (!isCurrentInstance) + { #if UNITY_EDITOR - DestroyImmediate(manager.gameObject); + DestroyImmediate(manager.gameObject); #else - Destroy(manager.gameObject); + Destroy(manager.gameObject); #endif + } } } yield return null; @@ -572,7 +589,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, Dictionary public string EventId { get; set; } = Guid.NewGuid().ToString(); -#if LOOTLOCKER_BETA_ENABLE_ERROR_REPORTING /// /// Sends a report about a failed request to be viewable in the LootLocker dashboard. /// This is intended to be used in the case where a request fails and you want to send the details of that failure to LootLocker for debugging and tracking purposes. @@ -60,7 +59,6 @@ public void ReportFailure(string userDescription, Action onC { LootLocker.Requests.LootLockerSDKManager.SendLootLockerErrorReport(userDescription, this, onComplete); } -#endif public static void Deserialize(Action onComplete, LootLockerResponse serverResponse, #if LOOTLOCKER_USE_NEWTONSOFTJSON diff --git a/Runtime/Client/LootLockerStateData.cs b/Runtime/Client/LootLockerStateData.cs index 8f2537edd..2e845e930 100644 --- a/Runtime/Client/LootLockerStateData.cs +++ b/Runtime/Client/LootLockerStateData.cs @@ -211,6 +211,11 @@ public void OverrideStateWriter(ILootLockerStateWriter newWriter) } } + public static ILootLockerStateWriter GetStateWriter() + { + return _stateWriter; + } + //================================================== // Constants //================================================== diff --git a/Runtime/Client/LootLockerStateWriter.cs b/Runtime/Client/LootLockerStateWriter.cs index bab35b837..4b0702a71 100644 --- a/Runtime/Client/LootLockerStateWriter.cs +++ b/Runtime/Client/LootLockerStateWriter.cs @@ -27,6 +27,19 @@ public interface ILootLockerStateWriter /// The 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..204a42240 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. @@ -3313,6 +3476,11 @@ public static void ListPlayerInfo(string[] playerIdsToLookUp, int[] playerLegacy /// /// Get the players inventory. /// + /// + /// Operates on assets and asset instances. For the item-based inventory, see + /// and friends, which deal with items and + /// item templates and are a separate system. + /// /// Amount of assets to retrieve /// The instance ID the list should start from /// onComplete Action for handling the response of type LootLockerInventoryResponse @@ -3378,6 +3546,11 @@ public static void GetInventory(int count, Action o /// /// List player inventory with default parameters (no filters, first page, default page size). /// + /// + /// Operates on assets and asset instances. For the item-based inventory, see + /// and friends, which deal with items and + /// item templates and are a separate system. + /// /// 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 ListPlayerInventoryWithDefaultParameters(Action onComplete, string forPlayerWithUlid = null) @@ -3401,6 +3574,11 @@ public static void ListPlayerInventoryWithDefaultParameters(ActionOptional : Page number to retrieve. /// onComplete Action for handling the response /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + /// + /// Operates on assets and asset instances. For the item-based inventory, see + /// and friends, which deal with items and + /// item templates and are a separate system. + /// public static void ListPlayerInventory(LootLockerListSimplifiedInventoryRequest request, int perPage, int page, Action onComplete, string forPlayerWithUlid = null) { if (!CheckInitialized(false, forPlayerWithUlid)) @@ -4043,7 +4221,6 @@ public static void UploadPlayerFile(string pathToFile, string filePurpose, bool { "public", isPublic.ToString().ToLower() } }; - var fileBytes = new byte[] { }; try { @@ -4127,7 +4304,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 +4349,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 +4648,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 @@ -6138,7 +6641,232 @@ public static void DeleteAssetInstanceFromPlayerInventory(int assetInstanceID, A LootLockerAPIManager.DeleteAssetInstanceFromPlayerInventory(forPlayerWithUlid, data, onComplete); } #endregion - + + #region Items + + // These methods operate on items and item templates. They are unrelated to the asset-based + // inventory API (GetInventory, ListPlayerInventory, ...), which deals with assets and asset + // instances. The two share a URL prefix but are separate systems. + + /// + /// Returns a paginated list of all visible item templates. + /// + /// The page of item templates to return. + /// The number of item templates to return per page. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void ListItemTemplates(int page, int perPage, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + EndPointClass endPoint = LootLockerEndPoints.listItemTemplates; + + var queryParams = new LootLocker.Utilities.HTTP.QueryParamaterBuilder(); + if (page > 0) + queryParams.Add("page", page); + if (perPage > 0) + queryParams.Add("per_page", perPage); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint + queryParams.Build(), endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + /// + /// Returns a paginated list of the current player's inventory items, including their item templates. + /// + /// + /// Operates on items and item templates. This is unrelated to the asset-based + /// inventory API (, , + /// , ), which deals with + /// assets and asset instances. The two share a URL prefix but are separate systems. + /// + /// The page of items to return. + /// The number of items to return per page. + /// (Optional) Return only items whose template name starts with the specified value. Set to null to not use this filter. + /// (Optional) Return only items of the specified type. Set to null to not use this filter. + /// (Optional) Return only items that are (or are not) consumable. Set to null to not use this filter. + /// (Optional) The field by which to sort the items. Set to null to not use this filter. + /// (Optional) The direction in which to sort the items. Set to null to not use this filter. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void ListPlayerInventoryItems(int page, int perPage, string name = null, LootLockerItemType? itemType = null, bool? consumable = null, LootLockerItemSortField? sort = null, LootLockerSortOrder? order = null, Action onComplete = null, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + EndPointClass endPoint = LootLockerEndPoints.listPlayerInventoryItems; + + var queryParams = new LootLocker.Utilities.HTTP.QueryParamaterBuilder(); + if (page > 0) + queryParams.Add("page", page); + if (perPage > 0) + queryParams.Add("per_page", perPage); + queryParams.Add("name", name); + if (itemType.HasValue) + queryParams.Add("item_type", itemType.Value.ToString()); + if (consumable.HasValue) + queryParams.Add("consumable", consumable.Value.ToString().ToLower()); + if (sort.HasValue) + queryParams.Add("sort", sort.Value.ToString()); + if (order.HasValue) + queryParams.Add("order", order.Value.ToString()); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint + queryParams.Build(), endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + /// + /// Returns a single inventory item for the current player, including its item template. + /// + /// + /// Operates on items and item templates. This is unrelated to the asset-based + /// inventory API, which deals with assets and asset instances. + /// + /// The id of the inventory item to fetch. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void GetPlayerInventoryItem(string inventoryId, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + EndPointClass endPoint = LootLockerEndPoints.getPlayerInventoryItem; + + string getVariable = endPoint.WithPathParameter(inventoryId); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + /// + /// Deletes an inventory item from the current player's inventory. + /// + /// + /// Operates on items and item templates. This is unrelated to the asset-based + /// inventory API (), which deals with + /// assets and asset instances. + /// + /// The id of the inventory item to delete. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void DeletePlayerInventoryItem(string inventoryId, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + EndPointClass endPoint = LootLockerEndPoints.deletePlayerInventoryItem; + + string getVariable = endPoint.WithPathParameter(inventoryId); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + /// + /// Consumes one or more items from a stackable inventory item. If is null, a single item is consumed. + /// + /// + /// Operates on items and item templates. This is unrelated to the asset-based + /// inventory API, which deals with assets and asset instances. + /// + /// The id of the stackable inventory item to consume. + /// (Optional) The number of items to consume. If null, the backend consumes 1. To consume the whole stack, pass the item's current count. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void ConsumePlayerInventoryItem(string inventoryId, int? count, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + EndPointClass endPoint = LootLockerEndPoints.consumePlayerInventoryItem; + + string getVariable = endPoint.WithPathParameter(inventoryId); + string json = LootLockerJson.SerializeObject(new LootLockerConsumeInventoryItemRequest { count = count }); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + /// + /// Consumes a single item from a stackable inventory item. + /// + /// + /// Operates on items and item templates. This is unrelated to the asset-based + /// inventory API, which deals with assets and asset instances. + /// + /// The id of the stackable inventory item to consume. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void ConsumePlayerInventoryItem(string inventoryId, Action onComplete, string forPlayerWithUlid = null) + { + ConsumePlayerInventoryItem(inventoryId, null, onComplete, forPlayerWithUlid); + } + + /// + /// Splits a stackable inventory item into two stacks, moving items into a new stack. + /// + /// + /// Operates on items and item templates. This is unrelated to the asset-based + /// inventory API, which deals with assets and asset instances. + /// + /// The id of the stackable inventory item to split. + /// The number of items to move into the new stack. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void SplitPlayerInventoryItemStack(string inventoryId, int count, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + EndPointClass endPoint = LootLockerEndPoints.splitPlayerInventoryItemStack; + + string getVariable = endPoint.WithPathParameter(inventoryId); + string json = LootLockerJson.SerializeObject(new LootLockerSplitInventoryItemStackRequest { count = count }); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + /// + /// Merges two stacks of the same item into one. + /// + /// + /// Operates on items and item templates. This is unrelated to the asset-based + /// inventory API, which deals with assets and asset instances. + /// + /// The id of the inventory item to merge from. + /// The id of the inventory item to merge into. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void MergePlayerInventoryItemStacks(string sourceInventoryId, string targetInventoryId, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + EndPointClass endPoint = LootLockerEndPoints.mergePlayerInventoryItemStacks; + + string json = LootLockerJson.SerializeObject(new LootLockerMergeInventoryItemStacksRequest { source_inventory_id = sourceInventoryId, target_inventory_id = targetInventoryId }); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + #endregion + #region AssetInstance progressions /// @ingroup AssetInstanceProgressions @@ -8267,7 +8995,6 @@ public static void SendUGCFeedback(string ulid, string description, string categ SendFeedback(LootLockerFeedbackTypes.ugc, ulid, description, category_id, onComplete, forPlayerWithUlid); } -#if LOOTLOCKER_BETA_ENABLE_ERROR_REPORTING /// /// Sends a report about a failed request to be viewable in the LootLocker dashboard. /// This is intended to be used in the case where a request fails and you want to send the details of that failure to LootLocker for debugging and tracking purposes. @@ -8375,7 +9102,6 @@ string[] RedactSensitiveHeaders(string[] headers) EndPointClass endPoint = LootLockerEndPoints.createErrorReport; LootLockerServerRequest.CallAPI(failedResponse.requestContext.player_ulid, endPoint.endPoint, endPoint.httpMethod, reportAsJson, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } -#endif /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. private static void SendFeedback(LootLockerFeedbackTypes type, string ulid, string description, string category_id, Action onComplete, string forPlayerWithUlid = null) @@ -9046,6 +9772,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/ItemRequest.cs b/Runtime/Game/Requests/ItemRequest.cs new file mode 100644 index 000000000..4112d9501 --- /dev/null +++ b/Runtime/Game/Requests/ItemRequest.cs @@ -0,0 +1,322 @@ +using System; +using LootLocker.LootLockerEnums; +using LootLocker.Requests; + +namespace LootLocker.LootLockerEnums +{ + /// + /// The type of an item, determining whether the item instances are stackable or individually tracked. + /// + public enum LootLockerItemType + { + /// Each granted item is a separate, individually tracked instance. + instanced, + /// The item is stored as a single entry with a count that can be incremented or decremented. + stackable + } + + /// + /// The field by which to order a player item list response. + /// + public enum LootLockerItemSortField + { + /// Order by when the item was created. + created_at, + /// Order by when the item was last updated. + updated_at, + /// Order by the source that granted the item. + source + } + + /// + /// The kind of reward that was granted as a result of a behaviour. + /// + public enum LootLockerRewardKind + { + /// The reward kind could not be determined. + unknown, + /// An asset. + asset, + /// Progression points. + progression_points, + /// A progression reset. + progression_reset, + /// A currency. + currency, + /// A group. + group, + /// A reward. + reward, + /// A platform key. + platform_key, + /// A publisher currency. + publisher_currency, + /// Publisher progression points. + publisher_progression_points, + /// Player metadata. + player_metadata, + /// A file. + file, + /// A Discord role. + discord_role, + /// An item template. + item_template + } + + /// + /// The direction in which to order a player item list response. + /// + public enum LootLockerSortOrder + { + /// Order ascending. + ASC, + /// Order descending. + DESC + } +} + +namespace LootLocker.Requests +{ + //================================================== + // Item Template Definitions + //================================================== + + /// + /// An item template defines a type of item that can be granted to players. + /// + public class LootLockerItemTemplate + { + /// The unique id of the item template. + public string id { get; set; } + + /// The name of the item template. + public string name { get; set; } + + /// Indicates whether this item template is limited in how many of it can exist. + public int limited { get; set; } + + /// The type of the item (instanced or stackable). + public LootLockerItemType item_type { get; set; } + + /// True if the item can be consumed by the player. + public bool consumable { get; set; } + + /// True if the item can be deleted by the player. + public bool deletable { get; set; } + + /// The time that this item template was created. + public DateTime created_at { get; set; } + + /// The time that this item template was last updated, or null if it has never been updated. + public DateTime? updated_at { get; set; } + } + + //================================================== + // Player Item Definitions + //================================================== + + /// + /// An item instance held in a player's inventory. + /// + /// + /// This represents an item granted from an item template. It is unrelated to the + /// asset-based inventory API (, GetInventory, + /// ListPlayerInventory), which deals with assets and asset instances. + /// + public class LootLockerInventoryItem + { + /// The unique id of this inventory item. + public string id { get; set; } + + /// The id of the player that owns this inventory item. + public int player_id { get; set; } + + /// The id of the item template this inventory item is based on. + public string item_template_id { get; set; } + + /// The type of the item (instanced or stackable). + public LootLockerItemType item_type { get; set; } + + /// True if the item can be consumed by the player. + public bool consumable { get; set; } + + /// True if the item can be deleted by the player. + public bool deletable { get; set; } + + /// The number of items in this stack (only relevant for stackable items). + public int count { get; set; } + + /// The name of the item template this inventory item is based on. + public string name { get; set; } + + /// The source of this inventory item, or null if not specified. + public string source { get; set; } + + /// The time that this inventory item was created. + public DateTime created_at { get; set; } + + /// The time that this inventory item was last updated, or null if it has never been updated. + public DateTime? updated_at { get; set; } + + /// Arbitrary metadata attached to this inventory item. + public LootLockerMetadataEntry[] metadata { get; set; } + } + + /// + /// An item granted to a player as the result of consuming another item (or via an on-grant behaviour). + /// Matches the go-backend `GrantResult` shape returned in `granted` / `behaviour_results`. + /// + public class LootLockerGrantedItem + { + /// The source id of the granted entry (inventory id, currency id, etc.). + public string source_id { get; set; } + + /// The number of items granted. + public int count { get; set; } + + /// The type of the granted entry. + public LootLockerRewardKind type { get; set; } + + /// The name of the granted entry, if applicable. + public string name { get; set; } + + /// The code of the granted entry, if applicable. + public string code { get; set; } + } + + //================================================== + // Request Definitions + //================================================== + + /// + /// Request to consume a stackable item from the player's inventory. + /// + public class LootLockerConsumeInventoryItemRequest + { + /// + /// The number of items to consume. Defaults to 1 when omitted. To consume an entire stack, + /// pass the item's current count. + /// + public int? count { get; set; } + + public bool ShouldSerializecount() + { + // Omit count entirely when unset so the backend applies its default of 1. This also keeps + // the Newtonsoft and ZeroDep JSON backends consistent, since only ZeroDep skips nulls. + return count.HasValue; + } + } + + /// + /// Request to split a stackable item in the player's inventory into two stacks. + /// + public class LootLockerSplitInventoryItemStackRequest + { + /// The number of items to move into the new stack. + public int count { get; set; } + } + + /// + /// Request to merge two stacks of the same item into one. + /// + public class LootLockerMergeInventoryItemStacksRequest + { + /// The id of the source inventory item to merge from. + public string source_inventory_id { get; set; } + + /// The id of the target inventory item to merge into. + public string target_inventory_id { get; set; } + } + + //================================================== + // Response Definitions + //================================================== + + /// + /// Response containing a paginated list of visible item templates. + /// + public class LootLockerListItemTemplatesResponse : LootLockerResponse + { + /// The list of visible item templates. + public LootLockerItemTemplate[] items { get; set; } + + /// Pagination details for the response. + public LootLockerExtendedPagination pagination { get; set; } + } + + /// + /// Response containing a paginated list of the player's inventory items. + /// + public class LootLockerListPlayerInventoryItemsResponse : LootLockerResponse + { + /// The list of the player's inventory items. + public LootLockerInventoryItem[] items { get; set; } + + /// Pagination details for the response. + public LootLockerExtendedPagination pagination { get; set; } + } + + /// + /// Response containing the player's inventory item, returned at the top level with its template and metadata. + /// + public class LootLockerGetPlayerInventoryItemResponse : LootLockerResponse + { + /// The unique id of this inventory item. + public string id { get; set; } + + /// The id of the player that owns this inventory item. + public int player_id { get; set; } + + /// The id of the item template this inventory item is based on. + public string item_template_id { get; set; } + + /// The type of the item (instanced or stackable). + public LootLockerItemType item_type { get; set; } + + /// True if the item can be consumed by the player. + public bool consumable { get; set; } + + /// True if the item can be deleted by the player. + public bool deletable { get; set; } + + /// The number of items in this stack (only relevant for stackable items). + public int count { get; set; } + + /// The source of this inventory item, or null if not specified. + public string source { get; set; } + + /// The time that this inventory item was created. + public DateTime created_at { get; set; } + + /// The time that this inventory item was last updated, or null if it has never been updated. + public DateTime? updated_at { get; set; } + + /// The item template this inventory item is based on. + public LootLockerItemTemplate template { get; set; } + + /// Arbitrary metadata attached to this inventory item. + public LootLockerMetadataEntry[] metadata { get; set; } + } + + /// + /// Response containing the result of consuming an item, including any items granted as a result. + /// + public class LootLockerConsumeInventoryItemResponse : LootLockerResponse + { + /// True if the item was consumed. + public bool consumed { get; set; } + + /// Any items granted as a result of consuming the item. + public LootLockerGrantedItem[] granted { get; set; } + } + + /// + /// Response containing the id of the newly created stack after splitting an item. + /// + public class LootLockerSplitInventoryItemStackResponse : LootLockerResponse + { + /// The id of the newly created item stack. + public string id { get; set; } + } +} + diff --git a/Runtime/Game/Requests/ItemRequest.cs.meta b/Runtime/Game/Requests/ItemRequest.cs.meta new file mode 100644 index 000000000..0f33bdad8 --- /dev/null +++ b/Runtime/Game/Requests/ItemRequest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1c184783fa724925b081edf9211f3855 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: 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/MetadataRequests.cs b/Runtime/Game/Requests/MetadataRequests.cs index 896c274c3..4a9e4fdcc 100644 --- a/Runtime/Game/Requests/MetadataRequests.cs +++ b/Runtime/Game/Requests/MetadataRequests.cs @@ -25,7 +25,9 @@ public enum LootLockerMetadataSources player = 5, self = 6, asset = 7, - item = 8, // This is the source for asset instances (player inventory items), while the "asset" source is for the asset in general + item = 8, // This is the source for legacy items, while the "inventory_item" source is for item instances + inventory_item = 9, // This is the source for item instances (player inventory items) + item_template = 10, // This is the source for item templates }; /// 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/LootLockerTestConfigurationEndpoints.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs index c8f22a1e0..c5ed49b6a 100644 --- a/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs @@ -109,6 +109,20 @@ public class LootLockerTestConfigurationEndpoints [Header("LootLocker Admin API Inventory Operations")] public static EndPointClass adminGrantAssetToPlayerInventory = new EndPointClass("/game/#GAMEID#/player/{0}/inventory/grant", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + [Header("LootLocker Admin API Item Template Operations")] + public static EndPointClass createItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass listItemTemplates = new EndPointClass("game/#GAMEID#/templates/v1", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin); + public static EndPointClass getItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1/{0}", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin); + public static EndPointClass updateItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1/{0}", LootLockerHTTPMethod.PATCH, LootLockerCallerRole.Admin); + public static EndPointClass deleteItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1/{0}", LootLockerHTTPMethod.DELETE, LootLockerCallerRole.Admin); + public static EndPointClass grantItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1/grant", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass setItemTemplateAudiences = new EndPointClass("game/#GAMEID#/templates/v1/{0}/audiences", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass adminListPlayerInventoryItems = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin); + public static EndPointClass adminConsumePlayerInventoryItem = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/{1}/consume", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass adminDeletePlayerInventoryItem = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/{1}", LootLockerHTTPMethod.DELETE, LootLockerCallerRole.Admin); + public static EndPointClass adminSplitPlayerInventoryItemStack = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/{1}/split", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass adminMergePlayerInventoryItemStacks = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/merge", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + [Header("LootLocker Admin API Metadata Operations")] public static EndPointClass metadataOperations = new EndPointClass("game/#GAMEID#/metadata", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs new file mode 100644 index 000000000..35c01b040 --- /dev/null +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs @@ -0,0 +1,276 @@ +using System; +using LootLocker; +using Random = UnityEngine.Random; + +namespace LootLockerTestConfigurationUtils +{ + /// + /// Admin API helpers for setting up item templates and player inventory items in tests. + /// + /// + /// These helpers deliberately mirror the go-backend admin routes rather than the published + /// ApiDog spec, which is stale for this feature. Notable differences from the spec: + /// the list endpoint returns templates (not items), grant returns id + /// (not inventory_id), consume requires a body and returns a count (not a bool), + /// and the player inventory listing uses template_name and has no deletable. + /// + public static class LootLockerTestItems + { + public static string GetRandomItemTemplateName() + { + string[] colors = { "Green", "Blue", "Red", "Black", "Yellow", "Orange", "Purple", "Indigo", "Clear", "White", "Magenta", "Marine", "Crimson", "Teal" }; + string[] items = { "Rod", "House", "Wand", "Staff", "Car", "Sword", "Shield", "Gun", "Shovel", "Boomstick", "Rifle", "Hut", "Boat", "Bicycle", "Wheelchair" }; + + return colors[Random.Range(0, colors.Length)] + " " + items[Random.Range(0, items.Length)]; + } + + /// + /// Create an item template. Audiences are set inline by the create endpoint, which is + /// required for the template to be visible to the player through the game API. + /// + public static void CreateItemTemplate(string name, string itemType, bool consumable, bool deletable, string[] audiences, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerTestItemTemplateResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var request = new LootLockerTestCreateItemTemplateRequest + { + name = name, + item_type = itemType, + consumable = consumable, + deletable = deletable, + audiences = audiences + }; + + var endpoint = LootLockerTestConfigurationEndpoints.createItemTemplate; + string json = LootLockerJson.SerializeObject(request); + + LootLockerAdminRequest.Send(endpoint.endPoint, endpoint.httpMethod, json, onComplete: (serverResponse) => + { + var response = LootLockerResponse.Deserialize(serverResponse); + onComplete?.Invoke(response); + }, true); + } + + /// + /// Grant an item template to a player. Returns the id of the created inventory item. + /// + public static void GrantItemToPlayer(int playerId, string itemTemplateId, int count, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerTestGrantItemResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var request = new LootLockerTestGrantItemRequest + { + player_id = playerId, + item_template_id = itemTemplateId, + count = count + }; + + var endpoint = LootLockerTestConfigurationEndpoints.grantItemTemplate; + string json = LootLockerJson.SerializeObject(request); + + LootLockerAdminRequest.Send(endpoint.endPoint, endpoint.httpMethod, json, onComplete: (serverResponse) => + { + var response = LootLockerResponse.Deserialize(serverResponse); + onComplete?.Invoke(response); + }, true); + } + + public static void SetItemTemplateAudiences(string itemTemplateId, string[] audiences, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var endpoint = LootLockerTestConfigurationEndpoints.setItemTemplateAudiences; + string formattedEndpoint = string.Format(endpoint.endPoint, itemTemplateId); + string json = LootLockerJson.SerializeObject(new LootLockerTestSetItemTemplateAudiencesRequest { audiences = audiences }); + + LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, json, onComplete, true); + } + + public static void ListPlayerInventoryItemsAdmin(int playerId, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerTestAdminListPlayerInventoryItemsResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var endpoint = LootLockerTestConfigurationEndpoints.adminListPlayerInventoryItems; + string formattedEndpoint = string.Format(endpoint.endPoint, playerId); + + LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, null, onComplete: (serverResponse) => + { + var response = LootLockerResponse.Deserialize(serverResponse); + onComplete?.Invoke(response); + }, true); + } + + /// + /// Consume items through the admin API. The endpoint requires a body carrying the + /// player id; of 0 lets the backend apply its default of 1. + /// + public static void ConsumePlayerInventoryItemAdmin(int playerId, string inventoryId, int count, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerTestAdminConsumeItemResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var endpoint = LootLockerTestConfigurationEndpoints.adminConsumePlayerInventoryItem; + string formattedEndpoint = string.Format(endpoint.endPoint, playerId, inventoryId); + string json = LootLockerJson.SerializeObject(new LootLockerTestAdminConsumeItemRequest { player_id = playerId, count = count }); + + LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, json, onComplete: (serverResponse) => + { + var response = LootLockerResponse.Deserialize(serverResponse); + onComplete?.Invoke(response); + }, true); + } + + public static void DeletePlayerInventoryItemAdmin(int playerId, string inventoryId, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var endpoint = LootLockerTestConfigurationEndpoints.adminDeletePlayerInventoryItem; + string formattedEndpoint = string.Format(endpoint.endPoint, playerId, inventoryId); + + LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, null, onComplete, true); + } + + public static void SplitPlayerInventoryItemStackAdmin(int playerId, string inventoryId, int count, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerTestSplitItemStackResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var endpoint = LootLockerTestConfigurationEndpoints.adminSplitPlayerInventoryItemStack; + string formattedEndpoint = string.Format(endpoint.endPoint, playerId, inventoryId); + string json = LootLockerJson.SerializeObject(new LootLockerTestSplitItemStackRequest { count = count }); + + LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, json, onComplete: (serverResponse) => + { + var response = LootLockerResponse.Deserialize(serverResponse); + onComplete?.Invoke(response); + }, true); + } + + public static void MergePlayerInventoryItemStacksAdmin(int playerId, string sourceInventoryId, string targetInventoryId, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var endpoint = LootLockerTestConfigurationEndpoints.adminMergePlayerInventoryItemStacks; + string formattedEndpoint = string.Format(endpoint.endPoint, playerId); + string json = LootLockerJson.SerializeObject(new LootLockerTestMergeItemStacksRequest + { + source_inventory_id = sourceInventoryId, + target_inventory_id = targetInventoryId + }); + + LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, json, onComplete, true); + } + } + + public class LootLockerTestCreateItemTemplateRequest + { + public string name { get; set; } + public string item_type { get; set; } + public bool consumable { get; set; } + public bool deletable { get; set; } + public string[] audiences { get; set; } + } + + public class LootLockerTestItemTemplateResponse : LootLockerResponse + { + public string id { get; set; } + } + + public class LootLockerTestGrantItemRequest + { + public int player_id { get; set; } + public string item_template_id { get; set; } + public int count { get; set; } + } + + public class LootLockerTestGrantItemResponse : LootLockerResponse + { + public string id { get; set; } + } + + public class LootLockerTestSetItemTemplateAudiencesRequest + { + public string[] audiences { get; set; } + } + + public class LootLockerTestAdminConsumeItemRequest + { + public int player_id { get; set; } + public int count { get; set; } + } + + public class LootLockerTestAdminConsumeItemResponse : LootLockerResponse + { + public int consumed { get; set; } + } + + public class LootLockerTestSplitItemStackRequest + { + public int count { get; set; } + } + + public class LootLockerTestSplitItemStackResponse : LootLockerResponse + { + public string id { get; set; } + } + + public class LootLockerTestMergeItemStacksRequest + { + public string source_inventory_id { get; set; } + public string target_inventory_id { get; set; } + } + + public class LootLockerTestAdminListPlayerInventoryItemsResponse : LootLockerResponse + { + public LootLockerTestAdminPlayerItem[] items { get; set; } + } + + /// + /// An item as returned by the admin player inventory listing. Note that this shape differs + /// from the game API item: the template name is exposed as template_name and there is + /// no deletable field. + /// + public class LootLockerTestAdminPlayerItem + { + public string id { get; set; } + public int player_id { get; set; } + public string item_template_id { get; set; } + public string template_name { get; set; } + public string item_type { get; set; } + public bool consumable { get; set; } + public int count { get; set; } + public string source { get; set; } + public string created_at { get; set; } + public string updated_at { get; set; } + } +} diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs.meta b/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs.meta new file mode 100644 index 000000000..3bbd24fd3 --- /dev/null +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 99fdfa0f2eea476a8a97204dcb3005e6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs index 329e69284..2c8742877 100644 --- a/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs @@ -48,7 +48,9 @@ public enum LootLockerTestMetadataSources player = 5, self = 6, asset = 7, - item = 8, // This is the source for asset instances (player inventory items), while the "asset" source is for the asset in general + item = 8, // This is the source for legacy items, while the "inventory_item" source is for item instances + inventory_item = 9, // This is the source for item instances (player inventory items) + item_template = 10, // This is the source for item templates }; /// 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/ItemTests.cs b/Tests/LootLockerTests/PlayMode/ItemTests.cs new file mode 100644 index 000000000..46ed96bc0 --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/ItemTests.cs @@ -0,0 +1,512 @@ +using System; +using System.Collections; +using System.Linq; +using LootLocker; +using LootLocker.LootLockerEnums; +using LootLocker.Requests; +using LootLockerTestConfigurationUtils; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace LootLockerTests.PlayMode +{ + public class ItemTests + { + private LootLockerTestGame gameUnderTest = null; + private LootLockerConfig configCopy = null; + private static int TestCounter = 0; + private bool SetupFailed = false; + private int sessionPlayerId = 0; + private string createdTemplateId = string.Empty; + private string createdTemplateName = string.Empty; + private string grantedInventoryId = string.Empty; + + private const string PublicAudience = "public"; + + [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"); + } + + bool gameCreationCallCompleted = false; + LootLockerTestGame.CreateGame(testName: this.GetType().Name + TestCounter + " ", onComplete: (success, errorMessage, game) => + { + if (!success) + { + Debug.LogError(errorMessage); + SetupFailed = true; + } + gameUnderTest = game; + gameCreationCallCompleted = true; + }); + yield return new WaitUntil(() => gameCreationCallCompleted); + if (SetupFailed) + { + yield break; + } + + gameUnderTest?.SwitchToStageEnvironment(); + + bool enableGuestLoginCallCompleted = false; + gameUnderTest?.EnableGuestLogin((success, errorMessage) => + { + if (!success) + { + Debug.LogError(errorMessage); + SetupFailed = true; + } + enableGuestLoginCallCompleted = true; + }); + yield return new WaitUntil(() => enableGuestLoginCallCompleted); + if (SetupFailed) + { + yield break; + } + + Assert.IsTrue(gameUnderTest?.InitializeLootLockerSDK(), "Successfully created test game and initialized LootLocker"); + + // Create a stackable, consumable, deletable template with a public audience. The + // audience is required: the game API only lists templates that have an audience row, + // so without it the template would be invisible even to the player it was granted to. + createdTemplateName = LootLockerTestItems.GetRandomItemTemplateName(); + bool createTemplateCallCompleted = false; + LootLockerTestItems.CreateItemTemplate(createdTemplateName, "stackable", true, true, new[] { PublicAudience }, (templateResponse) => + { + if (templateResponse == null || !templateResponse.success) + { + Debug.LogError("Failed to create item template: " + templateResponse?.errorData?.message); + SetupFailed = true; + createTemplateCallCompleted = true; + return; + } + + createdTemplateId = templateResponse.id; + createTemplateCallCompleted = true; + }); + yield return new WaitUntil(() => createTemplateCallCompleted); + if (SetupFailed) + { + yield break; + } + + bool guestLoginCompleted = false; + LootLockerSDKManager.StartGuestSession(Guid.NewGuid().ToString(), response => + { + SetupFailed |= !response.success; + sessionPlayerId = response.player_id; + guestLoginCompleted = true; + }); + yield return new WaitUntil(() => guestLoginCompleted); + if (SetupFailed) + { + yield break; + } + + bool grantCallCompleted = false; + LootLockerTestItems.GrantItemToPlayer(sessionPlayerId, createdTemplateId, 5, (grantResponse) => + { + if (grantResponse == null || !grantResponse.success) + { + Debug.LogError("Failed to grant item to player: " + grantResponse?.errorData?.message); + SetupFailed = true; + grantCallCompleted = true; + return; + } + + grantedInventoryId = grantResponse.id; + grantCallCompleted = true; + }); + yield return new WaitUntil(() => grantCallCompleted); + + 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 #####"); + } + + /// + /// The designated fast test: it covers the whole setup chain (create template with an + /// audience, grant to player) plus the central read path, so it gives early signal + /// without paying for the full suite. + /// + [UnityTest, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + [Timeout(360_000)] + public IEnumerator Items_ListItemTemplates_ReturnsCreatedTemplate() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When + bool listCallCompleted = false; + LootLockerListItemTemplatesResponse response = null; + LootLockerSDKManager.ListItemTemplates(1, 100, (r) => + { + response = r; + listCallCompleted = true; + }); + yield return new WaitUntil(() => listCallCompleted); + + // Then + Assert.IsTrue(response.success, response.errorData?.ToString() ?? "ListItemTemplates call failed"); + Assert.IsNotNull(response.items, "Item templates should not be null"); + Assert.IsTrue(response.items.Any(t => t.id == createdTemplateId), "Expected the created template to be visible to the player"); + + var template = response.items.First(t => t.id == createdTemplateId); + Assert.AreEqual(createdTemplateName, template.name, "Template name should match the created template"); + Assert.AreEqual(LootLockerItemType.stackable, template.item_type, "Template should be stackable"); + Assert.IsTrue(template.consumable, "Template should be consumable"); + Assert.IsTrue(template.deletable, "Template should be deletable"); + Assert.AreNotEqual(default(DateTime), template.created_at, "Template created_at should be populated"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_ListPlayerInventoryItems_ReturnsGrantedItem() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When + bool listCallCompleted = false; + LootLockerListPlayerInventoryItemsResponse response = null; + LootLockerSDKManager.ListPlayerInventoryItems(1, 100, onComplete: (r) => + { + response = r; + listCallCompleted = true; + }); + yield return new WaitUntil(() => listCallCompleted); + + // Then + Assert.IsTrue(response.success, response.errorData?.ToString() ?? "ListPlayerInventoryItems call failed"); + Assert.IsNotNull(response.items, "Player items should not be null"); + Assert.IsTrue(response.items.Any(i => i.id == grantedInventoryId), "Expected the granted item to be listed"); + + var item = response.items.First(i => i.id == grantedInventoryId); + Assert.AreEqual(5, item.count, "Granted count should be 5"); + Assert.AreEqual(createdTemplateName, item.name, "Item name should be the template name"); + Assert.AreEqual(LootLockerItemType.stackable, item.item_type, "Item should be stackable"); + Assert.IsTrue(item.deletable, "Item should be deletable"); + Assert.IsNotNull(item.source, "Item source should be populated"); + Assert.AreNotEqual(default(DateTime), item.created_at, "Item created_at should be populated"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_ListPlayerInventoryItems_WithFilters_ReturnsFilteredItems() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When + bool listCallCompleted = false; + LootLockerListPlayerInventoryItemsResponse response = null; + LootLockerSDKManager.ListPlayerInventoryItems(1, 100, name: createdTemplateName, itemType: LootLockerItemType.stackable, + consumable: true, sort: LootLockerItemSortField.created_at, order: LootLockerSortOrder.DESC, onComplete: (r) => + { + response = r; + listCallCompleted = true; + }); + yield return new WaitUntil(() => listCallCompleted); + + // Then + Assert.IsTrue(response.success, response.errorData?.ToString() ?? "Filtered ListPlayerInventoryItems call failed"); + Assert.IsNotNull(response.items, "Player items should not be null"); + Assert.IsTrue(response.items.Any(i => i.id == grantedInventoryId), "Expected the granted item to match the filters"); + Assert.IsTrue(response.items.All(i => i.item_type == LootLockerItemType.stackable), "All returned items should be stackable"); + Assert.IsTrue(response.items.All(i => i.consumable), "All returned items should be consumable"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_GetPlayerInventoryItem_ReturnsItemWithTemplate() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When + bool getCallCompleted = false; + LootLockerGetPlayerInventoryItemResponse response = null; + LootLockerSDKManager.GetPlayerInventoryItem(grantedInventoryId, (r) => + { + response = r; + getCallCompleted = true; + }); + yield return new WaitUntil(() => getCallCompleted); + + // Then + Assert.IsTrue(response.success, response.errorData?.ToString() ?? "GetPlayerInventoryItem call failed"); + Assert.AreEqual(grantedInventoryId, response.id, "Returned item id should match the requested id"); + Assert.AreEqual(5, response.count, "Item count should be 5"); + Assert.AreEqual(LootLockerItemType.stackable, response.item_type, "Item should be stackable"); + Assert.IsTrue(response.deletable, "Item should be deletable"); + Assert.IsNotNull(response.template, "Item template should be populated on the single item response"); + Assert.AreEqual(createdTemplateId, response.template.id, "Template id should match the created template"); + Assert.AreEqual(createdTemplateName, response.template.name, "Template name should match the created template"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_ConsumePlayerInventoryItem_PartialStack_DecrementsCount() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When + bool consumeCallCompleted = false; + LootLockerConsumeInventoryItemResponse consumeResponse = null; + LootLockerSDKManager.ConsumePlayerInventoryItem(grantedInventoryId, 2, (r) => + { + consumeResponse = r; + consumeCallCompleted = true; + }); + yield return new WaitUntil(() => consumeCallCompleted); + + // Then + Assert.IsTrue(consumeResponse.success, consumeResponse.errorData?.ToString() ?? "ConsumePlayerInventoryItem call failed"); + Assert.IsTrue(consumeResponse.consumed, "Item should report as consumed"); + + bool getCallCompleted = false; + LootLockerGetPlayerInventoryItemResponse getResponse = null; + LootLockerSDKManager.GetPlayerInventoryItem(grantedInventoryId, (r) => + { + getResponse = r; + getCallCompleted = true; + }); + yield return new WaitUntil(() => getCallCompleted); + + Assert.IsTrue(getResponse.success, getResponse.errorData?.ToString() ?? "GetPlayerInventoryItem after consume failed"); + Assert.AreEqual(3, getResponse.count, "Consuming 2 of 5 should leave 3"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_ConsumePlayerInventoryItem_WholeStack_RemovesItem() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When - passing the full count consumes the entire row + bool consumeCallCompleted = false; + LootLockerConsumeInventoryItemResponse consumeResponse = null; + LootLockerSDKManager.ConsumePlayerInventoryItem(grantedInventoryId, 5, (r) => + { + consumeResponse = r; + consumeCallCompleted = true; + }); + yield return new WaitUntil(() => consumeCallCompleted); + + // Then + Assert.IsTrue(consumeResponse.success, consumeResponse.errorData?.ToString() ?? "ConsumePlayerInventoryItem call failed"); + Assert.IsTrue(consumeResponse.consumed, "Item should report as consumed"); + + bool listCallCompleted = false; + LootLockerListPlayerInventoryItemsResponse listResponse = null; + LootLockerSDKManager.ListPlayerInventoryItems(1, 100, onComplete: (r) => + { + listResponse = r; + listCallCompleted = true; + }); + yield return new WaitUntil(() => listCallCompleted); + + Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerInventoryItems after consume failed"); + Assert.IsFalse(listResponse.items.Any(i => i.id == grantedInventoryId), "Fully consumed item should no longer be listed"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_SplitPlayerInventoryItemStack_CreatesSecondStack() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When + bool splitCallCompleted = false; + LootLockerSplitInventoryItemStackResponse splitResponse = null; + LootLockerSDKManager.SplitPlayerInventoryItemStack(grantedInventoryId, 2, (r) => + { + splitResponse = r; + splitCallCompleted = true; + }); + yield return new WaitUntil(() => splitCallCompleted); + + // Then + Assert.IsTrue(splitResponse.success, splitResponse.errorData?.ToString() ?? "SplitPlayerInventoryItemStack call failed"); + Assert.IsNotNull(splitResponse.id, "Split should return the id of the new stack"); + Assert.AreNotEqual(grantedInventoryId, splitResponse.id, "The new stack should have a different id"); + + bool listCallCompleted = false; + LootLockerListPlayerInventoryItemsResponse listResponse = null; + LootLockerSDKManager.ListPlayerInventoryItems(1, 100, onComplete: (r) => + { + listResponse = r; + listCallCompleted = true; + }); + yield return new WaitUntil(() => listCallCompleted); + + Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerInventoryItems after split failed"); + var original = listResponse.items.FirstOrDefault(i => i.id == grantedInventoryId); + var split = listResponse.items.FirstOrDefault(i => i.id == splitResponse.id); + Assert.IsNotNull(original, "Original stack should still be listed"); + Assert.IsNotNull(split, "New stack should be listed"); + Assert.AreEqual(3, original.count, "Original stack should be left with 3"); + Assert.AreEqual(2, split.count, "New stack should hold 2"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_MergePlayerInventoryItemStacks_CombinesCounts() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // Given - split off a second stack to merge back + bool splitCallCompleted = false; + LootLockerSplitInventoryItemStackResponse splitResponse = null; + LootLockerSDKManager.SplitPlayerInventoryItemStack(grantedInventoryId, 2, (r) => + { + splitResponse = r; + splitCallCompleted = true; + }); + yield return new WaitUntil(() => splitCallCompleted); + Assert.IsTrue(splitResponse.success, splitResponse.errorData?.ToString() ?? "SplitPlayerInventoryItemStack call failed"); + + // When - merge the new stack back into the original + bool mergeCallCompleted = false; + LootLockerResponse mergeResponse = null; + LootLockerSDKManager.MergePlayerInventoryItemStacks(splitResponse.id, grantedInventoryId, (r) => + { + mergeResponse = r; + mergeCallCompleted = true; + }); + yield return new WaitUntil(() => mergeCallCompleted); + + // Then + Assert.IsTrue(mergeResponse.success, mergeResponse.errorData?.ToString() ?? "MergePlayerInventoryItemStacks call failed"); + + bool listCallCompleted = false; + LootLockerListPlayerInventoryItemsResponse listResponse = null; + LootLockerSDKManager.ListPlayerInventoryItems(1, 100, onComplete: (r) => + { + listResponse = r; + listCallCompleted = true; + }); + yield return new WaitUntil(() => listCallCompleted); + + Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerInventoryItems after merge failed"); + var target = listResponse.items.FirstOrDefault(i => i.id == grantedInventoryId); + Assert.IsNotNull(target, "Target stack should still be listed"); + Assert.AreEqual(5, target.count, "Merged stack should hold the combined count of 5"); + Assert.IsFalse(listResponse.items.Any(i => i.id == splitResponse.id), "Source stack should be gone after merging"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_DeletePlayerInventoryItem_RemovesItem() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When + bool deleteCallCompleted = false; + LootLockerResponse deleteResponse = null; + LootLockerSDKManager.DeletePlayerInventoryItem(grantedInventoryId, (r) => + { + deleteResponse = r; + deleteCallCompleted = true; + }); + yield return new WaitUntil(() => deleteCallCompleted); + + // Then + Assert.IsTrue(deleteResponse.success, deleteResponse.errorData?.ToString() ?? "DeletePlayerInventoryItem call failed"); + + bool listCallCompleted = false; + LootLockerListPlayerInventoryItemsResponse listResponse = null; + LootLockerSDKManager.ListPlayerInventoryItems(1, 100, onComplete: (r) => + { + listResponse = r; + listCallCompleted = true; + }); + yield return new WaitUntil(() => listCallCompleted); + + Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerInventoryItems after delete failed"); + Assert.IsFalse(listResponse.items.Any(i => i.id == grantedInventoryId), "Deleted item should no longer be listed"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_DeletePlayerInventoryItem_NotDeletable_ReturnsForbidden() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // Given - a second template that is explicitly not deletable + bool createTemplateCallCompleted = false; + string nonDeletableTemplateId = string.Empty; + LootLockerTestItems.CreateItemTemplate(LootLockerTestItems.GetRandomItemTemplateName(), "stackable", false, false, new[] { PublicAudience }, (templateResponse) => + { + if (templateResponse == null || !templateResponse.success) + { + Debug.LogError("Failed to create non-deletable item template: " + templateResponse?.errorData?.message); + createTemplateCallCompleted = true; + return; + } + + nonDeletableTemplateId = templateResponse.id; + createTemplateCallCompleted = true; + }); + yield return new WaitUntil(() => createTemplateCallCompleted); + Assert.IsNotEmpty(nonDeletableTemplateId, "Failed to create the non-deletable template"); + + bool grantCallCompleted = false; + string nonDeletableInventoryId = string.Empty; + LootLockerTestItems.GrantItemToPlayer(sessionPlayerId, nonDeletableTemplateId, 1, (grantResponse) => + { + if (grantResponse == null || !grantResponse.success) + { + Debug.LogError("Failed to grant non-deletable item: " + grantResponse?.errorData?.message); + grantCallCompleted = true; + return; + } + + nonDeletableInventoryId = grantResponse.id; + grantCallCompleted = true; + }); + yield return new WaitUntil(() => grantCallCompleted); + Assert.IsNotEmpty(nonDeletableInventoryId, "Failed to grant the non-deletable item"); + + // When + bool deleteCallCompleted = false; + LootLockerResponse deleteResponse = null; + LootLockerSDKManager.DeletePlayerInventoryItem(nonDeletableInventoryId, (r) => + { + deleteResponse = r; + deleteCallCompleted = true; + }); + yield return new WaitUntil(() => deleteCallCompleted); + + // Then + Assert.IsFalse(deleteResponse.success, "Deleting a non-deletable item should fail"); + Assert.AreEqual(403, deleteResponse.statusCode, "Deleting a non-deletable item should return 403 Forbidden"); + } + } +} diff --git a/Tests/LootLockerTests/PlayMode/ItemTests.cs.meta b/Tests/LootLockerTests/PlayMode/ItemTests.cs.meta new file mode 100644 index 000000000..354a1e1fb --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/ItemTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7fed9865e8244b08bd29cda5c063248a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Tests/LootLockerTests/PlayMode/JsonTests.cs b/Tests/LootLockerTests/PlayMode/JsonTests.cs index f78fe1a6d..f9dbc1697 100644 --- a/Tests/LootLockerTests/PlayMode/JsonTests.cs +++ b/Tests/LootLockerTests/PlayMode/JsonTests.cs @@ -1,4 +1,5 @@ -using LootLocker; +using LootLocker; +using LootLocker.LootLockerEnums; using LootLocker.Requests; using System; using System.Collections; @@ -315,6 +316,186 @@ public void Json_DeserializationCaseConversion_CaseIsConvertedToSnake() #endif } + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_SerializingConsumeItemRequestWithoutCount_OmitsCount() + { + // Given + var request = new LootLockerConsumeInventoryItemRequest(); + + // When + string serializedJson = LootLockerJson.SerializeObject(request); + + // Then + Assert.IsNotNull(serializedJson, "Not serialized, is null"); + Assert.IsFalse(serializedJson.Contains("count"), + "Not serialized correctly, contains count even though it was never set"); + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_SerializingConsumeItemRequestWithCount_IncludesCount() + { + // Given + var request = new LootLockerConsumeInventoryItemRequest { count = 3 }; + + // When + string serializedJson = LootLockerJson.SerializeObject(request); + + // Then + Assert.IsNotNull(serializedJson, "Not serialized, is null"); + Assert.IsTrue(serializedJson.Contains("count"), + "Not serialized correctly, does not contain count"); + Assert.IsTrue(serializedJson.Contains("3"), + "Not serialized correctly, does not contain the count value"); + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_SerializingSplitItemStackRequest_IncludesCount() + { + // Given + var request = new LootLockerSplitInventoryItemStackRequest { count = 5 }; + + // When + string serializedJson = LootLockerJson.SerializeObject(request); + + // Then + Assert.IsNotNull(serializedJson, "Not serialized, is null"); + Assert.IsTrue(serializedJson.Contains("count"), + "Not serialized correctly, does not contain count"); + Assert.IsTrue(serializedJson.Contains("5"), + "Not serialized correctly, does not contain the count value"); + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_SerializingMergeItemStacksRequest_IncludesBothInventoryIds() + { + // Given + var request = new LootLockerMergeInventoryItemStacksRequest + { + source_inventory_id = "01HZZZZZZZZZZZZZZZZZZZZZZZ", + target_inventory_id = "01HYYYYYYYYYYYYYYYYYYYYYYY" + }; + + // When + string serializedJson = LootLockerJson.SerializeObject(request); + + // Then + Assert.IsNotNull(serializedJson, "Not serialized, is null"); + Assert.IsTrue(serializedJson.Contains("source_inventory_id"), + "Not serialized correctly, does not contain source_inventory_id"); + Assert.IsTrue(serializedJson.Contains("target_inventory_id"), + "Not serialized correctly, does not contain target_inventory_id"); + Assert.IsTrue(serializedJson.Contains("01HZZZZZZZZZZZZZZZZZZZZZZZ"), + "Not serialized correctly, does not contain the source inventory id value"); + Assert.IsTrue(serializedJson.Contains("01HYYYYYYYYYYYYYYYYYYYYYYY"), + "Not serialized correctly, does not contain the target inventory id value"); + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_DeserializingListPlayerInventoryItemsResponse_Succeeds() + { + // Given + const string listPlayerItemsResponse = + "{\"success\":true,\"items\":[{\"id\":\"01HZZZZZZZZZZZZZZZZZZZZZZZ\",\"player_id\":3,\"item_template_id\":\"01HAAAAAAAAAAAAAAAAAAAAAAA\",\"item_type\":\"stackable\",\"consumable\":true,\"count\":7,\"source\":\"grant\",\"name\":\"Health Potion\",\"deletable\":true,\"created_at\":\"2024-01-01T00:00:00Z\"}],\"pagination\":{\"total\":1,\"offset\":0,\"per_page\":25,\"last_page\":1,\"current_page\":1,\"next_page\":null,\"prev_page\":null}}"; + + // When + var deserialized = LootLockerJson.DeserializeObject(listPlayerItemsResponse); + + // Then + Assert.IsNotNull(deserialized, "Not deserialized, is null"); + Assert.IsNotNull(deserialized.items, "Not deserialized, items is null"); + Assert.AreEqual(1, deserialized.items.Length, "Not deserialized, wrong number of items"); + Assert.AreEqual("01HZZZZZZZZZZZZZZZZZZZZZZZ", deserialized.items[0].id, "Wrong item id"); + Assert.AreEqual(3, deserialized.items[0].player_id, "Wrong player id"); + Assert.AreEqual("01HAAAAAAAAAAAAAAAAAAAAAAA", deserialized.items[0].item_template_id, "Wrong item template id"); + Assert.AreEqual(LootLockerItemType.stackable, deserialized.items[0].item_type, "Wrong item type"); + Assert.IsTrue(deserialized.items[0].consumable, "Wrong consumable value"); + Assert.AreEqual(7, deserialized.items[0].count, "Wrong count"); + Assert.AreEqual("Health Potion", deserialized.items[0].name, "Wrong name"); + Assert.IsTrue(deserialized.items[0].deletable, "Wrong deletable value"); + Assert.AreNotEqual(default(DateTime), deserialized.items[0].created_at, "Wrong created_at value"); + Assert.IsNull(deserialized.items[0].updated_at, "Wrong updated_at value"); + Assert.IsNotNull(deserialized.pagination, "Not deserialized, pagination is null"); + Assert.AreEqual(1, deserialized.pagination.total, "Wrong pagination total"); + Assert.AreEqual(25, deserialized.pagination.per_page, "Wrong pagination per_page"); + Assert.IsNull(deserialized.pagination.next_page, "Wrong pagination next_page"); + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_DeserializingGetPlayerInventoryItemResponseWithTemplateAndMetadata_Succeeds() + { + // Given + const string getPlayerItemResponse = + "{\"success\":true,\"id\":\"01HZZZZZZZZZZZZZZZZZZZZZZZ\",\"player_id\":3,\"item_template_id\":\"01HAAAAAAAAAAAAAAAAAAAAAAA\",\"item_type\":\"instanced\",\"consumable\":false,\"count\":1,\"source\":\"grant\",\"deletable\":true,\"created_at\":\"2024-01-01T00:00:00Z\",\"template\":{\"id\":\"01HAAAAAAAAAAAAAAAAAAAAAAA\",\"name\":\"Sword\",\"game_id\":42,\"limited\":0,\"item_type\":\"instanced\",\"consumable\":false,\"deletable\":true},\"metadata\":[{\"key\":\"damage\",\"value\":12,\"type\":\"number\",\"access\":[\"game_api.read\"],\"tags\":[\"combat\"]}]}"; + + // When + var deserialized = LootLockerJson.DeserializeObject(getPlayerItemResponse); + + // Then + Assert.IsNotNull(deserialized, "Not deserialized, is null"); + Assert.AreEqual("01HZZZZZZZZZZZZZZZZZZZZZZZ", deserialized.id, "Wrong item id"); + Assert.AreEqual(LootLockerItemType.instanced, deserialized.item_type, "Wrong item type"); + Assert.IsTrue(deserialized.deletable, "Wrong deletable value"); + Assert.AreNotEqual(default(DateTime), deserialized.created_at, "Wrong created_at value"); + Assert.IsNotNull(deserialized.template, "Not deserialized, template is null"); + Assert.AreEqual("Sword", deserialized.template.name, "Wrong template name"); + Assert.AreEqual(LootLockerItemType.instanced, deserialized.template.item_type, "Wrong template item type"); + Assert.IsNotNull(deserialized.metadata, "Not deserialized, metadata is null"); + Assert.AreEqual(1, deserialized.metadata.Length, "Not deserialized, wrong number of metadata entries"); + Assert.AreEqual("damage", deserialized.metadata[0].key, "Wrong metadata key"); + Assert.AreEqual(LootLockerMetadataTypes.Number, deserialized.metadata[0].type, "Wrong metadata type"); + Assert.IsNotNull(deserialized.metadata[0].access, "Not deserialized, metadata access is null"); + Assert.AreEqual("game_api.read", deserialized.metadata[0].access[0], "Wrong metadata access"); + Assert.IsNotNull(deserialized.metadata[0].tags, "Not deserialized, metadata tags is null"); + Assert.AreEqual("combat", deserialized.metadata[0].tags[0], "Wrong metadata tag"); + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_DeserializingConsumeItemResponseWithGrantedItems_Succeeds() + { + // Given + const string consumeItemResponse = + "{\"success\":true,\"consumed\":true,\"granted\":[{\"source_id\":\"01HBBBBBBBBBBBBBBBBBBBBBBB\",\"count\":2,\"type\":\"currency\",\"name\":\"Gold\",\"code\":\"gold\"}]}"; + + // When + var deserialized = LootLockerJson.DeserializeObject(consumeItemResponse); + + // Then + Assert.IsNotNull(deserialized, "Not deserialized, is null"); + Assert.IsTrue(deserialized.consumed, "Wrong consumed value"); + Assert.IsNotNull(deserialized.granted, "Not deserialized, granted is null"); + Assert.AreEqual(1, deserialized.granted.Length, "Not deserialized, wrong number of granted items"); + Assert.AreEqual("01HBBBBBBBBBBBBBBBBBBBBBBB", deserialized.granted[0].source_id, "Wrong granted source id"); + Assert.AreEqual(2, deserialized.granted[0].count, "Wrong granted count"); + Assert.AreEqual(LootLockerRewardKind.currency, deserialized.granted[0].type, "Wrong granted type"); + Assert.AreEqual("Gold", deserialized.granted[0].name, "Wrong granted name"); + Assert.AreEqual("gold", deserialized.granted[0].code, "Wrong granted code"); + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_DeserializingListItemTemplatesResponse_Succeeds() + { + // Given + const string listItemTemplatesResponse = + "{\"success\":true,\"items\":[{\"id\":\"01HAAAAAAAAAAAAAAAAAAAAAAA\",\"name\":\"Sword\",\"game_id\":42,\"limited\":10,\"item_type\":\"instanced\",\"consumable\":false,\"deletable\":true,\"created_at\":\"2024-01-01T00:00:00Z\",\"updated_at\":\"2024-01-02T00:00:00Z\"}],\"pagination\":{\"total\":1,\"offset\":0,\"per_page\":25,\"last_page\":1,\"current_page\":1,\"next_page\":null,\"prev_page\":null}}"; + + // When + var deserialized = LootLockerJson.DeserializeObject(listItemTemplatesResponse); + + // Then + Assert.IsNotNull(deserialized, "Not deserialized, is null"); + Assert.IsNotNull(deserialized.items, "Not deserialized, items is null"); + Assert.AreEqual(1, deserialized.items.Length, "Not deserialized, wrong number of item templates"); + Assert.AreEqual("01HAAAAAAAAAAAAAAAAAAAAAAA", deserialized.items[0].id, "Wrong item template id"); + Assert.AreEqual("Sword", deserialized.items[0].name, "Wrong item template name"); + Assert.AreEqual(LootLockerItemType.instanced, deserialized.items[0].item_type, "Wrong item template item type"); + Assert.AreEqual(10, deserialized.items[0].limited, "Wrong item template limited value"); + Assert.IsTrue(deserialized.items[0].deletable, "Wrong item template deletable value"); + Assert.AreNotEqual(default(DateTime), deserialized.items[0].created_at, "Wrong item template created_at value"); + Assert.IsNotNull(deserialized.items[0].updated_at, "Wrong item template updated_at value"); + Assert.IsNotNull(deserialized.pagination, "Not deserialized, pagination is null"); + Assert.AreEqual(1, deserialized.pagination.total, "Wrong pagination total"); + } + #if !LOOTLOCKER_USE_NEWTONSOFTJSON [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] public void Json_SimpleTypeSerialization_Succeeds() @@ -450,6 +631,7 @@ public void Json_SerializingAndDesierializingEnumArrays_Works() Assert.IsNotEmpty(deserialized.enumArray); Assert.AreNotEqual(defaultEnumValue, deserialized.enumArray[0]); } + } class CustomOptions : JsonOptions 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..e2da5f273 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,862 @@ 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"); + + // Revision timestamps have second granularity, so revisions created within the same + // second are indistinguishable and their relative order is undefined. + yield return new WaitForSeconds(1f); + + // 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"); + + // Revision timestamps have second granularity, so revisions created within the same + // second are indistinguishable and their relative order is undefined. + yield return new WaitForSeconds(1f); + + // 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"); + + // Revision timestamps have second granularity, so revisions created within the same + // second are indistinguishable and their relative order is undefined. + yield return new WaitForSeconds(1f); + + // 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"); + + // Revision timestamps have second granularity, so revisions created within the same + // second are indistinguishable and their relative order is undefined. + yield return new WaitForSeconds(1f); + + 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"); + + // Revision timestamps have second granularity, so revisions created within the same + // second are indistinguishable and their relative order is undefined. + yield return new WaitForSeconds(1f); + + 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/TimezoneConverterTests.cs b/Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs new file mode 100644 index 000000000..8e643e7f4 --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs @@ -0,0 +1,59 @@ +using LootLocker; +using NUnit.Framework; + +namespace LootLockerTests.PlayMode +{ + /// + /// Guards against regressions in the IANA timezone strings produced by the SDK. + /// The backend rejects invalid IANA timezones (e.g. wrong casing such as "ETC/GMT") + /// with "invalid JSON input" errors when starting a session. + /// + public class TimezoneConverterTests + { + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void ConvertUTCOffsetToIana_UsesCorrectCasingAndSign() + { + // IANA sign convention is reversed: Etc/GMT+X means UTC-X + Assert.AreEqual("Etc/GMT+5", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(-5)); + Assert.AreEqual("Etc/GMT-2", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(2)); + Assert.AreEqual("Etc/UTC", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(0)); + #if UNITY_2021_1_OR_NEWER + // Out-of-range offsets are clamped (the converter only clamps from UNITY_2021_1_OR_NEWER) + Assert.AreEqual("Etc/GMT+12", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(-999)); + Assert.AreEqual("Etc/GMT-14", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(999)); + #endif + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void ConvertGMTOffsetToIana_UsesCorrectCasingAndSign() + { + Assert.AreEqual("Etc/GMT-5", LootLockerTimezoneConverter.convertGMTOffsetToIanaTzString(-5)); + Assert.AreEqual("Etc/GMT+2", LootLockerTimezoneConverter.convertGMTOffsetToIanaTzString(2)); + Assert.AreEqual("Etc/UTC", LootLockerTimezoneConverter.convertGMTOffsetToIanaTzString(0)); + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void TryConvertStringToIana_HandlesOffsetsWindowsAndIanaInputs() + { + // Numeric offset string + Assert.IsTrue(LootLockerTimezoneConverter.TryConvertStringToIanaTzString("2", out string fromOffset)); + Assert.AreEqual("Etc/GMT-2", fromOffset); + + // IANA passthrough + Assert.IsTrue(LootLockerTimezoneConverter.TryConvertStringToIanaTzString("Asia/Tokyo", out string iana)); + Assert.AreEqual("Asia/Tokyo", iana); + + // Windows timezone converted + Assert.IsTrue(LootLockerTimezoneConverter.TryConvertStringToIanaTzString("Romance Standard Time", out string fromWindows)); + Assert.AreEqual("Europe/Paris", fromWindows); + + // Unknown input falls back to Etc/UTC (correctly cased) + Assert.IsFalse(LootLockerTimezoneConverter.TryConvertStringToIanaTzString("NotATimezone", out string fallback)); + Assert.AreEqual("Etc/UTC", fallback); + + // Empty input falls back to Etc/UTC + Assert.IsFalse(LootLockerTimezoneConverter.TryConvertStringToIanaTzString("", out string emptyFallback)); + Assert.AreEqual("Etc/UTC", emptyFallback); + } + } +} diff --git a/Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs.meta b/Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs.meta new file mode 100644 index 000000000..799f7fd45 --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f237609cb88f46e0bb05d583a91dba17 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: 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",