From 3f508f20156c82aa76c04875c9b76b269937227f Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Mon, 6 Jul 2026 13:20:19 +0200 Subject: [PATCH 01/20] meta: Attach unitypackages from inside unity repo --- .github/workflows/package-sdk.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From cddedbe92ba00fbc2f3e449e041b12e680720ea8 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Mon, 6 Jul 2026 20:44:28 +0200 Subject: [PATCH 02/20] meta: Robustness changes to open-release-pr.yml --- .github/workflows/open-release-pr.yml | 45 ++++++++++++++------------- 1 file changed, 24 insertions(+), 21 deletions(-) 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 From 93ce03585de6b6960af409fe9cdb21d1437a40d5 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Thu, 9 Jul 2026 09:22:47 +0200 Subject: [PATCH 03/20] meta: Further robustness fixes to workflows --- .github/workflows/create-release.yml | 2 +- .github/workflows/enforce-release-pr.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 From 955c93419fb9060d0d6525bfd89a139fbe43d94b Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Thu, 16 Jul 2026 16:54:01 +0200 Subject: [PATCH 04/20] fix: Allow "clear local player data" button when disabled extension --- Runtime/Editor/Editor UI/LootLockerAdminExtension.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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)] From e5ef68e31c9b531e5e75e8e5564f2b1ddf98e1d1 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Fri, 17 Jul 2026 08:52:12 +0200 Subject: [PATCH 05/20] Add List Platform Keys endpoint Adds support for GET /game/platform-keys/v1 returning platform keys redeemed by the authenticated player. - New DTOs: LootLockerPlatformKeyCampaign, LootLockerPlatformKey, LootLockerListPlatformKeysResponse - New endpoint in LootLockerEndPoints: listPlatformKeys - New SDKManager method: ListPlatformKeys() with optional forPlayerWithUlid --- Runtime/Client/LootLockerEndPoints.cs | 4 ++ Runtime/Game/LootLockerSDKManager.cs | 19 +++++++ Runtime/Game/Requests/PlatformKeyRequests.cs | 52 +++++++++++++++++++ .../Game/Requests/PlatformKeyRequests.cs.meta | 2 + 4 files changed, 77 insertions(+) create mode 100644 Runtime/Game/Requests/PlatformKeyRequests.cs create mode 100644 Runtime/Game/Requests/PlatformKeyRequests.cs.meta diff --git a/Runtime/Client/LootLockerEndPoints.cs b/Runtime/Client/LootLockerEndPoints.cs index 1bbf294f3..9df44a207 100644 --- a/Runtime/Client/LootLockerEndPoints.cs +++ b/Runtime/Client/LootLockerEndPoints.cs @@ -253,6 +253,10 @@ public class LootLockerEndPoints public static EndPointClass getCurrencyDetails = new EndPointClass("currency/code/{0}", LootLockerHTTPMethod.GET); public static EndPointClass getCurrencyDenominationsByCode = new EndPointClass("currency/code/{0}/denominations", LootLockerHTTPMethod.GET); + // Platform Keys + [Header("Platform Keys")] + public static EndPointClass listPlatformKeys = new EndPointClass("platform-keys/v1", LootLockerHTTPMethod.GET); + // Balances [Header("Balances")] public static EndPointClass listBalancesInWallet = new EndPointClass("balances/wallet/{0}", LootLockerHTTPMethod.GET); diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index 75b96fc51..88c65c1d0 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -9046,6 +9046,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/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..eb0bc1ed9 --- /dev/null +++ b/Runtime/Game/Requests/PlatformKeyRequests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: db18f465c3edca84dacafe89af3a3ab3 \ No newline at end of file From 0c4ad76507a915886c2ec5cd029619460832aed3 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Fri, 17 Jul 2026 15:23:33 +0200 Subject: [PATCH 06/20] Fix .meta file and add doxygen group for PlatformKeys - Added MonoImporter section to PlatformKeyRequests.cs.meta to match other script meta files in the folder - Added @defgroup PlatformKeys entry in .doxygen/groups.dox so the new API appears in generated documentation --- .doxygen/groups.dox | 9 +++++++++ Runtime/Game/Requests/PlatformKeyRequests.cs.meta | 11 ++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) 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/Runtime/Game/Requests/PlatformKeyRequests.cs.meta b/Runtime/Game/Requests/PlatformKeyRequests.cs.meta index eb0bc1ed9..a94a78316 100644 --- a/Runtime/Game/Requests/PlatformKeyRequests.cs.meta +++ b/Runtime/Game/Requests/PlatformKeyRequests.cs.meta @@ -1,2 +1,11 @@ fileFormatVersion: 2 -guid: db18f465c3edca84dacafe89af3a3ab3 \ No newline at end of file +guid: db18f465c3edca84dacafe89af3a3ab3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file From ea5468f9fb5e0ff5f8a0d375297b36dda3368cea Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Thu, 9 Jul 2026 09:08:09 +0200 Subject: [PATCH 07/20] feat: Add support for WLL custom sign up fields --- Runtime/Client/LootLockerEndPoints.cs | 1 + Runtime/Game/LootLockerSDKManager.cs | 52 ++++ Runtime/Game/Requests/WhiteLabelRequest.cs | 39 +++ .../PlayMode/WhiteLabelSignUpFieldsTest.cs | 234 ++++++++++++++++++ 4 files changed, 326 insertions(+) create mode 100644 Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs diff --git a/Runtime/Client/LootLockerEndPoints.cs b/Runtime/Client/LootLockerEndPoints.cs index 9df44a207..20238cb82 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); diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index 88c65c1d0..40f6d975f 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -2893,6 +2893,58 @@ 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; + } + + LootLockerWhiteLabelUserRequest input = new LootLockerWhiteLabelUserRequest + { + 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. diff --git a/Runtime/Game/Requests/WhiteLabelRequest.cs b/Runtime/Game/Requests/WhiteLabelRequest.cs index 68a2d6b13..fcd2d736c 100644 --- a/Runtime/Game/Requests/WhiteLabelRequest.cs +++ b/Runtime/Game/Requests/WhiteLabelRequest.cs @@ -4,11 +4,29 @@ namespace LootLocker.Requests { + public class LootLockerWhiteLabelCustomFieldValue + { + public string metadata_key { get; set; } + public string 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; } public string password { get; set; } public bool remember { get; set; } + public LootLockerWhiteLabelCustomFieldValue[] custom_fields { get; set; } } public class LootLockerWhiteLabelVerifySessionRequest @@ -41,6 +59,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 { @@ -191,6 +215,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/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs b/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs new file mode 100644 index 000000000..ca6abd604 --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs @@ -0,0 +1,234 @@ +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; + } + + Assert.IsTrue(gameUnderTest?.InitializeLootLockerSDK(), "Successfully created test game and initialized LootLocker"); + + 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); + // Fields array should be present (empty if no custom fields configured on this game) + Assert.IsNotNull(actualResponse.fields, "Fields array should not be null"); + } + + // 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}"); + Assert.IsTrue(json.Contains("\"min\":\"1900-01-01\""), + $"JSON must contain the 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 LootLockerWhiteLabelUserRequest + { + email = "player@example.com", + password = "s3cur3p4ssw0rd", + remember = false, + custom_fields = new[] { customFieldValue } + }; + + // When + string json = LootLockerJson.SerializeObject(request); + Debug.Log($"Serialized sign-up request: {json}"); + + // 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}"); + Assert.IsTrue(json.Contains("\"value_json\":\"true\""), + $"JSON must contain value_json, 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"); + } + } +} From 03f5eb2004ec8f17deb5b848944faebd65f9bc4b Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Fri, 17 Jul 2026 16:25:51 +0200 Subject: [PATCH 08/20] fix: Decouple custom_fields from login request, fix test assertions - Removed custom_fields from LootLockerWhiteLabelUserRequest so login requests no longer serialize an unexpected custom_fields field - Created LootLockerWhiteLabelSignUpRequest inheriting from the base user request and carrying custom_fields - Updated both WhiteLabelSignUp overloads to use the signup-specific request type - Fixed @params JSON escaping assertion to match escaped inner quotes - Removed Debug.Log line that printed the full sign-up request (including password) to CI logs - Fixed assert message wording to describe failure instead of success Addresses review comments on PR #481 --- Runtime/Game/LootLockerSDKManager.cs | 4 ++-- Runtime/Game/Requests/WhiteLabelRequest.cs | 6 +++++- .../PlayMode/WhiteLabelSignUpFieldsTest.cs | 10 +++++----- .../PlayMode/WhiteLabelSignUpFieldsTest.cs.meta | 2 ++ 4 files changed, 14 insertions(+), 8 deletions(-) create mode 100644 Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs.meta diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index 40f6d975f..10e5395f3 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -2884,7 +2884,7 @@ public static void WhiteLabelSignUp(string email, string password, Action { 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; diff --git a/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs b/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs index ca6abd604..c501aee98 100644 --- a/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs +++ b/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs @@ -66,7 +66,7 @@ public IEnumerator Setup() yield break; } - Assert.IsTrue(gameUnderTest?.InitializeLootLockerSDK(), "Successfully created test game and initialized LootLocker"); + Assert.IsTrue(gameUnderTest?.InitializeLootLockerSDK(), "Failed to initialize LootLockerSDK"); Debug.Log($"##### Start of {this.GetType().Name} test no.{TestCounter} test case #####"); } @@ -143,8 +143,9 @@ public void CustomField_SerializeDeserialize_HandlesParamsKeywordProperty() // Then — the @params property serialized as "params" in JSON Assert.IsTrue(json.Contains("\"params\""), $"JSON must contain the key \"params\", got:\n{json}"); - Assert.IsTrue(json.Contains("\"min\":\"1900-01-01\""), - $"JSON must contain the nested JSON payload, 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); @@ -169,7 +170,7 @@ public void UserRequest_SerializeDeserialize_IncludesCustomFields() value_json = "true" }; - var request = new LootLockerWhiteLabelUserRequest + var request = new LootLockerWhiteLabelSignUpRequest { email = "player@example.com", password = "s3cur3p4ssw0rd", @@ -179,7 +180,6 @@ public void UserRequest_SerializeDeserialize_IncludesCustomFields() // When string json = LootLockerJson.SerializeObject(request); - Debug.Log($"Serialized sign-up request: {json}"); // Then — verify custom_fields appear in JSON with correct keys Assert.IsTrue(json.Contains("\"custom_fields\""), 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 From 4b0081cbc3bc5da81970df07276cf1cb0b9fb0c9 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Wed, 26 Aug 2026 16:32:39 +0200 Subject: [PATCH 09/20] feat: align SDKs with "better files" backend changes - Add revision handling for files - Add optional `key` parameter to FileStream upload overloads - Add 19 PlayMode tests covering key-based upload/upsert, key-based lookup/delete, file revisions (by ID and by key), and response field verification --- Runtime/Client/LootLockerEndPoints.cs | 8 + Runtime/Game/LootLockerSDKManager.cs | 180 +++- Runtime/Game/Requests/PlayerRequest.cs | 98 +++ Runtime/Game/Requests/RemoteSessionRequest.cs | 38 +- .../PlayMode/PlayerFilesTest.cs | 771 +++++++++++++++++- 5 files changed, 1059 insertions(+), 36 deletions(-) diff --git a/Runtime/Client/LootLockerEndPoints.cs b/Runtime/Client/LootLockerEndPoints.cs index 20238cb82..6a5aee8f8 100644 --- a/Runtime/Client/LootLockerEndPoints.cs +++ b/Runtime/Client/LootLockerEndPoints.cs @@ -74,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")] diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index 10e5395f3..f8e17b1e3 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -4080,8 +4080,9 @@ public static void GetAllPlayerFiles(int playerId, ActionPurpose of the file, example: savefile/config /// Should this file be viewable by other players? /// onComplete Action for handling the response of type LootLockerPlayerFile + /// Optional key for upsert behavior. If a file with this key already exists, it will be updated. /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. - public static void UploadPlayerFile(string pathToFile, string filePurpose, bool isPublic, Action onComplete, string forPlayerWithUlid = null) + public static void UploadPlayerFile(string pathToFile, string filePurpose, bool isPublic, Action onComplete, string key = null, string forPlayerWithUlid = null) { if (!CheckInitialized(false, forPlayerWithUlid)) { @@ -4095,6 +4096,10 @@ public static void UploadPlayerFile(string pathToFile, string filePurpose, bool { "public", isPublic.ToString().ToLower() } }; + if (!string.IsNullOrEmpty(key)) + { + body.Add("key", key); + } var fileBytes = new byte[] { }; try @@ -4125,7 +4130,7 @@ public static void UploadPlayerFile(string pathToFile, string filePurpose, bool /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. public static void UploadPlayerFile(string pathToFile, string filePurpose, Action onComplete, string forPlayerWithUlid = null) { - UploadPlayerFile(pathToFile, filePurpose, false, onComplete, forPlayerWithUlid); + UploadPlayerFile(pathToFile, filePurpose, false, onComplete, null, forPlayerWithUlid); } /// @ingroup PlayerFiles @@ -4136,8 +4141,9 @@ public static void UploadPlayerFile(string pathToFile, string filePurpose, Actio /// Purpose of the file, example: savefile/config /// Should this file be viewable by other players? /// onComplete Action for handling the response of type LootLockerPlayerFile + /// Optional key for upsert behavior. If a file with this key already exists, it will be updated. /// 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, bool isPublic, Action onComplete, string forPlayerWithUlid = null) + public static void UploadPlayerFile(FileStream fileStream, string filePurpose, bool isPublic, Action onComplete, string key = null, string forPlayerWithUlid = null) { if (!CheckInitialized(false, forPlayerWithUlid)) { @@ -4151,6 +4157,11 @@ public static void UploadPlayerFile(FileStream fileStream, string filePurpose, b { "public", isPublic.ToString().ToLower() } }; + if (!string.IsNullOrEmpty(key)) + { + body.Add("key", key); + } + var fileBytes = new byte[fileStream.Length]; try { @@ -4176,10 +4187,11 @@ public static void UploadPlayerFile(FileStream fileStream, string filePurpose, b /// Filestream to upload /// Purpose of the file, example: savefile/config /// onComplete Action for handling the response of type LootLockerPlayerFile + /// Optional key for upsert behavior. If a file with this key already exists, it will be updated. /// 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) + public static void UploadPlayerFile(FileStream fileStream, string filePurpose, Action onComplete, string key = null, string forPlayerWithUlid = null) { - UploadPlayerFile(fileStream, filePurpose, false, onComplete, forPlayerWithUlid); + UploadPlayerFile(fileStream, filePurpose, false, onComplete, key, forPlayerWithUlid); } /// @ingroup PlayerFiles @@ -4191,8 +4203,9 @@ public static void UploadPlayerFile(FileStream fileStream, string filePurpose, A /// Purpose of the file, example: savefile/config /// Should this file be viewable by other players? /// onComplete Action for handling the response of type LootLockerPlayerFile + /// Optional key for upsert behavior. If a file with this key already exists, it will be updated. /// 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, bool isPublic, Action onComplete, string forPlayerWithUlid = null) + public static void UploadPlayerFile(byte[] fileBytes, string fileName, string filePurpose, bool isPublic, Action onComplete, string key = null, string forPlayerWithUlid = null) { if (!CheckInitialized(false, forPlayerWithUlid)) { @@ -4206,6 +4219,11 @@ public static void UploadPlayerFile(byte[] fileBytes, string fileName, string fi { "public", isPublic.ToString().ToLower() } }; + if (!string.IsNullOrEmpty(key)) + { + body.Add("key", key); + } + LootLockerServerRequest.UploadFile(forPlayerWithUlid, LootLockerEndPoints.uploadPlayerFile, fileBytes, Path.GetFileName(fileName), "multipart/form-data", body, onComplete: (serverResponse) => { @@ -4224,7 +4242,7 @@ 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, false, onComplete, null, forPlayerWithUlid); } /// @ingroup PlayerFiles @@ -4345,6 +4363,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 diff --git a/Runtime/Game/Requests/PlayerRequest.cs b/Runtime/Game/Requests/PlayerRequest.cs index 22783a442..2455247c5 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,54 @@ 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 + { + /// 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 +520,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/RemoteSessionRequest.cs b/Runtime/Game/Requests/RemoteSessionRequest.cs index e536b8374..efec97937 100644 --- a/Runtime/Game/Requests/RemoteSessionRequest.cs +++ b/Runtime/Game/Requests/RemoteSessionRequest.cs @@ -56,12 +56,17 @@ 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) + public LootLockerLeaseRemoteSessionRequest(string titleId, string environmentId, string[] providers = null) { title_id = titleId; environment_id = environmentId; game_version = LootLockerConfig.current.game_version; + this.providers = providers; } } @@ -379,7 +384,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 +423,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 +464,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 +562,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/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs b/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs index 24b94a55f..135b6848c 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,748 @@ 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.UploadPlayerFile(path, "test", true, fileResponse => + { + actualResponse = fileResponse; + completed = true; + }, key: fileKey); + + 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.UploadPlayerFile(pathA, "test", true, fileResponse => + { + firstResponse = fileResponse; + firstDone = true; + }, key: fileKey); + 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.UploadPlayerFile(pathB, "test", true, fileResponse => + { + secondResponse = fileResponse; + secondDone = true; + }, key: fileKey); + 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.UploadPlayerFile(path, "test", true, fileResponse => + { + uploadedFile = fileResponse; + uploadDone = true; + }, key: fileKey); + 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 + LootLockerPlayerFile fetchedFile = new LootLockerPlayerFile(); + bool fetchDone = false; + LootLockerSDKManager.GetPlayerFileByKey("nonexistent-key-" + TestCounter, fileResponse => + { + fetchedFile = fileResponse; + fetchDone = true; + }); + yield return new WaitUntil(() => fetchDone); + + // 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.UploadPlayerFile(path, "test", true, fileResponse => + { + uploadedFile = fileResponse; + uploadDone = true; + }, key: fileKey); + 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"); + + LootLockerPlayerFile fetchedFile = new LootLockerPlayerFile(); + bool fetchDone = false; + LootLockerSDKManager.GetPlayerFileByKey(fileKey, fileResponse => + { + fetchedFile = fileResponse; + fetchDone = true; + }); + yield return new WaitUntil(() => fetchDone); + Assert.IsFalse(fetchedFile.success, "File should no longer exist after deletion by key"); + } + + // ================================================================ + // Phase 3: Revisions by ID + // ================================================================ + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_UpdateFile_CreatesNewRevision() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string pathA = CreateTempFile("Original revision content"); + string pathB = CreateTempFile("Updated revision content"); + + LootLockerPlayerFile uploadedFile = new LootLockerPlayerFile(); + bool uploadDone = false; + LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, fileResponse => + { + uploadedFile = fileResponse; + uploadDone = true; + }); + yield return new WaitUntil(() => uploadDone); + Assert.IsTrue(uploadedFile.success, "Initial upload failed"); + + // When — update the file + LootLockerPlayerFile updatedFile = new LootLockerPlayerFile(); + bool updateDone = false; + LootLockerSDKManager.UpdatePlayerFile(uploadedFile.id, pathB, fileResponse => + { + updatedFile = fileResponse; + updateDone = true; + }); + yield return new WaitUntil(() => updateDone); + Assert.IsTrue(updatedFile.success, "Update failed"); + + // Then — list revisions + LootLockerPlayerFileRevisionsResponse revisionsResponse = new LootLockerPlayerFileRevisionsResponse(); + bool revisionsDone = false; + LootLockerSDKManager.GetPlayerFileRevisions(uploadedFile.id, response => + { + revisionsResponse = response; + revisionsDone = true; + }); + yield return new WaitUntil(() => revisionsDone); + + Assert.IsTrue(revisionsResponse.success, "List revisions failed"); + Assert.GreaterOrEqual(revisionsResponse.revisions.Length, 2, "Should have at least 2 revisions after update"); + Assert.IsNotNull(revisionsResponse.current_revision_id, "Current revision ID should be set"); + Assert.AreEqual(revisionsResponse.current_revision_id, revisionsResponse.revisions[revisionsResponse.revisions.Length - 1].id, + "Current revision should be the latest"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_GetFileRevision_ReturnsSpecificRevision() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string pathA = CreateTempFile("First revision"); + string pathB = CreateTempFile("Second revision"); + + LootLockerPlayerFile uploadedFile = new LootLockerPlayerFile(); + bool uploadDone = false; + LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, fileResponse => + { + uploadedFile = fileResponse; + uploadDone = true; + }); + yield return new WaitUntil(() => uploadDone); + Assert.IsTrue(uploadedFile.success, "Initial upload failed"); + + // Update to create a second revision + bool updateDone = false; + LootLockerSDKManager.UpdatePlayerFile(uploadedFile.id, pathB, _ => { updateDone = true; }); + yield return new WaitUntil(() => updateDone); + + // 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 the first (oldest) revision + 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"); + Assert.IsFalse(string.IsNullOrEmpty(revisionContent.url), "Revision URL should not be empty"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + public IEnumerator PlayerFiles_PromoteFileRevision_RestoresOldRevision() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + // Given + string pathA = CreateTempFile("First revision content"); + string pathB = CreateTempFile("Second revision content"); + + LootLockerPlayerFile uploadedFile = new LootLockerPlayerFile(); + bool uploadDone = false; + LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, fileResponse => + { + uploadedFile = fileResponse; + uploadDone = true; + }); + yield return new WaitUntil(() => uploadDone); + Assert.IsTrue(uploadedFile.success, "Initial upload failed"); + + // Update to create revision 2 + bool updateDone = false; + LootLockerSDKManager.UpdatePlayerFile(uploadedFile.id, pathB, _ => { updateDone = true; }); + yield return new WaitUntil(() => updateDone); + + // 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"); + string firstRevisionId = revisionsResponse.revisions[0].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) + bool firstDone = false; + LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, _ => { firstDone = true; }, key: fileKey); + yield return new WaitUntil(() => firstDone); + + // Upsert with same key (creates revision 2) + bool secondDone = false; + LootLockerSDKManager.UploadPlayerFile(pathB, "test", true, _ => { secondDone = true; }, key: fileKey); + yield return new WaitUntil(() => secondDone); + + // 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"); + + bool firstDone = false; + LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, _ => { firstDone = true; }, key: fileKey); + yield return new WaitUntil(() => firstDone); + + bool secondDone = false; + LootLockerSDKManager.UploadPlayerFile(pathB, "test", true, _ => { secondDone = true; }, key: fileKey); + yield return new WaitUntil(() => secondDone); + + // 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"); + + bool firstDone = false; + LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, _ => { firstDone = true; }, key: fileKey); + yield return new WaitUntil(() => firstDone); + + bool secondDone = false; + LootLockerSDKManager.UploadPlayerFile(pathB, "test", true, _ => { secondDone = true; }, key: fileKey); + yield return new WaitUntil(() => secondDone); + + // 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"); + string firstRevisionId = revisionsResponse.revisions[0].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"); + + bool uploadADone = false; + LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, _ => { uploadADone = true; }); + yield return new WaitUntil(() => uploadADone); + + bool uploadBDone = false; + LootLockerSDKManager.UploadPlayerFile(pathB, "test", true, _ => { uploadBDone = true; }); + yield return new WaitUntil(() => uploadBDone); + + // 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"); + Assert.IsFalse(string.IsNullOrEmpty(item.url), "Each file should have a URL"); + } + } + + [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 + 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"); + } + + [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"); + + bool uploadDone = false; + LootLockerSDKManager.UploadPlayerFile(path, "test", true, _ => { uploadDone = true; }, key: fileKey); + yield return new WaitUntil(() => uploadDone); + + // 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"); + + bool firstDone = false; + LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, _ => { firstDone = true; }, key: fileKey); + yield return new WaitUntil(() => firstDone); + + bool secondDone = false; + LootLockerSDKManager.UploadPlayerFile(pathB, "test", true, _ => { secondDone = true; }, key: fileKey); + yield return new WaitUntil(() => secondDone); + + // 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"); + } } } From 97b3ba3b310f69c26cce845fc0093112a8f5950f Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Thu, 27 Aug 2026 12:53:08 +0200 Subject: [PATCH 10/20] fixes after review --- Runtime/Game/LootLockerSDKManager.cs | 209 +++++++++++++++--- Runtime/Game/Requests/PlayerRequest.cs | 3 +- .../PlayMode/PlayerFilesTest.cs | 139 +++++++++--- 3 files changed, 298 insertions(+), 53 deletions(-) diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index f8e17b1e3..9571f92ed 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -4080,9 +4080,8 @@ public static void GetAllPlayerFiles(int playerId, ActionPurpose of the file, example: savefile/config /// Should this file be viewable by other players? /// onComplete Action for handling the response of type LootLockerPlayerFile - /// Optional key for upsert behavior. If a file with this key already exists, it will be updated. /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. - public static void UploadPlayerFile(string pathToFile, string filePurpose, bool isPublic, Action onComplete, string key = null, string forPlayerWithUlid = null) + public static void UploadPlayerFile(string pathToFile, string filePurpose, bool isPublic, Action onComplete, string forPlayerWithUlid = null) { if (!CheckInitialized(false, forPlayerWithUlid)) { @@ -4096,11 +4095,6 @@ public static void UploadPlayerFile(string pathToFile, string filePurpose, bool { "public", isPublic.ToString().ToLower() } }; - if (!string.IsNullOrEmpty(key)) - { - body.Add("key", key); - } - var fileBytes = new byte[] { }; try { @@ -4130,7 +4124,7 @@ public static void UploadPlayerFile(string pathToFile, string filePurpose, bool /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. public static void UploadPlayerFile(string pathToFile, string filePurpose, Action onComplete, string forPlayerWithUlid = null) { - UploadPlayerFile(pathToFile, filePurpose, false, onComplete, null, forPlayerWithUlid); + UploadPlayerFile(pathToFile, filePurpose, false, onComplete, forPlayerWithUlid); } /// @ingroup PlayerFiles @@ -4141,9 +4135,8 @@ public static void UploadPlayerFile(string pathToFile, string filePurpose, Actio /// Purpose of the file, example: savefile/config /// Should this file be viewable by other players? /// onComplete Action for handling the response of type LootLockerPlayerFile - /// Optional key for upsert behavior. If a file with this key already exists, it will be updated. /// 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, bool isPublic, Action onComplete, string key = null, string forPlayerWithUlid = null) + public static void UploadPlayerFile(FileStream fileStream, string filePurpose, bool isPublic, Action onComplete, string forPlayerWithUlid = null) { if (!CheckInitialized(false, forPlayerWithUlid)) { @@ -4157,11 +4150,6 @@ public static void UploadPlayerFile(FileStream fileStream, string filePurpose, b { "public", isPublic.ToString().ToLower() } }; - if (!string.IsNullOrEmpty(key)) - { - body.Add("key", key); - } - var fileBytes = new byte[fileStream.Length]; try { @@ -4187,11 +4175,10 @@ public static void UploadPlayerFile(FileStream fileStream, string filePurpose, b /// Filestream to upload /// Purpose of the file, example: savefile/config /// onComplete Action for handling the response of type LootLockerPlayerFile - /// Optional key for upsert behavior. If a file with this key already exists, it will be updated. /// 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 key = null, string forPlayerWithUlid = null) + public static void UploadPlayerFile(FileStream fileStream, string filePurpose, Action onComplete, string forPlayerWithUlid = null) { - UploadPlayerFile(fileStream, filePurpose, false, onComplete, key, forPlayerWithUlid); + UploadPlayerFile(fileStream, filePurpose, isPublic: false, onComplete, forPlayerWithUlid: forPlayerWithUlid); } /// @ingroup PlayerFiles @@ -4203,9 +4190,8 @@ public static void UploadPlayerFile(FileStream fileStream, string filePurpose, A /// Purpose of the file, example: savefile/config /// Should this file be viewable by other players? /// onComplete Action for handling the response of type LootLockerPlayerFile - /// Optional key for upsert behavior. If a file with this key already exists, it will be updated. /// 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, bool isPublic, Action onComplete, string key = null, string forPlayerWithUlid = null) + public static void UploadPlayerFile(byte[] fileBytes, string fileName, string filePurpose, bool isPublic, Action onComplete, string forPlayerWithUlid = null) { if (!CheckInitialized(false, forPlayerWithUlid)) { @@ -4219,11 +4205,181 @@ public static void UploadPlayerFile(byte[] fileBytes, string fileName, string fi { "public", isPublic.ToString().ToLower() } }; - if (!string.IsNullOrEmpty(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. Can be useful if you want to upload without storing anything on disk. The file will be owned by the currently active player. + /// + /// Byte array to upload + /// Name of the file on LootLocker + /// Purpose of the file, example: savefile/config + /// 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 UploadPlayerFile(byte[] fileBytes, string fileName, string filePurpose, Action onComplete, string forPlayerWithUlid = null) + { + 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) { - body.Add("key", key); + 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) => { @@ -4233,16 +4389,19 @@ public static void UploadPlayerFile(byte[] fileBytes, string fileName, string fi /// @ingroup PlayerFiles /// - /// Upload a file using a byte array. Can be useful if you want to upload without storing anything on disk. The file will be owned by the currently active player. + /// 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 UploadPlayerFile(byte[] fileBytes, string fileName, string filePurpose, Action onComplete, string forPlayerWithUlid = null) + public static void UploadPlayerFileByKey(byte[] fileBytes, string fileName, string filePurpose, string key, Action onComplete, string forPlayerWithUlid = null) { - UploadPlayerFile(fileBytes, fileName, filePurpose, false, onComplete, null, forPlayerWithUlid); + UploadPlayerFileByKey(fileBytes, fileName, filePurpose, isPublic: false, key, onComplete, forPlayerWithUlid: forPlayerWithUlid); } /// @ingroup PlayerFiles diff --git a/Runtime/Game/Requests/PlayerRequest.cs b/Runtime/Game/Requests/PlayerRequest.cs index 2455247c5..3680fa732 100644 --- a/Runtime/Game/Requests/PlayerRequest.cs +++ b/Runtime/Game/Requests/PlayerRequest.cs @@ -437,7 +437,7 @@ public class LootLockerPlayerFileMetadata /// /// A single file revision with download URL and metadata. /// - public class LootLockerPlayerFileContent + public class LootLockerPlayerFileContent : LootLockerResponse { /// The ULID of this revision. public string id { get; set; } @@ -448,7 +448,6 @@ public class LootLockerPlayerFileContent /// When this revision was created. public DateTime created_at { get; set; } } - } /// /// Response containing asset reward notifications for the current player. diff --git a/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs b/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs index 135b6848c..7e2b1e1f2 100644 --- a/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs +++ b/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs @@ -158,11 +158,11 @@ public IEnumerator PlayerFiles_UploadWithKey_ReturnsKeyInResponse() // When LootLockerPlayerFile actualResponse = new LootLockerPlayerFile(); bool completed = false; - LootLockerSDKManager.UploadPlayerFile(path, "test", true, fileResponse => + LootLockerSDKManager.UploadPlayerFileByKey(path, "test", true, fileKey, fileResponse => { actualResponse = fileResponse; completed = true; - }, key: fileKey); + }); yield return new WaitUntil(() => completed); @@ -184,22 +184,22 @@ public IEnumerator PlayerFiles_UploadWithSameKeyTwice_UpdatesExistingFile() // When — first upload LootLockerPlayerFile firstResponse = new LootLockerPlayerFile(); bool firstDone = false; - LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, fileResponse => + LootLockerSDKManager.UploadPlayerFileByKey(pathA, "test", true, fileKey, fileResponse => { firstResponse = fileResponse; firstDone = true; - }, key: fileKey); + }); 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.UploadPlayerFile(pathB, "test", true, fileResponse => + LootLockerSDKManager.UploadPlayerFileByKey(pathB, "test", true, fileKey, fileResponse => { secondResponse = fileResponse; secondDone = true; - }, key: fileKey); + }); yield return new WaitUntil(() => secondDone); // Then @@ -267,11 +267,11 @@ public IEnumerator PlayerFiles_GetFileByKey_ReturnsCorrectFile() string path = CreateTempFile("Lookup by key content"); LootLockerPlayerFile uploadedFile = new LootLockerPlayerFile(); bool uploadDone = false; - LootLockerSDKManager.UploadPlayerFile(path, "test", true, fileResponse => + LootLockerSDKManager.UploadPlayerFileByKey(path, "test", true, fileKey, fileResponse => { uploadedFile = fileResponse; uploadDone = true; - }, key: fileKey); + }); yield return new WaitUntil(() => uploadDone); Assert.IsTrue(uploadedFile.success, "Upload for lookup test failed"); @@ -296,6 +296,8 @@ 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 => @@ -304,6 +306,7 @@ public IEnumerator PlayerFiles_GetFileByKey_NonExistentKey_Fails() fetchDone = true; }); yield return new WaitUntil(() => fetchDone); + LootLockerConfig.current.logErrorsAsWarnings = preLogErrorsAsWarningsSetting; // Then Assert.IsFalse(fetchedFile.success, "GetPlayerFileByKey should fail for non-existent key"); @@ -318,11 +321,11 @@ public IEnumerator PlayerFiles_DeleteFileByKey_RemovesFile() string path = CreateTempFile("To be deleted by key"); LootLockerPlayerFile uploadedFile = new LootLockerPlayerFile(); bool uploadDone = false; - LootLockerSDKManager.UploadPlayerFile(path, "test", true, fileResponse => + LootLockerSDKManager.UploadPlayerFileByKey(path, "test", true, fileKey, fileResponse => { uploadedFile = fileResponse; uploadDone = true; - }, key: fileKey); + }); yield return new WaitUntil(() => uploadDone); Assert.IsTrue(uploadedFile.success, "Upload for delete-by-key test failed"); @@ -339,15 +342,18 @@ public IEnumerator PlayerFiles_DeleteFileByKey_RemovesFile() // Then — verify deletion Assert.IsTrue(deleteResponse.success, "DeletePlayerFileByKey failed"); - LootLockerPlayerFile fetchedFile = new LootLockerPlayerFile(); + 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 => { - fetchedFile = fileResponse; + fetchedFileResponse = fileResponse; fetchDone = true; }); yield return new WaitUntil(() => fetchDone); - Assert.IsFalse(fetchedFile.success, "File should no longer exist after deletion by key"); + Assert.IsFalse(fetchedFileResponse.success, "File should no longer exist after deletion by key"); + LootLockerConfig.current.logErrorsAsWarnings = preLogErrorsAsWarningsSetting; } // ================================================================ @@ -419,9 +425,15 @@ public IEnumerator PlayerFiles_GetFileRevision_ReturnsSpecificRevision() Assert.IsTrue(uploadedFile.success, "Initial upload failed"); // Update to create a second revision + LootLockerPlayerFile updateResponse = new LootLockerPlayerFile(); bool updateDone = false; - LootLockerSDKManager.UpdatePlayerFile(uploadedFile.id, pathB, _ => { updateDone = true; }); + 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(); @@ -472,9 +484,15 @@ public IEnumerator PlayerFiles_PromoteFileRevision_RestoresOldRevision() Assert.IsTrue(uploadedFile.success, "Initial upload failed"); // Update to create revision 2 + LootLockerPlayerFile updateResponse = new LootLockerPlayerFile(); bool updateDone = false; - LootLockerSDKManager.UpdatePlayerFile(uploadedFile.id, pathB, _ => { updateDone = true; }); + 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(); @@ -528,14 +546,26 @@ public IEnumerator PlayerFiles_GetFileRevisionsByKey_ReturnsRevisions() string pathB = CreateTempFile("Revision B by key"); // Upload with key (creates revision 1) + LootLockerPlayerFile firstUpload = new LootLockerPlayerFile(); bool firstDone = false; - LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, _ => { firstDone = true; }, key: fileKey); + 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.UploadPlayerFile(pathB, "test", true, _ => { secondDone = true; }, key: fileKey); + 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(); @@ -563,13 +593,25 @@ public IEnumerator PlayerFiles_GetFileRevisionByKey_ReturnsSpecificRevision() string pathA = CreateTempFile("First revision by key"); string pathB = CreateTempFile("Second revision by key"); + LootLockerPlayerFile firstUpload = new LootLockerPlayerFile(); bool firstDone = false; - LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, _ => { firstDone = true; }, key: fileKey); + LootLockerSDKManager.UploadPlayerFileByKey(pathA, "test", true, fileKey, response => + { + firstUpload = response; + firstDone = true; + }); yield return new WaitUntil(() => firstDone); + Assert.IsTrue(firstUpload.success, "First upload for get revision by key test failed"); + LootLockerPlayerFile secondUpload = new LootLockerPlayerFile(); bool secondDone = false; - LootLockerSDKManager.UploadPlayerFile(pathB, "test", true, _ => { secondDone = true; }, key: fileKey); + 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(); @@ -608,13 +650,25 @@ public IEnumerator PlayerFiles_PromoteFileRevisionByKey_PromotesRevision() 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.UploadPlayerFile(pathA, "test", true, _ => { firstDone = true; }, key: fileKey); + LootLockerSDKManager.UploadPlayerFileByKey(pathA, "test", true, fileKey, response => + { + firstUpload = response; + firstDone = true; + }); yield return new WaitUntil(() => firstDone); + Assert.IsTrue(firstUpload.success, "First upload for promote by key test failed"); + LootLockerPlayerFile secondUpload = new LootLockerPlayerFile(); bool secondDone = false; - LootLockerSDKManager.UploadPlayerFile(pathB, "test", true, _ => { secondDone = true; }, key: fileKey); + 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(); @@ -699,13 +753,25 @@ public IEnumerator PlayerFiles_GetAllPlayerFiles_ReturnsFiles() string pathA = CreateTempFile("First list file"); string pathB = CreateTempFile("Second list file"); + LootLockerPlayerFile uploadA = new LootLockerPlayerFile(); bool uploadADone = false; - LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, _ => { uploadADone = true; }); + 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, _ => { uploadBDone = true; }); + 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(); @@ -758,6 +824,8 @@ public IEnumerator PlayerFiles_DeletePlayerFile_RemovesFile() 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 => @@ -767,6 +835,7 @@ public IEnumerator PlayerFiles_DeletePlayerFile_RemovesFile() }); 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")] @@ -817,9 +886,15 @@ public IEnumerator PlayerFiles_ListResponse_IncludesKeyField() string fileKey = "list-key-" + TestCounter; string path = CreateTempFile("List response key check"); + LootLockerPlayerFile uploadedFile = new LootLockerPlayerFile(); bool uploadDone = false; - LootLockerSDKManager.UploadPlayerFile(path, "test", true, _ => { uploadDone = true; }, key: fileKey); + 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(); @@ -854,13 +929,25 @@ public IEnumerator PlayerFiles_RevisionsResponse_FileMetadataHasKey() string pathA = CreateTempFile("Metadata key revision A"); string pathB = CreateTempFile("Metadata key revision B"); + LootLockerPlayerFile firstUpload = new LootLockerPlayerFile(); bool firstDone = false; - LootLockerSDKManager.UploadPlayerFile(pathA, "test", true, _ => { firstDone = true; }, key: fileKey); + 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.UploadPlayerFile(pathB, "test", true, _ => { secondDone = true; }, key: fileKey); + 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(); From 5aada3fb504904cd30d7ee56758d667101bf4f41 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Thu, 27 Aug 2026 15:58:38 +0200 Subject: [PATCH 11/20] ci: Add sign up fields for testing --- .../LootLockerTestConfigurationTitleConfig.cs | 42 +++++++++++++- .../PlayMode/WhiteLabelSignUpFieldsTest.cs | 56 ++++++++++++++++++- 2 files changed, 96 insertions(+), 2 deletions(-) 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/WhiteLabelSignUpFieldsTest.cs b/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs index c501aee98..51d021dd6 100644 --- a/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs +++ b/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs @@ -66,6 +66,45 @@ public IEnumerator Setup() 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 #####"); @@ -114,8 +153,23 @@ public IEnumerator GetSignUpFields_WithWhiteLabelEnabled_ReturnsFieldsResponse() // Then Assert.IsTrue(actualResponse.success, "GetSignUpFields returned unsuccessful: " + actualResponse.errorData?.message); - // Fields array should be present (empty if no custom fields configured on this game) 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 From 6a0e6cb063102a61b2de1b911193abbd5821dace Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Fri, 21 Aug 2026 13:06:16 +0200 Subject: [PATCH 12/20] feat: add ConnectSteamAccount, ConnectXboxAccount, ConnectNintendo, ConnectGooglePlayGames Adds four new connected account methods and their request types, plus the google_play_games = 11 enum value in LootLockerAccountProvider. --- Runtime/Game/LootLockerSDKManager.cs | 96 +++++++++++++++++++ .../Game/Requests/ConnectedAccountRequest.cs | 47 ++++++++- 2 files changed, 142 insertions(+), 1 deletion(-) diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index 9571f92ed..a3d51b181 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -2459,6 +2459,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 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 //================================================== From e011ddf746aebe285c410356d7f0163ace86bc92 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Fri, 21 Aug 2026 13:06:33 +0200 Subject: [PATCH 13/20] feat: add auto_create_profile optional to session requests Defaults to true. When false, session start fails with 404 if no profile exists. --- Runtime/Game/Requests/LootLockerSessionRequest.cs | 5 +++++ 1 file changed, 5 insertions(+) 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 From 76f01b4860f00edd27ed1bd7b4a11065068cfb58 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Fri, 21 Aug 2026 13:09:56 +0200 Subject: [PATCH 14/20] feat: add GetInt/SetInt to ILootLockerStateWriter and expose GetStateWriter() Adds integer read/write to the state writer interface with PlayerPrefs and no-op implementations, plus a public accessor on LootLockerSDKManager. --- Runtime/Client/LootLockerStateData.cs | 5 +++ Runtime/Client/LootLockerStateWriter.cs | 45 +++++++++++++++++++++++++ Runtime/Game/LootLockerSDKManager.cs | 15 +++++++++ 3 files changed, 65 insertions(+) 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/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index a3d51b181..03d7ef35d 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -152,11 +152,26 @@ public static void _OverrideLootLockerCertificateHandler(CertificateHandler cert #region SDK Customization #if LOOTLOCKER_ENABLE_OVERRIDABLE_STATE_WRITER /// @ingroup SDKCustomization + /// + /// Override the default state writer used by the SDK. This allows you to customize how the SDK saves and loads player state data. + /// The default is the default Unity Player Prefs implementation, but you can provide your own implementation of ILootLockerStateWriter to save state data in a different way (e.g. to a file, to a database, etc.). + /// + /// The state writer to use for saving and loading player state data. public static void SetStateWriter(ILootLockerStateWriter stateWriter) { LootLockerStateData.overrideStateWriter(stateWriter); } #endif + + /// @ingroup SDKCustomization + /// + /// Get the current state writer used by the SDK. This allows you to access the current implementation of ILootLockerStateWriter used for saving and loading player state data. + /// + /// The current state writer used by the SDK. + public static ILootLockerStateWriter GetStateWriter() + { + return LootLockerStateData.GetStateWriter(); + } /// @ingroup SDKCustomization /// From 45a15e63735e974df886403cc8ba5e5b59a6ee50 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Fri, 21 Aug 2026 13:11:01 +0200 Subject: [PATCH 15/20] fix: use WaitForSecondsRealtime instead of WaitForSeconds in coroutines Prevents timing drift when Time.timeScale is modified (e.g., paused games). Affects health checks, presence reconnect/ping, purchase polling, and remote session polling. --- Runtime/Client/LootLockerLifecycleManager.cs | 2 +- Runtime/Client/LootLockerPresenceClient.cs | 6 +++--- Runtime/Client/LootLockerPresenceManager.cs | 2 +- Runtime/Game/Requests/PurchaseRequest.cs | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/Runtime/Client/LootLockerLifecycleManager.cs b/Runtime/Client/LootLockerLifecycleManager.cs index 9bef1cb77..65a2c6fb5 100644 --- a/Runtime/Client/LootLockerLifecycleManager.cs +++ b/Runtime/Client/LootLockerLifecycleManager.cs @@ -572,7 +572,7 @@ private IEnumerator ServiceHealthMonitor() while (_serviceHealthMonitoringEnabled && Application.isPlaying) { - yield return new WaitForSeconds(healthCheckInterval); + yield return new WaitForSecondsRealtime(healthCheckInterval); if (_state != LifecycleManagerState.Ready) { diff --git a/Runtime/Client/LootLockerPresenceClient.cs b/Runtime/Client/LootLockerPresenceClient.cs index 716cc373d..f242075b5 100644 --- a/Runtime/Client/LootLockerPresenceClient.cs +++ b/Runtime/Client/LootLockerPresenceClient.cs @@ -478,7 +478,7 @@ private IEnumerator WaitForConnectionAndUpdateStatus(string status, Dictionary= 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); } } From 677d3e77d5dc851125e1a47e09cc7956107149f2 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Fri, 21 Aug 2026 13:11:22 +0200 Subject: [PATCH 16/20] chore: reorganize project settings UI with foldouts, suppress unreachable warnings - Groups log settings and presence settings into collapsible foldouts - Presence sub-settings now always visible (not gated behind enablePresence) - Removes unused ValidateClearLocalPlayerData method - Adds #pragma warning disable 0162 for compile-time constant branches - Adds [InspectorName(null)] on NotSet enum value --- Runtime/Editor/ProjectSettings.cs | 118 ++++++++++-------- .../UpdateChecker/LootLockerUpdateChecker.cs | 2 + Runtime/Game/Resources/LootLockerConfig.cs | 1 + 3 files changed, 72 insertions(+), 49 deletions(-) diff --git a/Runtime/Editor/ProjectSettings.cs b/Runtime/Editor/ProjectSettings.cs index 2591fbc1c..e65993356 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,84 @@ 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() + { + logSettingsFoldout = EditorGUILayout.Foldout(logSettingsFoldout, "Log Settings", true, EditorStyles.foldoutHeader); + 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); + presenceSettingsFoldout = EditorGUILayout.Foldout(presenceSettingsFoldout, "Presence Settings", true, EditorStyles.foldoutHeader); + if (!presenceSettingsFoldout) return; EditorGUILayout.Space(); if(gameSettings.enablePresence) @@ -220,39 +247,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/Resources/LootLockerConfig.cs b/Runtime/Game/Resources/LootLockerConfig.cs index 1e533dce0..3672f2e64 100644 --- a/Runtime/Game/Resources/LootLockerConfig.cs +++ b/Runtime/Game/Resources/LootLockerConfig.cs @@ -21,6 +21,7 @@ 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. /// + [InspectorName(null)] NotSet = 0, /// From c6c4b3686e8d97d7e046c7f487cabec0be0e79f2 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Fri, 28 Aug 2026 10:12:09 +0200 Subject: [PATCH 17/20] fix: Fixes after review --- Runtime/Editor/ProjectSettings.cs | 8 +++++++ Runtime/Game/Requests/RemoteSessionRequest.cs | 7 ++++++- Runtime/Game/Resources/LootLockerConfig.cs | 2 ++ .../PlayMode/MultiUserTests.cs | 21 ++++++++++++++++++- 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/Runtime/Editor/ProjectSettings.cs b/Runtime/Editor/ProjectSettings.cs index e65993356..a9cf4458d 100644 --- a/Runtime/Editor/ProjectSettings.cs +++ b/Runtime/Editor/ProjectSettings.cs @@ -186,7 +186,11 @@ private static bool IsSemverString(string str) 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(); @@ -230,7 +234,11 @@ private void DrawLogSettings() private void DrawPresenceSettings() { +#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(); diff --git a/Runtime/Game/Requests/RemoteSessionRequest.cs b/Runtime/Game/Requests/RemoteSessionRequest.cs index efec97937..e218623e4 100644 --- a/Runtime/Game/Requests/RemoteSessionRequest.cs +++ b/Runtime/Game/Requests/RemoteSessionRequest.cs @@ -61,7 +61,12 @@ public class LootLockerLeaseRemoteSessionRequest /// public string[] providers { get; set; } - public LootLockerLeaseRemoteSessionRequest(string titleId, string environmentId, string[] providers = null) + public LootLockerLeaseRemoteSessionRequest(string titleId, string environmentId) + : this(titleId, environmentId, null) + { + } + + public LootLockerLeaseRemoteSessionRequest(string titleId, string environmentId, string[] providers) { title_id = titleId; environment_id = environmentId; diff --git a/Runtime/Game/Resources/LootLockerConfig.cs b/Runtime/Game/Resources/LootLockerConfig.cs index 3672f2e64..ee177dd45 100644 --- a/Runtime/Game/Resources/LootLockerConfig.cs +++ b/Runtime/Game/Resources/LootLockerConfig.cs @@ -21,7 +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/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); } } From 9f63eb328927a8955cd2d61e1d92a0293d61c91d Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Fri, 28 Aug 2026 11:00:23 +0200 Subject: [PATCH 18/20] fix: Custom sign up field request should be json primitive --- Runtime/Game/Requests/WhiteLabelRequest.cs | 10 +++++++++- .../PlayMode/WhiteLabelSignUpFieldsTest.cs | 11 ++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/Runtime/Game/Requests/WhiteLabelRequest.cs b/Runtime/Game/Requests/WhiteLabelRequest.cs index 0c220c743..47ceff158 100644 --- a/Runtime/Game/Requests/WhiteLabelRequest.cs +++ b/Runtime/Game/Requests/WhiteLabelRequest.cs @@ -7,7 +7,15 @@ namespace LootLocker.Requests public class LootLockerWhiteLabelCustomFieldValue { public string metadata_key { get; set; } - public string value_json { 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 diff --git a/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs b/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs index 51d021dd6..d4670583b 100644 --- a/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs +++ b/Tests/LootLockerTests/PlayMode/WhiteLabelSignUpFieldsTest.cs @@ -221,7 +221,7 @@ public void UserRequest_SerializeDeserialize_IncludesCustomFields() var customFieldValue = new LootLockerWhiteLabelCustomFieldValue { metadata_key = "tos_agree", - value_json = "true" + value_json = true }; var request = new LootLockerWhiteLabelSignUpRequest @@ -240,8 +240,9 @@ public void UserRequest_SerializeDeserialize_IncludesCustomFields() $"JSON must contain \"custom_fields\", got:\n{json}"); Assert.IsTrue(json.Contains("\"metadata_key\":\"tos_agree\""), $"JSON must contain metadata_key, got:\n{json}"); - Assert.IsTrue(json.Contains("\"value_json\":\"true\""), - $"JSON must contain value_json, 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}"); @@ -261,12 +262,12 @@ public IEnumerator SignUp_WithCustomFields_Succeeds() new LootLockerWhiteLabelCustomFieldValue { metadata_key = "birth_date", - value_json = "\"2000-01-15\"" + value_json = "2000-01-15" }, new LootLockerWhiteLabelCustomFieldValue { metadata_key = "tos_agree", - value_json = "true" + value_json = true } }; From de7ee7f65ab71028b41e749bfb9afb37647b24e6 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Wed, 2 Sep 2026 14:18:08 +0200 Subject: [PATCH 19/20] fix: file tests --- .../PlayMode/PlayerFilesTest.cs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs b/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs index 7e2b1e1f2..af193ca09 100644 --- a/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs +++ b/Tests/LootLockerTests/PlayMode/PlayerFilesTest.cs @@ -402,7 +402,8 @@ public IEnumerator PlayerFiles_UpdateFile_CreatesNewRevision() 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"); - Assert.AreEqual(revisionsResponse.current_revision_id, revisionsResponse.revisions[revisionsResponse.revisions.Length - 1].id, + // 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"); } @@ -447,7 +448,7 @@ public IEnumerator PlayerFiles_GetFileRevision_ReturnsSpecificRevision() Assert.IsTrue(revisionsResponse.success, "List revisions failed"); Assert.GreaterOrEqual(revisionsResponse.revisions.Length, 2, "Should have at least 2 revisions"); - // When — get the first (oldest) revision + // 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; @@ -462,7 +463,8 @@ public IEnumerator PlayerFiles_GetFileRevision_ReturnsSpecificRevision() 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"); - Assert.IsFalse(string.IsNullOrEmpty(revisionContent.url), "Revision URL should not be empty"); + // 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")] @@ -504,7 +506,9 @@ public IEnumerator PlayerFiles_PromoteFileRevision_RestoresOldRevision() }); yield return new WaitUntil(() => revisionsDone); Assert.IsTrue(revisionsResponse.success, "List revisions failed"); - string firstRevisionId = revisionsResponse.revisions[0].id; + // 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(); @@ -680,7 +684,9 @@ public IEnumerator PlayerFiles_PromoteFileRevisionByKey_PromotesRevision() }); yield return new WaitUntil(() => revisionsDone); Assert.IsTrue(revisionsResponse.success, "List revisions by key failed"); - string firstRevisionId = revisionsResponse.revisions[0].id; + // 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(); @@ -790,7 +796,8 @@ public IEnumerator PlayerFiles_GetAllPlayerFiles_ReturnsFiles() { Assert.Greater(item.id, 0, "Each file should have a positive ID"); Assert.IsFalse(string.IsNullOrEmpty(item.name), "Each file should have a name"); - Assert.IsFalse(string.IsNullOrEmpty(item.url), "Each file should have a URL"); + // 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. } } From b7cea139aad4561b33de106a32a9831598822e14 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 2 Sep 2026 08:03:21 +0000 Subject: [PATCH 20/20] Bump version to 8.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",