From 3f508f20156c82aa76c04875c9b76b269937227f Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Mon, 6 Jul 2026 13:20:19 +0200 Subject: [PATCH 01/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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", From 37a2dc1869942a551dacbf92338c16d152b30823 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Tue, 22 Sep 2026 08:38:40 +0200 Subject: [PATCH 21/30] feat: Enable the error reporting feature by default --- Runtime/Client/LootLockerHTTPClient.cs | 2 -- Runtime/Client/LootLockerResponse.cs | 2 -- Runtime/Game/LootLockerSDKManager.cs | 2 -- 3 files changed, 6 deletions(-) diff --git a/Runtime/Client/LootLockerHTTPClient.cs b/Runtime/Client/LootLockerHTTPClient.cs index 4a166ff74..d2bab3fea 100644 --- a/Runtime/Client/LootLockerHTTPClient.cs +++ b/Runtime/Client/LootLockerHTTPClient.cs @@ -747,12 +747,10 @@ private void CallListenersAndMarkDone(LootLockerHTTPExecutionQueueItem execution response.requestContext = new LootLockerRequestContext(executionItem.RequestData.ForPlayerWithUlid, executionItem.RequestData.RequestStartTime, executionItem.RequestData.RequestId); -#if LOOTLOCKER_BETA_ENABLE_ERROR_REPORTING if (response != null && !response.success) { StoreFailedRequestReport(response, executionItem); } -#endif CurrentlyOngoingRequests.Remove(executionItem.RequestData.RequestId); executionItem.IsWaitingForSessionRefresh = false; diff --git a/Runtime/Client/LootLockerResponse.cs b/Runtime/Client/LootLockerResponse.cs index e6015004a..5e884f3c9 100644 --- a/Runtime/Client/LootLockerResponse.cs +++ b/Runtime/Client/LootLockerResponse.cs @@ -46,7 +46,6 @@ public class LootLockerResponse /// public string EventId { get; set; } = Guid.NewGuid().ToString(); -#if LOOTLOCKER_BETA_ENABLE_ERROR_REPORTING /// /// Sends a report about a failed request to be viewable in the LootLocker dashboard. /// This is intended to be used in the case where a request fails and you want to send the details of that failure to LootLocker for debugging and tracking purposes. @@ -60,7 +59,6 @@ public void ReportFailure(string userDescription, Action onC { LootLocker.Requests.LootLockerSDKManager.SendLootLockerErrorReport(userDescription, this, onComplete); } -#endif public static void Deserialize(Action onComplete, LootLockerResponse serverResponse, #if LOOTLOCKER_USE_NEWTONSOFTJSON diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index 03d7ef35d..2d104dacf 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -8755,7 +8755,6 @@ public static void SendUGCFeedback(string ulid, string description, string categ SendFeedback(LootLockerFeedbackTypes.ugc, ulid, description, category_id, onComplete, forPlayerWithUlid); } -#if LOOTLOCKER_BETA_ENABLE_ERROR_REPORTING /// /// Sends a report about a failed request to be viewable in the LootLocker dashboard. /// This is intended to be used in the case where a request fails and you want to send the details of that failure to LootLocker for debugging and tracking purposes. @@ -8863,7 +8862,6 @@ string[] RedactSensitiveHeaders(string[] headers) EndPointClass endPoint = LootLockerEndPoints.createErrorReport; LootLockerServerRequest.CallAPI(failedResponse.requestContext.player_ulid, endPoint.endPoint, endPoint.httpMethod, reportAsJson, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } -#endif /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. private static void SendFeedback(LootLockerFeedbackTypes type, string ulid, string description, string category_id, Action onComplete, string forPlayerWithUlid = null) From 17673fcc2a00476b0560c40ee7f0ccbdd501b1b3 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Tue, 22 Sep 2026 11:59:00 +0200 Subject: [PATCH 22/30] fix: replace GetInstanceID with GetEntityId on Unity 6.5+ Unity 6.5+ obsoletes Object.GetInstanceID with a compile error (CS0619), which broke the SDK on Unity 6.5/6.6. Guard the migration behind UNITY_6000_5_OR_NEWER so older supported editors keep working. Also add tests locking in the IANA timezone casing/sign convention produced by LootLockerTimezoneConverter (Etc/GMT, not ETC/GMT). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Runtime/Client/LootLockerLifecycleManager.cs | 29 ++++++++-- .../PlayMode/TimezoneConverterTests.cs | 57 +++++++++++++++++++ .../PlayMode/TimezoneConverterTests.cs.meta | 11 ++++ 3 files changed, 91 insertions(+), 6 deletions(-) create mode 100644 Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs create mode 100644 Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs.meta diff --git a/Runtime/Client/LootLockerLifecycleManager.cs b/Runtime/Client/LootLockerLifecycleManager.cs index 65a2c6fb5..f63dd2f39 100644 --- a/Runtime/Client/LootLockerLifecycleManager.cs +++ b/Runtime/Client/LootLockerLifecycleManager.cs @@ -46,7 +46,11 @@ public class LootLockerLifecycleManager : MonoBehaviour #region Instance Handling private static LootLockerLifecycleManager _instance; - private static int _instanceId = 0; +#if UNITY_6000_5_OR_NEWER + private static EntityId _instanceId = default; +#else + private static int _instanceId = default; +#endif private static GameObject _hostingGameObject = null; private static readonly object _instanceLock = new object(); @@ -70,7 +74,7 @@ private static void AutoInitialize() { Debug.LogWarning($"[LootLocker] LifecycleManager auto-initialization failed: {ex.Message}. Services will be initialized on first use."); _instance = null; - _instanceId = 0; + _instanceId = default; if (_hostingGameObject != null) { Destroy(_hostingGameObject); @@ -115,7 +119,11 @@ private static void Instantiate() { var gameObject = new GameObject("LootLockerLifecycleManager"); _instance = gameObject.AddComponent(); +#if UNITY_6000_5_OR_NEWER + _instanceId = _instance.GetEntityId(); +#else _instanceId = _instance.GetInstanceID(); +#endif _hostingGameObject = gameObject; if (Application.isPlaying) @@ -150,7 +158,7 @@ private static void TeardownInstance() #endif _instance = null; - _instanceId = 0; + _instanceId = default; _hostingGameObject = null; } @@ -168,13 +176,22 @@ public static IEnumerator CleanUpOldInstances() #endif foreach (LootLockerLifecycleManager manager in managers) { - if (manager != null && _instanceId != manager.GetInstanceID() && manager.gameObject != null && ((LootLockerLifecycleManager)manager)._isInitialized) + if (manager != null && manager.gameObject != null && ((LootLockerLifecycleManager)manager)._isInitialized) { + bool isCurrentInstance; +#if UNITY_6000_5_OR_NEWER + isCurrentInstance = _instanceId == manager.GetEntityId(); +#else + isCurrentInstance = _instanceId == manager.GetInstanceID(); +#endif + if (!isCurrentInstance) + { #if UNITY_EDITOR - DestroyImmediate(manager.gameObject); + DestroyImmediate(manager.gameObject); #else - Destroy(manager.gameObject); + Destroy(manager.gameObject); #endif + } } } yield return null; diff --git a/Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs b/Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs new file mode 100644 index 000000000..da1c1d8f2 --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs @@ -0,0 +1,57 @@ +using LootLocker; +using NUnit.Framework; + +namespace LootLockerTests.PlayMode +{ + /// + /// Guards against regressions in the IANA timezone strings produced by the SDK. + /// The backend rejects invalid IANA timezones (e.g. wrong casing such as "ETC/GMT") + /// with "invalid JSON input" errors when starting a session. + /// + public class TimezoneConverterTests + { + [Test, Category("LootLocker"), Category("LootLockerCIFast")] + public void ConvertUTCOffsetToIana_UsesCorrectCasingAndSign() + { + // IANA sign convention is reversed: Etc/GMT+X means UTC-X + Assert.AreEqual("Etc/GMT+5", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(-5)); + Assert.AreEqual("Etc/GMT-2", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(2)); + Assert.AreEqual("Etc/UTC", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(0)); + // Out-of-range offsets are clamped + Assert.AreEqual("Etc/GMT+12", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(-999)); + Assert.AreEqual("Etc/GMT-14", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(999)); + } + + [Test, Category("LootLocker"), Category("LootLockerCIFast")] + public void ConvertGMTOffsetToIana_UsesCorrectCasingAndSign() + { + Assert.AreEqual("Etc/GMT-5", LootLockerTimezoneConverter.convertGMTOffsetToIanaTzString(-5)); + Assert.AreEqual("Etc/GMT+2", LootLockerTimezoneConverter.convertGMTOffsetToIanaTzString(2)); + Assert.AreEqual("Etc/UTC", LootLockerTimezoneConverter.convertGMTOffsetToIanaTzString(0)); + } + + [Test, Category("LootLocker"), Category("LootLockerCIFast")] + public void TryConvertStringToIana_HandlesOffsetsWindowsAndIanaInputs() + { + // Numeric offset string + Assert.IsTrue(LootLockerTimezoneConverter.TryConvertStringToIanaTzString("2", out string fromOffset)); + Assert.AreEqual("Etc/GMT-2", fromOffset); + + // IANA passthrough + Assert.IsTrue(LootLockerTimezoneConverter.TryConvertStringToIanaTzString("Asia/Tokyo", out string iana)); + Assert.AreEqual("Asia/Tokyo", iana); + + // Windows timezone converted + Assert.IsTrue(LootLockerTimezoneConverter.TryConvertStringToIanaTzString("Romance Standard Time", out string fromWindows)); + Assert.AreEqual("Europe/Paris", fromWindows); + + // Unknown input falls back to Etc/UTC (correctly cased) + Assert.IsFalse(LootLockerTimezoneConverter.TryConvertStringToIanaTzString("NotATimezone", out string fallback)); + Assert.AreEqual("Etc/UTC", fallback); + + // Empty input falls back to Etc/UTC + Assert.IsFalse(LootLockerTimezoneConverter.TryConvertStringToIanaTzString("", out string emptyFallback)); + Assert.AreEqual("Etc/UTC", emptyFallback); + } + } +} diff --git a/Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs.meta b/Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs.meta new file mode 100644 index 000000000..799f7fd45 --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f237609cb88f46e0bb05d583a91dba17 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: From 63d279bfa5a0718c4709fce4dc7c19daa32adeb8 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Tue, 22 Sep 2026 14:32:31 +0200 Subject: [PATCH 23/30] Add LootLockerCI category and guard clamping asserts in timezone tests - Tag tests with the LootLockerCI category so they run on PRs targeting main. - The out-of-range clamping assertions only hold from UNITY_2021_1_OR_NEWER, since the converter only clamps under that define; guard them accordingly. --- .../PlayMode/TimezoneConverterTests.cs | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs b/Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs index da1c1d8f2..8e643e7f4 100644 --- a/Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs +++ b/Tests/LootLockerTests/PlayMode/TimezoneConverterTests.cs @@ -10,28 +10,30 @@ namespace LootLockerTests.PlayMode /// public class TimezoneConverterTests { - [Test, Category("LootLocker"), Category("LootLockerCIFast")] + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] public void ConvertUTCOffsetToIana_UsesCorrectCasingAndSign() { // IANA sign convention is reversed: Etc/GMT+X means UTC-X Assert.AreEqual("Etc/GMT+5", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(-5)); Assert.AreEqual("Etc/GMT-2", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(2)); Assert.AreEqual("Etc/UTC", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(0)); - // Out-of-range offsets are clamped - Assert.AreEqual("Etc/GMT+12", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(-999)); - Assert.AreEqual("Etc/GMT-14", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(999)); - } + #if UNITY_2021_1_OR_NEWER + // Out-of-range offsets are clamped (the converter only clamps from UNITY_2021_1_OR_NEWER) + Assert.AreEqual("Etc/GMT+12", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(-999)); + Assert.AreEqual("Etc/GMT-14", LootLockerTimezoneConverter.convertUTCOffsetToIanaTzString(999)); + #endif + } - [Test, Category("LootLocker"), Category("LootLockerCIFast")] - public void ConvertGMTOffsetToIana_UsesCorrectCasingAndSign() - { - Assert.AreEqual("Etc/GMT-5", LootLockerTimezoneConverter.convertGMTOffsetToIanaTzString(-5)); - Assert.AreEqual("Etc/GMT+2", LootLockerTimezoneConverter.convertGMTOffsetToIanaTzString(2)); - Assert.AreEqual("Etc/UTC", LootLockerTimezoneConverter.convertGMTOffsetToIanaTzString(0)); - } + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void ConvertGMTOffsetToIana_UsesCorrectCasingAndSign() + { + Assert.AreEqual("Etc/GMT-5", LootLockerTimezoneConverter.convertGMTOffsetToIanaTzString(-5)); + Assert.AreEqual("Etc/GMT+2", LootLockerTimezoneConverter.convertGMTOffsetToIanaTzString(2)); + Assert.AreEqual("Etc/UTC", LootLockerTimezoneConverter.convertGMTOffsetToIanaTzString(0)); + } - [Test, Category("LootLocker"), Category("LootLockerCIFast")] - public void TryConvertStringToIana_HandlesOffsetsWindowsAndIanaInputs() + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void TryConvertStringToIana_HandlesOffsetsWindowsAndIanaInputs() { // Numeric offset string Assert.IsTrue(LootLockerTimezoneConverter.TryConvertStringToIanaTzString("2", out string fromOffset)); From 280803132771c26a293fb9925e04eec7bbeb9d7c Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Fri, 25 Sep 2026 10:18:53 +0200 Subject: [PATCH 24/30] feat: add Items and Item Templates support Adds SDK support for the new inventory system (internally "Assets 2.0"), publicly named Items and Item Templates. Game API endpoints (base player/inventory/v1): - GET / ListItemTemplates - GET /my ListPlayerItems - GET /{id} GetPlayerItem - DELETE /{id} DeletePlayerItem - POST /{id}/consume ConsumePlayerItem - POST /{id}/split SplitPlayerItemStack - POST /merge MergePlayerItemStacks Notes: - List responses use LootLockerExtendedPagination (the backend returns an extended offset pagination shape, not the simple page/per_page/total). - metadata is typed as LootLockerMetadataEntry[]. - consumable is a nullable bool so "no filter" remains reachable; the backend only applies the filter when the query value is non-empty. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Runtime/Client/LootLockerEndPoints.cs | 10 + Runtime/Game/LootLockerSDKManager.cs | 137 +++++++++- Runtime/Game/Requests/ItemRequest.cs | 359 ++++++++++++++++++++++++++ 3 files changed, 505 insertions(+), 1 deletion(-) create mode 100644 Runtime/Game/Requests/ItemRequest.cs diff --git a/Runtime/Client/LootLockerEndPoints.cs b/Runtime/Client/LootLockerEndPoints.cs index 6a5aee8f8..c34f75b43 100644 --- a/Runtime/Client/LootLockerEndPoints.cs +++ b/Runtime/Client/LootLockerEndPoints.cs @@ -349,6 +349,16 @@ public class LootLockerEndPoints // Presence (WebSocket) [Header("Presence")] public static EndPointClass presenceWebSocket = new EndPointClass("presence/v1", LootLockerHTTPMethod.GET); + + // Items + [Header("Items")] + public static EndPointClass listItemTemplates = new EndPointClass("player/inventory/v1", LootLockerHTTPMethod.GET); + public static EndPointClass listPlayerItems = new EndPointClass("player/inventory/v1/my", LootLockerHTTPMethod.GET); + public static EndPointClass getPlayerItem = new EndPointClass("player/inventory/v1/{0}", LootLockerHTTPMethod.GET); + public static EndPointClass deletePlayerItem = new EndPointClass("player/inventory/v1/{0}", LootLockerHTTPMethod.DELETE); + public static EndPointClass consumePlayerItem = new EndPointClass("player/inventory/v1/{0}/consume", LootLockerHTTPMethod.POST); + public static EndPointClass splitPlayerItemStack = new EndPointClass("player/inventory/v1/{0}/split", LootLockerHTTPMethod.POST); + public static EndPointClass mergePlayerItemStacks = new EndPointClass("player/inventory/v1/merge", LootLockerHTTPMethod.POST); } [Serializable] diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index 2d104dacf..2b6857351 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -6626,7 +6626,142 @@ public static void DeleteAssetInstanceFromPlayerInventory(int assetInstanceID, A LootLockerAPIManager.DeleteAssetInstanceFromPlayerInventory(forPlayerWithUlid, data, onComplete); } #endregion - + + #region Items + + /// + /// Returns a paginated list of all visible item templates. + /// + /// The page of item templates to return. Defaults to 1. + /// The number of item templates to return per page. Defaults to 25. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void ListItemTemplates(int page, int perPage, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerAPIManager.ListItemTemplates(forPlayerWithUlid, page, perPage, onComplete); + } + + /// + /// Returns a paginated list of the current player's inventory items, including their item templates. + /// + /// The page of items to return. Defaults to 1. + /// The number of items to return per page. Defaults to 25. + /// (Optional) Return only items whose template name starts with the specified value. Set to null to not use this filter. + /// (Optional) Return only items of the specified type. Set to null to not use this filter. + /// (Optional) Return only items that are (or are not) consumable. Set to null to not use this filter. + /// (Optional) The field by which to sort the items. Set to null to not use this filter. + /// (Optional) The direction in which to sort the items. Set to null to not use this filter. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void ListPlayerItems(int page, int perPage, string name = null, LootLockerItemType? itemType = null, bool? consumable = null, LootLockerItemSortField? sort = null, LootLockerSortOrder? order = null, Action onComplete = null, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerAPIManager.ListPlayerItems(forPlayerWithUlid, page, perPage, name, itemType?.ToString(), consumable, sort?.ToString(), order?.ToString(), onComplete); + } + + /// + /// Returns a single inventory item for the current player, including its item template. + /// + /// The id of the inventory item to fetch. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void GetPlayerItem(string inventoryId, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerAPIManager.GetPlayerItem(forPlayerWithUlid, inventoryId, onComplete); + } + + /// + /// Deletes an inventory item from the current player's inventory. + /// + /// The id of the inventory item to delete. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void DeletePlayerItem(string inventoryId, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerAPIManager.DeletePlayerItem(forPlayerWithUlid, inventoryId, onComplete); + } + + /// + /// Consumes one or more items from a stackable inventory item. If is null, the whole stack is consumed. + /// + /// The id of the stackable inventory item to consume. + /// (Optional) The number of items to consume. If null, the whole stack is consumed. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void ConsumePlayerItem(string inventoryId, int? count, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerConsumeItemRequest data = new LootLockerConsumeItemRequest { count = count }; + LootLockerAPIManager.ConsumePlayerItem(forPlayerWithUlid, inventoryId, data, onComplete); + } + + /// + /// Splits a stackable inventory item into two stacks, moving items into a new stack. + /// + /// The id of the stackable inventory item to split. + /// The number of items to move into the new stack. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void SplitPlayerItemStack(string inventoryId, int count, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerSplitItemStackRequest data = new LootLockerSplitItemStackRequest { count = count }; + LootLockerAPIManager.SplitPlayerItemStack(forPlayerWithUlid, inventoryId, data, onComplete); + } + + /// + /// Merges two stacks of the same item into one. + /// + /// The id of the inventory item to merge from. + /// The id of the inventory item to merge into. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void MergePlayerItemStacks(string sourceInventoryId, string targetInventoryId, Action onComplete, string forPlayerWithUlid = null) + { + if (!CheckInitialized(false, forPlayerWithUlid)) + { + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + return; + } + + LootLockerMergeItemStacksRequest data = new LootLockerMergeItemStacksRequest { source_inventory_id = sourceInventoryId, target_inventory_id = targetInventoryId }; + LootLockerAPIManager.MergePlayerItemStacks(forPlayerWithUlid, data, onComplete); + } + #endregion + #region AssetInstance progressions /// @ingroup AssetInstanceProgressions diff --git a/Runtime/Game/Requests/ItemRequest.cs b/Runtime/Game/Requests/ItemRequest.cs new file mode 100644 index 000000000..1ba1a024d --- /dev/null +++ b/Runtime/Game/Requests/ItemRequest.cs @@ -0,0 +1,359 @@ +using System; +using LootLocker.Requests; +using LootLocker.Utilities.HTTP; + +namespace LootLocker.LootLockerEnums +{ + /// + /// The type of an item, determining whether the item instances are stackable or individually tracked. + /// + public enum LootLockerItemType + { + /// Each granted item is a separate, individually tracked instance. + instanced, + /// The item is stored as a single entry with a count that can be incremented or decremented. + stackable + } + + /// + /// The field by which to order a player item list response. + /// + public enum LootLockerItemSortField + { + /// Order by when the item was created. + created_at, + /// Order by when the item was last updated. + updated_at + } + + /// + /// The direction in which to order a player item list response. + /// + public enum LootLockerSortOrder + { + /// Order ascending. + ASC, + /// Order descending. + DESC + } +} + +namespace LootLocker.Requests +{ + //================================================== + // Item Template Definitions + //================================================== + + /// + /// An item template defines a type of item that can be granted to players. + /// + public class LootLockerItemTemplate + { + /// The unique id of the item template. + public string id { get; set; } + + /// The name of the item template. + public string name { get; set; } + + /// The id of the game this item template belongs to. + public int game_id { get; set; } + + /// Indicates whether this item template is limited in how many of it can exist. + public int limited { get; set; } + + /// The type of the item (instanced or stackable). + public string item_type { get; set; } + + /// True if the item can be consumed by the player. + public bool consumable { get; set; } + + /// True if the item can be deleted by the player. + public bool deletable { get; set; } + + /// The time that this item template was created. + public string created_at { get; set; } + + /// The time that this item template was last updated. + public string updated_at { get; set; } + } + + //================================================== + // Player Item Definitions + //================================================== + + /// + /// An item instance held in a player's inventory. + /// + public class LootLockerItem + { + /// The unique id of this inventory item. + public string id { get; set; } + + /// The id of the player that owns this inventory item. + public int player_id { get; set; } + + /// The id of the item template this inventory item is based on. + public string item_template_id { get; set; } + + /// The type of the item (instanced or stackable). + public string item_type { get; set; } + + /// True if the item can be consumed by the player. + public bool consumable { get; set; } + + /// True if the item can be deleted by the player. + public bool deletable { get; set; } + + /// The number of items in this stack (only relevant for stackable items). + public int count { get; set; } + + /// The name of the item template this inventory item is based on. + public string name { get; set; } + + /// The source of this inventory item, or null if not specified. + public string source { get; set; } + + /// The time that this inventory item was created. + public string created_at { get; set; } + + /// The time that this inventory item was last updated. + public string updated_at { get; set; } + + /// Arbitrary metadata attached to this inventory item. + public LootLockerMetadataEntry[] metadata { get; set; } + } + + /// + /// An item granted to a player as the result of consuming another item (or via an on-grant behaviour). + /// Matches the go-backend `GrantResult` shape returned in `granted` / `behaviour_results`. + /// + public class LootLockerGrantedItem + { + /// The source id of the granted entry (inventory id, currency id, etc.). + public string source_id { get; set; } + + /// The number of items granted. + public int count { get; set; } + + /// The type of the granted entry ("item_template", "currency", or "publisher_currency"). + public string type { get; set; } + + /// The name of the granted entry, if applicable. + public string name { get; set; } + + /// The code of the granted entry, if applicable. + public string code { get; set; } + } + + //================================================== + // Request Definitions + //================================================== + + /// + /// Request to consume a stackable item from the player's inventory. + /// + public class LootLockerConsumeItemRequest + { + /// The number of items to consume. If omitted, the whole stack is consumed. + public int? count { get; set; } + } + + /// + /// Request to split a stackable item in the player's inventory into two stacks. + /// + public class LootLockerSplitItemStackRequest + { + /// The number of items to move into the new stack. + public int count { get; set; } + } + + /// + /// Request to merge two stacks of the same item into one. + /// + public class LootLockerMergeItemStacksRequest + { + /// The id of the source inventory item to merge from. + public string source_inventory_id { get; set; } + + /// The id of the target inventory item to merge into. + public string target_inventory_id { get; set; } + } + + //================================================== + // Response Definitions + //================================================== + + /// + /// Response containing a paginated list of visible item templates. + /// + public class LootLockerListItemTemplatesResponse : LootLockerResponse + { + /// The list of visible item templates. + public LootLockerItemTemplate[] items { get; set; } + + /// Pagination details for the response. + public LootLockerExtendedPagination pagination { get; set; } + } + + /// + /// Response containing a paginated list of the player's inventory items. + /// + public class LootLockerListPlayerItemsResponse : LootLockerResponse + { + /// The list of the player's inventory items. + public LootLockerItem[] items { get; set; } + + /// Pagination details for the response. + public LootLockerExtendedPagination pagination { get; set; } + } + + /// + /// Response containing the player's inventory item, returned at the top level with its template and metadata. + /// + public class LootLockerGetPlayerItemResponse : LootLockerResponse + { + /// The unique id of this inventory item. + public string id { get; set; } + + /// The id of the player that owns this inventory item. + public int player_id { get; set; } + + /// The id of the item template this inventory item is based on. + public string item_template_id { get; set; } + + /// The type of the item (instanced or stackable). + public string item_type { get; set; } + + /// True if the item can be consumed by the player. + public bool consumable { get; set; } + + /// True if the item can be deleted by the player. + public bool deletable { get; set; } + + /// The number of items in this stack (only relevant for stackable items). + public int count { get; set; } + + /// The source of this inventory item, or null if not specified. + public string source { get; set; } + + /// The time that this inventory item was created. + public string created_at { get; set; } + + /// The time that this inventory item was last updated. + public string updated_at { get; set; } + + /// The item template this inventory item is based on. + public LootLockerItemTemplate template { get; set; } + + /// Arbitrary metadata attached to this inventory item. + public LootLockerMetadataEntry[] metadata { get; set; } + } + + /// + /// Response containing the result of consuming an item, including any items granted as a result. + /// + public class LootLockerConsumeItemResponse : LootLockerResponse + { + /// True if the item was consumed. + public bool consumed { get; set; } + + /// Any items granted as a result of consuming the item. + public LootLockerGrantedItem[] granted { get; set; } + } + + /// + /// Response containing the id of the newly created stack after splitting an item. + /// + public class LootLockerSplitItemStackResponse : LootLockerResponse + { + /// The id of the newly created item stack. + public string id { get; set; } + } +} + +namespace LootLocker +{ + //================================================== + // API Class Definition + //================================================== + + public partial class LootLockerAPIManager + { + public static void ListItemTemplates(string forPlayerWithUlid, int page, int perPage, Action onComplete) + { + EndPointClass endPoint = LootLockerEndPoints.listItemTemplates; + + var queryParams = new QueryParamaterBuilder(); + queryParams.Add("page", page); + queryParams.Add("per_page", perPage); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint + queryParams.Build(), endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + public static void ListPlayerItems(string forPlayerWithUlid, int page, int perPage, string name, string itemType, bool? consumable, string sort, string order, Action onComplete) + { + EndPointClass endPoint = LootLockerEndPoints.listPlayerItems; + + var queryParams = new QueryParamaterBuilder(); + queryParams.Add("page", page); + queryParams.Add("per_page", perPage); + queryParams.Add("name", name); + queryParams.Add("item_type", itemType); + if (consumable.HasValue) + { + queryParams.Add("consumable", consumable.Value.ToString().ToLower()); + } + queryParams.Add("sort", sort); + queryParams.Add("order", order); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint + queryParams.Build(), endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + public static void GetPlayerItem(string forPlayerWithUlid, string inventoryId, Action onComplete) + { + EndPointClass endPoint = LootLockerEndPoints.getPlayerItem; + + string getVariable = endPoint.WithPathParameter(inventoryId); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + public static void DeletePlayerItem(string forPlayerWithUlid, string inventoryId, Action onComplete) + { + EndPointClass endPoint = LootLockerEndPoints.deletePlayerItem; + + string getVariable = endPoint.WithPathParameter(inventoryId); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + public static void ConsumePlayerItem(string forPlayerWithUlid, string inventoryId, LootLockerConsumeItemRequest data, Action onComplete) + { + EndPointClass endPoint = LootLockerEndPoints.consumePlayerItem; + + string getVariable = endPoint.WithPathParameter(inventoryId); + string json = LootLockerJson.SerializeObject(data); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + public static void SplitPlayerItemStack(string forPlayerWithUlid, string inventoryId, LootLockerSplitItemStackRequest data, Action onComplete) + { + EndPointClass endPoint = LootLockerEndPoints.splitPlayerItemStack; + + string getVariable = endPoint.WithPathParameter(inventoryId); + string json = LootLockerJson.SerializeObject(data); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + + public static void MergePlayerItemStacks(string forPlayerWithUlid, LootLockerMergeItemStacksRequest data, Action onComplete) + { + EndPointClass endPoint = LootLockerEndPoints.mergePlayerItemStacks; + + string json = LootLockerJson.SerializeObject(data); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); + } + } +} From e1f8c4065d098f30cee23b52ae66e350d048eb71 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Fri, 25 Sep 2026 15:11:17 +0200 Subject: [PATCH 25/30] fix: address review remarks and add Assets 2.0 metadata sources - Add ShouldSerializecount() to LootLockerConsumeItemRequest so an unset count is omitted entirely. This removes the Newtonsoft/ZeroDep asymmetry (only ZeroDep skips nulls) and avoids sending {"count":null}, which cannot decode into uint64. - Correct the consume count documentation: the backend defaults to 1, not the whole stack. To consume an entire stack, pass the item's current count. - Remove the incorrect "Defaults to 1." / "Defaults to 25." claims from the page/perPage docs on ListItemTemplates and ListPlayerItems; both are required. - Add the missing ItemRequest.cs.meta file. - Add inventory and item_template to LootLockerMetadataSources and correct the misleading item comment (item is the legacy source, inventory is Assets 2.0). - Add 7 serialization/deserialization tests covering the new item endpoints. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Runtime/Game/LootLockerSDKManager.cs | 8 +- Runtime/Game/Requests/ItemRequest.cs | 12 +- Runtime/Game/Requests/ItemRequest.cs.meta | 11 ++ Runtime/Game/Requests/MetadataRequests.cs | 4 +- Tests/LootLockerTests/PlayMode/JsonTests.cs | 179 +++++++++++++++++++- 5 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 Runtime/Game/Requests/ItemRequest.cs.meta diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index 2b6857351..ad5cbdcef 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -6632,8 +6632,8 @@ public static void DeleteAssetInstanceFromPlayerInventory(int assetInstanceID, A /// /// Returns a paginated list of all visible item templates. /// - /// The page of item templates to return. Defaults to 1. - /// The number of item templates to return per page. Defaults to 25. + /// The page of item templates to return. + /// The number of item templates to return per page. /// Delegate for handling the server response /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. public static void ListItemTemplates(int page, int perPage, Action onComplete, string forPlayerWithUlid = null) @@ -6650,8 +6650,8 @@ public static void ListItemTemplates(int page, int perPage, Action /// Returns a paginated list of the current player's inventory items, including their item templates. /// - /// The page of items to return. Defaults to 1. - /// The number of items to return per page. Defaults to 25. + /// The page of items to return. + /// The number of items to return per page. /// (Optional) Return only items whose template name starts with the specified value. Set to null to not use this filter. /// (Optional) Return only items of the specified type. Set to null to not use this filter. /// (Optional) Return only items that are (or are not) consumable. Set to null to not use this filter. diff --git a/Runtime/Game/Requests/ItemRequest.cs b/Runtime/Game/Requests/ItemRequest.cs index 1ba1a024d..fb3954cb2 100644 --- a/Runtime/Game/Requests/ItemRequest.cs +++ b/Runtime/Game/Requests/ItemRequest.cs @@ -154,8 +154,18 @@ public class LootLockerGrantedItem /// public class LootLockerConsumeItemRequest { - /// The number of items to consume. If omitted, the whole stack is consumed. + /// + /// The number of items to consume. Defaults to 1 when omitted. To consume an entire stack, + /// pass the item's current count. + /// public int? count { get; set; } + + public bool ShouldSerializecount() + { + // Omit count entirely when unset so the backend applies its default of 1. This also keeps + // the Newtonsoft and ZeroDep JSON backends consistent, since only ZeroDep skips nulls. + return count.HasValue; + } } /// diff --git a/Runtime/Game/Requests/ItemRequest.cs.meta b/Runtime/Game/Requests/ItemRequest.cs.meta new file mode 100644 index 000000000..0f33bdad8 --- /dev/null +++ b/Runtime/Game/Requests/ItemRequest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 1c184783fa724925b081edf9211f3855 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Runtime/Game/Requests/MetadataRequests.cs b/Runtime/Game/Requests/MetadataRequests.cs index 896c274c3..f05197318 100644 --- a/Runtime/Game/Requests/MetadataRequests.cs +++ b/Runtime/Game/Requests/MetadataRequests.cs @@ -25,7 +25,9 @@ public enum LootLockerMetadataSources player = 5, self = 6, asset = 7, - item = 8, // This is the source for asset instances (player inventory items), while the "asset" source is for the asset in general + item = 8, // This is the source for legacy items, while the "inventory" source is for Assets 2.0 item instances + inventory = 9, // This is the source for Assets 2.0 item instances (player inventory items) + item_template = 10, // This is the source for Assets 2.0 item templates }; /// diff --git a/Tests/LootLockerTests/PlayMode/JsonTests.cs b/Tests/LootLockerTests/PlayMode/JsonTests.cs index f78fe1a6d..9570399e6 100644 --- a/Tests/LootLockerTests/PlayMode/JsonTests.cs +++ b/Tests/LootLockerTests/PlayMode/JsonTests.cs @@ -1,4 +1,5 @@ -using LootLocker; +using LootLocker; +using LootLocker.LootLockerEnums; using LootLocker.Requests; using System; using System.Collections; @@ -315,6 +316,181 @@ public void Json_DeserializationCaseConversion_CaseIsConvertedToSnake() #endif } + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_SerializingConsumeItemRequestWithoutCount_OmitsCount() + { + // Given + var request = new LootLockerConsumeItemRequest(); + + // When + string serializedJson = LootLockerJson.SerializeObject(request); + + // Then + Assert.IsNotNull(serializedJson, "Not serialized, is null"); + Assert.IsFalse(serializedJson.Contains("count"), + "Not serialized correctly, contains count even though it was never set"); + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_SerializingConsumeItemRequestWithCount_IncludesCount() + { + // Given + var request = new LootLockerConsumeItemRequest { count = 3 }; + + // When + string serializedJson = LootLockerJson.SerializeObject(request); + + // Then + Assert.IsNotNull(serializedJson, "Not serialized, is null"); + Assert.IsTrue(serializedJson.Contains("count"), + "Not serialized correctly, does not contain count"); + Assert.IsTrue(serializedJson.Contains("3"), + "Not serialized correctly, does not contain the count value"); + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_SerializingSplitItemStackRequest_IncludesCount() + { + // Given + var request = new LootLockerSplitItemStackRequest { count = 5 }; + + // When + string serializedJson = LootLockerJson.SerializeObject(request); + + // Then + Assert.IsNotNull(serializedJson, "Not serialized, is null"); + Assert.IsTrue(serializedJson.Contains("count"), + "Not serialized correctly, does not contain count"); + Assert.IsTrue(serializedJson.Contains("5"), + "Not serialized correctly, does not contain the count value"); + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_SerializingMergeItemStacksRequest_IncludesBothInventoryIds() + { + // Given + var request = new LootLockerMergeItemStacksRequest + { + source_inventory_id = "01HZZZZZZZZZZZZZZZZZZZZZZZ", + target_inventory_id = "01HYYYYYYYYYYYYYYYYYYYYYYY" + }; + + // When + string serializedJson = LootLockerJson.SerializeObject(request); + + // Then + Assert.IsNotNull(serializedJson, "Not serialized, is null"); + Assert.IsTrue(serializedJson.Contains("source_inventory_id"), + "Not serialized correctly, does not contain source_inventory_id"); + Assert.IsTrue(serializedJson.Contains("target_inventory_id"), + "Not serialized correctly, does not contain target_inventory_id"); + Assert.IsTrue(serializedJson.Contains("01HZZZZZZZZZZZZZZZZZZZZZZZ"), + "Not serialized correctly, does not contain the source inventory id value"); + Assert.IsTrue(serializedJson.Contains("01HYYYYYYYYYYYYYYYYYYYYYYY"), + "Not serialized correctly, does not contain the target inventory id value"); + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_DeserializingListPlayerItemsResponse_Succeeds() + { + // Given + const string listPlayerItemsResponse = + "{\"success\":true,\"items\":[{\"id\":\"01HZZZZZZZZZZZZZZZZZZZZZZZ\",\"player_id\":3,\"item_template_id\":\"01HAAAAAAAAAAAAAAAAAAAAAAA\",\"item_type\":\"stackable\",\"consumable\":true,\"count\":7,\"source\":\"grant\",\"name\":\"Health Potion\",\"deletable\":true,\"created_at\":\"2024-01-01T00:00:00Z\"}],\"pagination\":{\"total\":1,\"offset\":0,\"per_page\":25,\"last_page\":1,\"current_page\":1,\"next_page\":null,\"prev_page\":null}}"; + + // When + var deserialized = LootLockerJson.DeserializeObject(listPlayerItemsResponse); + + // Then + Assert.IsNotNull(deserialized, "Not deserialized, is null"); + Assert.IsNotNull(deserialized.items, "Not deserialized, items is null"); + Assert.AreEqual(1, deserialized.items.Length, "Not deserialized, wrong number of items"); + Assert.AreEqual("01HZZZZZZZZZZZZZZZZZZZZZZZ", deserialized.items[0].id, "Wrong item id"); + Assert.AreEqual(3, deserialized.items[0].player_id, "Wrong player id"); + Assert.AreEqual("01HAAAAAAAAAAAAAAAAAAAAAAA", deserialized.items[0].item_template_id, "Wrong item template id"); + Assert.AreEqual("stackable", deserialized.items[0].item_type, "Wrong item type"); + Assert.IsTrue(deserialized.items[0].consumable, "Wrong consumable value"); + Assert.AreEqual(7, deserialized.items[0].count, "Wrong count"); + Assert.AreEqual("Health Potion", deserialized.items[0].name, "Wrong name"); + Assert.IsTrue(deserialized.items[0].deletable, "Wrong deletable value"); + Assert.IsNotNull(deserialized.pagination, "Not deserialized, pagination is null"); + Assert.AreEqual(1, deserialized.pagination.total, "Wrong pagination total"); + Assert.AreEqual(25, deserialized.pagination.per_page, "Wrong pagination per_page"); + Assert.IsNull(deserialized.pagination.next_page, "Wrong pagination next_page"); + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_DeserializingGetPlayerItemResponseWithTemplateAndMetadata_Succeeds() + { + // Given + const string getPlayerItemResponse = + "{\"success\":true,\"id\":\"01HZZZZZZZZZZZZZZZZZZZZZZZ\",\"player_id\":3,\"item_template_id\":\"01HAAAAAAAAAAAAAAAAAAAAAAA\",\"item_type\":\"instanced\",\"consumable\":false,\"count\":1,\"source\":\"grant\",\"deletable\":true,\"created_at\":\"2024-01-01T00:00:00Z\",\"template\":{\"id\":\"01HAAAAAAAAAAAAAAAAAAAAAAA\",\"name\":\"Sword\",\"game_id\":42,\"limited\":0,\"item_type\":\"instanced\",\"consumable\":false,\"deletable\":true},\"metadata\":[{\"key\":\"damage\",\"value\":12,\"type\":\"number\",\"access\":[\"game_api.read\"],\"tags\":[\"combat\"]}]}"; + + // When + var deserialized = LootLockerJson.DeserializeObject(getPlayerItemResponse); + + // Then + Assert.IsNotNull(deserialized, "Not deserialized, is null"); + Assert.AreEqual("01HZZZZZZZZZZZZZZZZZZZZZZZ", deserialized.id, "Wrong item id"); + Assert.AreEqual("instanced", deserialized.item_type, "Wrong item type"); + Assert.IsTrue(deserialized.deletable, "Wrong deletable value"); + Assert.IsNotNull(deserialized.template, "Not deserialized, template is null"); + Assert.AreEqual("Sword", deserialized.template.name, "Wrong template name"); + Assert.AreEqual(42, deserialized.template.game_id, "Wrong template game id"); + Assert.IsNotNull(deserialized.metadata, "Not deserialized, metadata is null"); + Assert.AreEqual(1, deserialized.metadata.Length, "Not deserialized, wrong number of metadata entries"); + Assert.AreEqual("damage", deserialized.metadata[0].key, "Wrong metadata key"); + Assert.AreEqual(LootLockerMetadataTypes.Number, deserialized.metadata[0].type, "Wrong metadata type"); + Assert.IsNotNull(deserialized.metadata[0].access, "Not deserialized, metadata access is null"); + Assert.AreEqual("game_api.read", deserialized.metadata[0].access[0], "Wrong metadata access"); + Assert.IsNotNull(deserialized.metadata[0].tags, "Not deserialized, metadata tags is null"); + Assert.AreEqual("combat", deserialized.metadata[0].tags[0], "Wrong metadata tag"); + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_DeserializingConsumeItemResponseWithGrantedItems_Succeeds() + { + // Given + const string consumeItemResponse = + "{\"success\":true,\"consumed\":true,\"granted\":[{\"source_id\":\"01HBBBBBBBBBBBBBBBBBBBBBBB\",\"count\":2,\"type\":\"currency\",\"name\":\"Gold\",\"code\":\"gold\"}]}"; + + // When + var deserialized = LootLockerJson.DeserializeObject(consumeItemResponse); + + // Then + Assert.IsNotNull(deserialized, "Not deserialized, is null"); + Assert.IsTrue(deserialized.consumed, "Wrong consumed value"); + Assert.IsNotNull(deserialized.granted, "Not deserialized, granted is null"); + Assert.AreEqual(1, deserialized.granted.Length, "Not deserialized, wrong number of granted items"); + Assert.AreEqual("01HBBBBBBBBBBBBBBBBBBBBBBB", deserialized.granted[0].source_id, "Wrong granted source id"); + Assert.AreEqual(2, deserialized.granted[0].count, "Wrong granted count"); + Assert.AreEqual("currency", deserialized.granted[0].type, "Wrong granted type"); + Assert.AreEqual("Gold", deserialized.granted[0].name, "Wrong granted name"); + Assert.AreEqual("gold", deserialized.granted[0].code, "Wrong granted code"); + } + + [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + public void Json_DeserializingListItemTemplatesResponse_Succeeds() + { + // Given + const string listItemTemplatesResponse = + "{\"success\":true,\"items\":[{\"id\":\"01HAAAAAAAAAAAAAAAAAAAAAAA\",\"name\":\"Sword\",\"game_id\":42,\"limited\":10,\"item_type\":\"instanced\",\"consumable\":false,\"deletable\":true,\"created_at\":\"2024-01-01T00:00:00Z\",\"updated_at\":\"2024-01-02T00:00:00Z\"}],\"pagination\":{\"total\":1,\"offset\":0,\"per_page\":25,\"last_page\":1,\"current_page\":1,\"next_page\":null,\"prev_page\":null}}"; + + // When + var deserialized = LootLockerJson.DeserializeObject(listItemTemplatesResponse); + + // Then + Assert.IsNotNull(deserialized, "Not deserialized, is null"); + Assert.IsNotNull(deserialized.items, "Not deserialized, items is null"); + Assert.AreEqual(1, deserialized.items.Length, "Not deserialized, wrong number of item templates"); + Assert.AreEqual("01HAAAAAAAAAAAAAAAAAAAAAAA", deserialized.items[0].id, "Wrong item template id"); + Assert.AreEqual("Sword", deserialized.items[0].name, "Wrong item template name"); + Assert.AreEqual(42, deserialized.items[0].game_id, "Wrong item template game id"); + Assert.AreEqual(10, deserialized.items[0].limited, "Wrong item template limited value"); + Assert.IsTrue(deserialized.items[0].deletable, "Wrong item template deletable value"); + Assert.IsNotNull(deserialized.pagination, "Not deserialized, pagination is null"); + Assert.AreEqual(1, deserialized.pagination.total, "Wrong pagination total"); + } + #if !LOOTLOCKER_USE_NEWTONSOFTJSON [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] public void Json_SimpleTypeSerialization_Succeeds() @@ -450,6 +626,7 @@ public void Json_SerializingAndDesierializingEnumArrays_Works() Assert.IsNotEmpty(deserialized.enumArray); Assert.AreNotEqual(defaultEnumValue, deserialized.enumArray[0]); } + } class CustomOptions : JsonOptions From 774c05b8d0402ac55d173e85d0294ec939bc55e8 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Mon, 28 Sep 2026 12:31:32 +0200 Subject: [PATCH 26/30] fix(unity): correct item DTO types and inline API manager methods Address PR review feedback on the Items / Item Templates DTOs: - Remove game_id from item and item template DTOs; the SDK always operates in a game context so the field is redundant. - Type item_type as LootLockerItemType instead of string. - Type created_at / updated_at as DateTime (updated_at nullable, since the backend omits it when unset). - Type granted item `type` as the new LootLockerRewardKind enum. - Add `source` to LootLockerItemSortField. - Only append query parameters when they carry a meaningful value, so unset filters are not sent as empty/zero values. - Inline the seven item methods from the LootLockerAPIManager partial class into LootLockerSDKManager, matching the convention used by the rest of the SDK. - Update the item JSON tests for the new types. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Runtime/Game/LootLockerSDKManager.cs | 64 +++++-- Runtime/Game/Requests/ItemRequest.cs | 158 ++++++------------ Runtime/Game/Requests/MetadataRequests.cs | 6 +- .../LootLockerTestConfigurationMetadata.cs | 2 + Tests/LootLockerTests/PlayMode/JsonTests.cs | 15 +- 5 files changed, 122 insertions(+), 123 deletions(-) diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index ad5cbdcef..20b8eab01 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -6644,7 +6644,15 @@ public static void ListItemTemplates(int page, int perPage, Action 0) + queryParams.Add("page", page); + if (perPage > 0) + queryParams.Add("per_page", perPage); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint + queryParams.Build(), endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } /// @@ -6667,7 +6675,24 @@ public static void ListPlayerItems(int page, int perPage, string name = null, Lo return; } - LootLockerAPIManager.ListPlayerItems(forPlayerWithUlid, page, perPage, name, itemType?.ToString(), consumable, sort?.ToString(), order?.ToString(), onComplete); + EndPointClass endPoint = LootLockerEndPoints.listPlayerItems; + + var queryParams = new LootLocker.Utilities.HTTP.QueryParamaterBuilder(); + if (page > 0) + queryParams.Add("page", page); + if (perPage > 0) + queryParams.Add("per_page", perPage); + queryParams.Add("name", name); + if (itemType.HasValue) + queryParams.Add("item_type", itemType.Value.ToString()); + if (consumable.HasValue) + queryParams.Add("consumable", consumable.Value.ToString().ToLower()); + if (sort.HasValue) + queryParams.Add("sort", sort.Value.ToString()); + if (order.HasValue) + queryParams.Add("order", order.Value.ToString()); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint + queryParams.Build(), endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } /// @@ -6684,7 +6709,11 @@ public static void GetPlayerItem(string inventoryId, Action { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } /// @@ -6701,7 +6730,11 @@ public static void DeletePlayerItem(string inventoryId, Action { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } /// @@ -6719,8 +6752,12 @@ public static void ConsumePlayerItem(string inventoryId, int? count, Action { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } /// @@ -6738,8 +6775,12 @@ public static void SplitPlayerItemStack(string inventoryId, int count, Action { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } /// @@ -6757,8 +6798,11 @@ public static void MergePlayerItemStacks(string sourceInventoryId, string target return; } - LootLockerMergeItemStacksRequest data = new LootLockerMergeItemStacksRequest { source_inventory_id = sourceInventoryId, target_inventory_id = targetInventoryId }; - LootLockerAPIManager.MergePlayerItemStacks(forPlayerWithUlid, data, onComplete); + EndPointClass endPoint = LootLockerEndPoints.mergePlayerItemStacks; + + string json = LootLockerJson.SerializeObject(new LootLockerMergeItemStacksRequest { source_inventory_id = sourceInventoryId, target_inventory_id = targetInventoryId }); + + LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } #endregion diff --git a/Runtime/Game/Requests/ItemRequest.cs b/Runtime/Game/Requests/ItemRequest.cs index fb3954cb2..b3f375259 100644 --- a/Runtime/Game/Requests/ItemRequest.cs +++ b/Runtime/Game/Requests/ItemRequest.cs @@ -1,6 +1,6 @@ using System; +using LootLocker.LootLockerEnums; using LootLocker.Requests; -using LootLocker.Utilities.HTTP; namespace LootLocker.LootLockerEnums { @@ -23,7 +23,44 @@ public enum LootLockerItemSortField /// Order by when the item was created. created_at, /// Order by when the item was last updated. - updated_at + updated_at, + /// Order by the source that granted the item. + source + } + + /// + /// The kind of reward that was granted as a result of a behaviour. + /// + public enum LootLockerRewardKind + { + /// The reward kind could not be determined. + unknown, + /// An asset. + asset, + /// Progression points. + progression_points, + /// A progression reset. + progression_reset, + /// A currency. + currency, + /// A group. + group, + /// A reward. + reward, + /// A platform key. + platform_key, + /// A publisher currency. + publisher_currency, + /// Publisher progression points. + publisher_progression_points, + /// Player metadata. + player_metadata, + /// A file. + file, + /// A Discord role. + discord_role, + /// An item template. + item_template } /// @@ -55,14 +92,11 @@ public class LootLockerItemTemplate /// The name of the item template. public string name { get; set; } - /// The id of the game this item template belongs to. - public int game_id { get; set; } - /// Indicates whether this item template is limited in how many of it can exist. public int limited { get; set; } /// The type of the item (instanced or stackable). - public string item_type { get; set; } + public LootLockerItemType item_type { get; set; } /// True if the item can be consumed by the player. public bool consumable { get; set; } @@ -71,10 +105,10 @@ public class LootLockerItemTemplate public bool deletable { get; set; } /// The time that this item template was created. - public string created_at { get; set; } + public DateTime created_at { get; set; } - /// The time that this item template was last updated. - public string updated_at { get; set; } + /// The time that this item template was last updated, or null if it has never been updated. + public DateTime? updated_at { get; set; } } //================================================== @@ -96,7 +130,7 @@ public class LootLockerItem public string item_template_id { get; set; } /// The type of the item (instanced or stackable). - public string item_type { get; set; } + public LootLockerItemType item_type { get; set; } /// True if the item can be consumed by the player. public bool consumable { get; set; } @@ -114,10 +148,10 @@ public class LootLockerItem public string source { get; set; } /// The time that this inventory item was created. - public string created_at { get; set; } + public DateTime created_at { get; set; } - /// The time that this inventory item was last updated. - public string updated_at { get; set; } + /// The time that this inventory item was last updated, or null if it has never been updated. + public DateTime? updated_at { get; set; } /// Arbitrary metadata attached to this inventory item. public LootLockerMetadataEntry[] metadata { get; set; } @@ -135,8 +169,8 @@ public class LootLockerGrantedItem /// The number of items granted. public int count { get; set; } - /// The type of the granted entry ("item_template", "currency", or "publisher_currency"). - public string type { get; set; } + /// The type of the granted entry. + public LootLockerRewardKind type { get; set; } /// The name of the granted entry, if applicable. public string name { get; set; } @@ -232,7 +266,7 @@ public class LootLockerGetPlayerItemResponse : LootLockerResponse public string item_template_id { get; set; } /// The type of the item (instanced or stackable). - public string item_type { get; set; } + public LootLockerItemType item_type { get; set; } /// True if the item can be consumed by the player. public bool consumable { get; set; } @@ -247,10 +281,10 @@ public class LootLockerGetPlayerItemResponse : LootLockerResponse public string source { get; set; } /// The time that this inventory item was created. - public string created_at { get; set; } + public DateTime created_at { get; set; } - /// The time that this inventory item was last updated. - public string updated_at { get; set; } + /// The time that this inventory item was last updated, or null if it has never been updated. + public DateTime? updated_at { get; set; } /// The item template this inventory item is based on. public LootLockerItemTemplate template { get; set; } @@ -281,89 +315,3 @@ public class LootLockerSplitItemStackResponse : LootLockerResponse } } -namespace LootLocker -{ - //================================================== - // API Class Definition - //================================================== - - public partial class LootLockerAPIManager - { - public static void ListItemTemplates(string forPlayerWithUlid, int page, int perPage, Action onComplete) - { - EndPointClass endPoint = LootLockerEndPoints.listItemTemplates; - - var queryParams = new QueryParamaterBuilder(); - queryParams.Add("page", page); - queryParams.Add("per_page", perPage); - - LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint + queryParams.Build(), endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); - } - - public static void ListPlayerItems(string forPlayerWithUlid, int page, int perPage, string name, string itemType, bool? consumable, string sort, string order, Action onComplete) - { - EndPointClass endPoint = LootLockerEndPoints.listPlayerItems; - - var queryParams = new QueryParamaterBuilder(); - queryParams.Add("page", page); - queryParams.Add("per_page", perPage); - queryParams.Add("name", name); - queryParams.Add("item_type", itemType); - if (consumable.HasValue) - { - queryParams.Add("consumable", consumable.Value.ToString().ToLower()); - } - queryParams.Add("sort", sort); - queryParams.Add("order", order); - - LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint + queryParams.Build(), endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); - } - - public static void GetPlayerItem(string forPlayerWithUlid, string inventoryId, Action onComplete) - { - EndPointClass endPoint = LootLockerEndPoints.getPlayerItem; - - string getVariable = endPoint.WithPathParameter(inventoryId); - - LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); - } - - public static void DeletePlayerItem(string forPlayerWithUlid, string inventoryId, Action onComplete) - { - EndPointClass endPoint = LootLockerEndPoints.deletePlayerItem; - - string getVariable = endPoint.WithPathParameter(inventoryId); - - LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); - } - - public static void ConsumePlayerItem(string forPlayerWithUlid, string inventoryId, LootLockerConsumeItemRequest data, Action onComplete) - { - EndPointClass endPoint = LootLockerEndPoints.consumePlayerItem; - - string getVariable = endPoint.WithPathParameter(inventoryId); - string json = LootLockerJson.SerializeObject(data); - - LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); - } - - public static void SplitPlayerItemStack(string forPlayerWithUlid, string inventoryId, LootLockerSplitItemStackRequest data, Action onComplete) - { - EndPointClass endPoint = LootLockerEndPoints.splitPlayerItemStack; - - string getVariable = endPoint.WithPathParameter(inventoryId); - string json = LootLockerJson.SerializeObject(data); - - LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); - } - - public static void MergePlayerItemStacks(string forPlayerWithUlid, LootLockerMergeItemStacksRequest data, Action onComplete) - { - EndPointClass endPoint = LootLockerEndPoints.mergePlayerItemStacks; - - string json = LootLockerJson.SerializeObject(data); - - LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); - } - } -} diff --git a/Runtime/Game/Requests/MetadataRequests.cs b/Runtime/Game/Requests/MetadataRequests.cs index f05197318..f99d0fa0d 100644 --- a/Runtime/Game/Requests/MetadataRequests.cs +++ b/Runtime/Game/Requests/MetadataRequests.cs @@ -25,9 +25,9 @@ public enum LootLockerMetadataSources player = 5, self = 6, asset = 7, - item = 8, // This is the source for legacy items, while the "inventory" source is for Assets 2.0 item instances - inventory = 9, // This is the source for Assets 2.0 item instances (player inventory items) - item_template = 10, // This is the source for Assets 2.0 item templates + item = 8, // This is the source for legacy items, while the "inventory" source is for item instances + inventory = 9, // This is the source for item instances (player inventory items) + item_template = 10, // This is the source for item templates }; /// diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs index 329e69284..8ff483081 100644 --- a/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs @@ -49,6 +49,8 @@ public enum LootLockerTestMetadataSources self = 6, asset = 7, item = 8, // This is the source for asset instances (player inventory items), while the "asset" source is for the asset in general + inventory = 9, // This is the source for inventory items + item_template = 10, // This is the source for item templates }; /// diff --git a/Tests/LootLockerTests/PlayMode/JsonTests.cs b/Tests/LootLockerTests/PlayMode/JsonTests.cs index 9570399e6..408f6c099 100644 --- a/Tests/LootLockerTests/PlayMode/JsonTests.cs +++ b/Tests/LootLockerTests/PlayMode/JsonTests.cs @@ -407,11 +407,13 @@ public void Json_DeserializingListPlayerItemsResponse_Succeeds() Assert.AreEqual("01HZZZZZZZZZZZZZZZZZZZZZZZ", deserialized.items[0].id, "Wrong item id"); Assert.AreEqual(3, deserialized.items[0].player_id, "Wrong player id"); Assert.AreEqual("01HAAAAAAAAAAAAAAAAAAAAAAA", deserialized.items[0].item_template_id, "Wrong item template id"); - Assert.AreEqual("stackable", deserialized.items[0].item_type, "Wrong item type"); + Assert.AreEqual(LootLockerItemType.stackable, deserialized.items[0].item_type, "Wrong item type"); Assert.IsTrue(deserialized.items[0].consumable, "Wrong consumable value"); Assert.AreEqual(7, deserialized.items[0].count, "Wrong count"); Assert.AreEqual("Health Potion", deserialized.items[0].name, "Wrong name"); Assert.IsTrue(deserialized.items[0].deletable, "Wrong deletable value"); + Assert.AreNotEqual(default(DateTime), deserialized.items[0].created_at, "Wrong created_at value"); + Assert.IsNull(deserialized.items[0].updated_at, "Wrong updated_at value"); Assert.IsNotNull(deserialized.pagination, "Not deserialized, pagination is null"); Assert.AreEqual(1, deserialized.pagination.total, "Wrong pagination total"); Assert.AreEqual(25, deserialized.pagination.per_page, "Wrong pagination per_page"); @@ -431,11 +433,12 @@ public void Json_DeserializingGetPlayerItemResponseWithTemplateAndMetadata_Succe // Then Assert.IsNotNull(deserialized, "Not deserialized, is null"); Assert.AreEqual("01HZZZZZZZZZZZZZZZZZZZZZZZ", deserialized.id, "Wrong item id"); - Assert.AreEqual("instanced", deserialized.item_type, "Wrong item type"); + Assert.AreEqual(LootLockerItemType.instanced, deserialized.item_type, "Wrong item type"); Assert.IsTrue(deserialized.deletable, "Wrong deletable value"); + Assert.AreNotEqual(default(DateTime), deserialized.created_at, "Wrong created_at value"); Assert.IsNotNull(deserialized.template, "Not deserialized, template is null"); Assert.AreEqual("Sword", deserialized.template.name, "Wrong template name"); - Assert.AreEqual(42, deserialized.template.game_id, "Wrong template game id"); + Assert.AreEqual(LootLockerItemType.instanced, deserialized.template.item_type, "Wrong template item type"); Assert.IsNotNull(deserialized.metadata, "Not deserialized, metadata is null"); Assert.AreEqual(1, deserialized.metadata.Length, "Not deserialized, wrong number of metadata entries"); Assert.AreEqual("damage", deserialized.metadata[0].key, "Wrong metadata key"); @@ -463,7 +466,7 @@ public void Json_DeserializingConsumeItemResponseWithGrantedItems_Succeeds() Assert.AreEqual(1, deserialized.granted.Length, "Not deserialized, wrong number of granted items"); Assert.AreEqual("01HBBBBBBBBBBBBBBBBBBBBBBB", deserialized.granted[0].source_id, "Wrong granted source id"); Assert.AreEqual(2, deserialized.granted[0].count, "Wrong granted count"); - Assert.AreEqual("currency", deserialized.granted[0].type, "Wrong granted type"); + Assert.AreEqual(LootLockerRewardKind.currency, deserialized.granted[0].type, "Wrong granted type"); Assert.AreEqual("Gold", deserialized.granted[0].name, "Wrong granted name"); Assert.AreEqual("gold", deserialized.granted[0].code, "Wrong granted code"); } @@ -484,9 +487,11 @@ public void Json_DeserializingListItemTemplatesResponse_Succeeds() Assert.AreEqual(1, deserialized.items.Length, "Not deserialized, wrong number of item templates"); Assert.AreEqual("01HAAAAAAAAAAAAAAAAAAAAAAA", deserialized.items[0].id, "Wrong item template id"); Assert.AreEqual("Sword", deserialized.items[0].name, "Wrong item template name"); - Assert.AreEqual(42, deserialized.items[0].game_id, "Wrong item template game id"); + Assert.AreEqual(LootLockerItemType.instanced, deserialized.items[0].item_type, "Wrong item template item type"); Assert.AreEqual(10, deserialized.items[0].limited, "Wrong item template limited value"); Assert.IsTrue(deserialized.items[0].deletable, "Wrong item template deletable value"); + Assert.AreNotEqual(default(DateTime), deserialized.items[0].created_at, "Wrong item template created_at value"); + Assert.IsNotNull(deserialized.items[0].updated_at, "Wrong item template updated_at value"); Assert.IsNotNull(deserialized.pagination, "Not deserialized, pagination is null"); Assert.AreEqual(1, deserialized.pagination.total, "Wrong pagination total"); } From 7b09bfce1ea58f9730eb347f9419aeed6ace4a52 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Mon, 28 Sep 2026 12:50:48 +0200 Subject: [PATCH 27/30] test(unity): add functional tests for items and item templates Add PlayMode coverage for the seven item endpoints against a live backend, using the admin API for setup (create template with a public audience, grant to the session player). - Add admin endpoint constants for the item template and player inventory routes. - Add a LootLockerTestItems helper mirroring the go-backend admin routes rather than the published ApiDog spec, which is stale for this feature: the list endpoint returns `templates` (not `items`), grant returns `id` (not `inventory_id`), consume requires a body and returns a count (not a bool), and the player inventory listing uses `template_name` and has no `deletable`. - Add ItemTests with 10 cases covering list templates, list player items (with and without filters), get, partial and whole-stack consume, split, merge, delete, and the forbidden delete of a non-deletable item. - Only the happy-path test carries LootLockerCIFast; the rest are LootLockerCI only. Also correct the ConsumePlayerItem doc comment: a null count consumes a single item, not the whole stack, matching the backend default. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Runtime/Game/LootLockerSDKManager.cs | 4 +- .../LootLockerTestConfigurationEndpoints.cs | 14 + .../LootLockerTestConfigurationItem.cs | 276 ++++++++++ .../LootLockerTestConfigurationItem.cs.meta | 11 + Tests/LootLockerTests/PlayMode/ItemTests.cs | 512 ++++++++++++++++++ .../PlayMode/ItemTests.cs.meta | 11 + 6 files changed, 826 insertions(+), 2 deletions(-) create mode 100644 Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs create mode 100644 Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs.meta create mode 100644 Tests/LootLockerTests/PlayMode/ItemTests.cs create mode 100644 Tests/LootLockerTests/PlayMode/ItemTests.cs.meta diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index 20b8eab01..9a19e2f44 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -6738,10 +6738,10 @@ public static void DeletePlayerItem(string inventoryId, Action - /// Consumes one or more items from a stackable inventory item. If is null, the whole stack is consumed. + /// Consumes one or more items from a stackable inventory item. If is null, a single item is consumed. /// /// The id of the stackable inventory item to consume. - /// (Optional) The number of items to consume. If null, the whole stack is consumed. + /// (Optional) The number of items to consume. If null, the backend consumes 1. To consume the whole stack, pass the item's current count. /// Delegate for handling the server response /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. public static void ConsumePlayerItem(string inventoryId, int? count, Action onComplete, string forPlayerWithUlid = null) diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs index c8f22a1e0..b638d9352 100644 --- a/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs @@ -109,6 +109,20 @@ public class LootLockerTestConfigurationEndpoints [Header("LootLocker Admin API Inventory Operations")] public static EndPointClass adminGrantAssetToPlayerInventory = new EndPointClass("/game/#GAMEID#/player/{0}/inventory/grant", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + [Header("LootLocker Admin API Item Template Operations")] + public static EndPointClass createItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass listItemTemplates = new EndPointClass("game/#GAMEID#/templates/v1", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin); + public static EndPointClass getItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1/{0}", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin); + public static EndPointClass updateItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1/{0}", LootLockerHTTPMethod.PATCH, LootLockerCallerRole.Admin); + public static EndPointClass deleteItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1/{0}", LootLockerHTTPMethod.DELETE, LootLockerCallerRole.Admin); + public static EndPointClass grantItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1/grant", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass setItemTemplateAudiences = new EndPointClass("game/#GAMEID#/templates/v1/{0}/audiences", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass adminListPlayerItems = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin); + public static EndPointClass adminConsumePlayerItem = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/{1}/consume", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass adminDeletePlayerItem = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/{1}", LootLockerHTTPMethod.DELETE, LootLockerCallerRole.Admin); + public static EndPointClass adminSplitPlayerItemStack = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/{1}/split", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass adminMergePlayerItemStacks = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/merge", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + [Header("LootLocker Admin API Metadata Operations")] public static EndPointClass metadataOperations = new EndPointClass("game/#GAMEID#/metadata", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs new file mode 100644 index 000000000..e6e5158bf --- /dev/null +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs @@ -0,0 +1,276 @@ +using System; +using LootLocker; +using Random = UnityEngine.Random; + +namespace LootLockerTestConfigurationUtils +{ + /// + /// Admin API helpers for setting up item templates and player inventory items in tests. + /// + /// + /// These helpers deliberately mirror the go-backend admin routes rather than the published + /// ApiDog spec, which is stale for this feature. Notable differences from the spec: + /// the list endpoint returns templates (not items), grant returns id + /// (not inventory_id), consume requires a body and returns a count (not a bool), + /// and the player inventory listing uses template_name and has no deletable. + /// + public static class LootLockerTestItems + { + public static string GetRandomItemTemplateName() + { + string[] colors = { "Green", "Blue", "Red", "Black", "Yellow", "Orange", "Purple", "Indigo", "Clear", "White", "Magenta", "Marine", "Crimson", "Teal" }; + string[] items = { "Rod", "House", "Wand", "Staff", "Car", "Sword", "Shield", "Gun", "Shovel", "Boomstick", "Rifle", "Hut", "Boat", "Bicycle", "Wheelchair" }; + + return colors[Random.Range(0, colors.Length)] + " " + items[Random.Range(0, items.Length)]; + } + + /// + /// Create an item template. Audiences are set inline by the create endpoint, which is + /// required for the template to be visible to the player through the game API. + /// + public static void CreateItemTemplate(string name, string itemType, bool consumable, bool deletable, string[] audiences, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerTestItemTemplateResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var request = new LootLockerTestCreateItemTemplateRequest + { + name = name, + item_type = itemType, + consumable = consumable, + deletable = deletable, + audiences = audiences + }; + + var endpoint = LootLockerTestConfigurationEndpoints.createItemTemplate; + string json = LootLockerJson.SerializeObject(request); + + LootLockerAdminRequest.Send(endpoint.endPoint, endpoint.httpMethod, json, onComplete: (serverResponse) => + { + var response = LootLockerResponse.Deserialize(serverResponse); + onComplete?.Invoke(response); + }, true); + } + + /// + /// Grant an item template to a player. Returns the id of the created inventory item. + /// + public static void GrantItemToPlayer(int playerId, string itemTemplateId, int count, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerTestGrantItemResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var request = new LootLockerTestGrantItemRequest + { + player_id = playerId, + item_template_id = itemTemplateId, + count = count + }; + + var endpoint = LootLockerTestConfigurationEndpoints.grantItemTemplate; + string json = LootLockerJson.SerializeObject(request); + + LootLockerAdminRequest.Send(endpoint.endPoint, endpoint.httpMethod, json, onComplete: (serverResponse) => + { + var response = LootLockerResponse.Deserialize(serverResponse); + onComplete?.Invoke(response); + }, true); + } + + public static void SetItemTemplateAudiences(string itemTemplateId, string[] audiences, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var endpoint = LootLockerTestConfigurationEndpoints.setItemTemplateAudiences; + string formattedEndpoint = string.Format(endpoint.endPoint, itemTemplateId); + string json = LootLockerJson.SerializeObject(new LootLockerTestSetItemTemplateAudiencesRequest { audiences = audiences }); + + LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, json, onComplete, true); + } + + public static void ListPlayerItemsAdmin(int playerId, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerTestAdminListPlayerItemsResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var endpoint = LootLockerTestConfigurationEndpoints.adminListPlayerItems; + string formattedEndpoint = string.Format(endpoint.endPoint, playerId); + + LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, null, onComplete: (serverResponse) => + { + var response = LootLockerResponse.Deserialize(serverResponse); + onComplete?.Invoke(response); + }, true); + } + + /// + /// Consume items through the admin API. The endpoint requires a body carrying the + /// player id; of 0 lets the backend apply its default of 1. + /// + public static void ConsumePlayerItemAdmin(int playerId, string inventoryId, int count, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerTestAdminConsumeItemResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var endpoint = LootLockerTestConfigurationEndpoints.adminConsumePlayerItem; + string formattedEndpoint = string.Format(endpoint.endPoint, playerId, inventoryId); + string json = LootLockerJson.SerializeObject(new LootLockerTestAdminConsumeItemRequest { player_id = playerId, count = count }); + + LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, json, onComplete: (serverResponse) => + { + var response = LootLockerResponse.Deserialize(serverResponse); + onComplete?.Invoke(response); + }, true); + } + + public static void DeletePlayerItemAdmin(int playerId, string inventoryId, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var endpoint = LootLockerTestConfigurationEndpoints.adminDeletePlayerItem; + string formattedEndpoint = string.Format(endpoint.endPoint, playerId, inventoryId); + + LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, null, onComplete, true); + } + + public static void SplitPlayerItemStackAdmin(int playerId, string inventoryId, int count, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerTestSplitItemStackResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var endpoint = LootLockerTestConfigurationEndpoints.adminSplitPlayerItemStack; + string formattedEndpoint = string.Format(endpoint.endPoint, playerId, inventoryId); + string json = LootLockerJson.SerializeObject(new LootLockerTestSplitItemStackRequest { count = count }); + + LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, json, onComplete: (serverResponse) => + { + var response = LootLockerResponse.Deserialize(serverResponse); + onComplete?.Invoke(response); + }, true); + } + + public static void MergePlayerItemStacksAdmin(int playerId, string sourceInventoryId, string targetInventoryId, Action onComplete) + { + if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) + { + onComplete?.Invoke(new LootLockerResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + return; + } + + var endpoint = LootLockerTestConfigurationEndpoints.adminMergePlayerItemStacks; + string formattedEndpoint = string.Format(endpoint.endPoint, playerId); + string json = LootLockerJson.SerializeObject(new LootLockerTestMergeItemStacksRequest + { + source_inventory_id = sourceInventoryId, + target_inventory_id = targetInventoryId + }); + + LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, json, onComplete, true); + } + } + + public class LootLockerTestCreateItemTemplateRequest + { + public string name { get; set; } + public string item_type { get; set; } + public bool consumable { get; set; } + public bool deletable { get; set; } + public string[] audiences { get; set; } + } + + public class LootLockerTestItemTemplateResponse : LootLockerResponse + { + public string id { get; set; } + } + + public class LootLockerTestGrantItemRequest + { + public int player_id { get; set; } + public string item_template_id { get; set; } + public int count { get; set; } + } + + public class LootLockerTestGrantItemResponse : LootLockerResponse + { + public string id { get; set; } + } + + public class LootLockerTestSetItemTemplateAudiencesRequest + { + public string[] audiences { get; set; } + } + + public class LootLockerTestAdminConsumeItemRequest + { + public int player_id { get; set; } + public int count { get; set; } + } + + public class LootLockerTestAdminConsumeItemResponse : LootLockerResponse + { + public int consumed { get; set; } + } + + public class LootLockerTestSplitItemStackRequest + { + public int count { get; set; } + } + + public class LootLockerTestSplitItemStackResponse : LootLockerResponse + { + public string id { get; set; } + } + + public class LootLockerTestMergeItemStacksRequest + { + public string source_inventory_id { get; set; } + public string target_inventory_id { get; set; } + } + + public class LootLockerTestAdminListPlayerItemsResponse : LootLockerResponse + { + public LootLockerTestAdminPlayerItem[] items { get; set; } + } + + /// + /// An item as returned by the admin player inventory listing. Note that this shape differs + /// from the game API item: the template name is exposed as template_name and there is + /// no deletable field. + /// + public class LootLockerTestAdminPlayerItem + { + public string id { get; set; } + public int player_id { get; set; } + public string item_template_id { get; set; } + public string template_name { get; set; } + public string item_type { get; set; } + public bool consumable { get; set; } + public int count { get; set; } + public string source { get; set; } + public string created_at { get; set; } + public string updated_at { get; set; } + } +} diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs.meta b/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs.meta new file mode 100644 index 000000000..3bbd24fd3 --- /dev/null +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 99fdfa0f2eea476a8a97204dcb3005e6 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file diff --git a/Tests/LootLockerTests/PlayMode/ItemTests.cs b/Tests/LootLockerTests/PlayMode/ItemTests.cs new file mode 100644 index 000000000..f43700cfc --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/ItemTests.cs @@ -0,0 +1,512 @@ +using System; +using System.Collections; +using System.Linq; +using LootLocker; +using LootLocker.LootLockerEnums; +using LootLocker.Requests; +using LootLockerTestConfigurationUtils; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace LootLockerTests.PlayMode +{ + public class ItemTests + { + private LootLockerTestGame gameUnderTest = null; + private LootLockerConfig configCopy = null; + private static int TestCounter = 0; + private bool SetupFailed = false; + private int sessionPlayerId = 0; + private string createdTemplateId = string.Empty; + private string createdTemplateName = string.Empty; + private string grantedInventoryId = string.Empty; + + private const string PublicAudience = "public"; + + [UnitySetUp] + public IEnumerator Setup() + { + TestCounter++; + configCopy = LootLockerConfig.current; + Debug.Log($"##### Start of {this.GetType().Name} test no.{TestCounter} setup #####"); + + if (!LootLockerConfig.ClearSettings()) + { + Debug.LogError("Could not clear LootLocker config"); + } + + bool gameCreationCallCompleted = false; + LootLockerTestGame.CreateGame(testName: this.GetType().Name + TestCounter + " ", onComplete: (success, errorMessage, game) => + { + if (!success) + { + Debug.LogError(errorMessage); + SetupFailed = true; + } + gameUnderTest = game; + gameCreationCallCompleted = true; + }); + yield return new WaitUntil(() => gameCreationCallCompleted); + if (SetupFailed) + { + yield break; + } + + gameUnderTest?.SwitchToStageEnvironment(); + + bool enableGuestLoginCallCompleted = false; + gameUnderTest?.EnableGuestLogin((success, errorMessage) => + { + if (!success) + { + Debug.LogError(errorMessage); + SetupFailed = true; + } + enableGuestLoginCallCompleted = true; + }); + yield return new WaitUntil(() => enableGuestLoginCallCompleted); + if (SetupFailed) + { + yield break; + } + + Assert.IsTrue(gameUnderTest?.InitializeLootLockerSDK(), "Successfully created test game and initialized LootLocker"); + + // Create a stackable, consumable, deletable template with a public audience. The + // audience is required: the game API only lists templates that have an audience row, + // so without it the template would be invisible even to the player it was granted to. + createdTemplateName = LootLockerTestItems.GetRandomItemTemplateName(); + bool createTemplateCallCompleted = false; + LootLockerTestItems.CreateItemTemplate(createdTemplateName, "stackable", true, true, new[] { PublicAudience }, (templateResponse) => + { + if (templateResponse == null || !templateResponse.success) + { + Debug.LogError("Failed to create item template: " + templateResponse?.errorData?.message); + SetupFailed = true; + createTemplateCallCompleted = true; + return; + } + + createdTemplateId = templateResponse.id; + createTemplateCallCompleted = true; + }); + yield return new WaitUntil(() => createTemplateCallCompleted); + if (SetupFailed) + { + yield break; + } + + bool guestLoginCompleted = false; + LootLockerSDKManager.StartGuestSession(Guid.NewGuid().ToString(), response => + { + SetupFailed |= !response.success; + sessionPlayerId = response.player_id; + guestLoginCompleted = true; + }); + yield return new WaitUntil(() => guestLoginCompleted); + if (SetupFailed) + { + yield break; + } + + bool grantCallCompleted = false; + LootLockerTestItems.GrantItemToPlayer(sessionPlayerId, createdTemplateId, 5, (grantResponse) => + { + if (grantResponse == null || !grantResponse.success) + { + Debug.LogError("Failed to grant item to player: " + grantResponse?.errorData?.message); + SetupFailed = true; + grantCallCompleted = true; + return; + } + + grantedInventoryId = grantResponse.id; + grantCallCompleted = true; + }); + yield return new WaitUntil(() => grantCallCompleted); + + Debug.Log($"##### Start of {this.GetType().Name} test no.{TestCounter} test case #####"); + } + + [UnityTearDown] + public IEnumerator TearDown() + { + Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} test case #####"); + if (gameUnderTest != null) + { + bool gameDeletionCallCompleted = false; + gameUnderTest.DeleteGame(((success, errorMessage) => + { + if (!success) + { + Debug.LogError(errorMessage); + } + + gameUnderTest = null; + gameDeletionCallCompleted = true; + })); + yield return new WaitUntil(() => gameDeletionCallCompleted); + } + + LootLockerStateData.ClearAllSavedStates(); + LootLockerConfig.CreateNewSettings(configCopy); + Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####"); + } + + /// + /// The designated fast test: it covers the whole setup chain (create template with an + /// audience, grant to player) plus the central read path, so it gives early signal + /// without paying for the full suite. + /// + [UnityTest, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] + [Timeout(360_000)] + public IEnumerator Items_ListItemTemplates_ReturnsCreatedTemplate() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When + bool listCallCompleted = false; + LootLockerListItemTemplatesResponse response = null; + LootLockerSDKManager.ListItemTemplates(1, 100, (r) => + { + response = r; + listCallCompleted = true; + }); + yield return new WaitUntil(() => listCallCompleted); + + // Then + Assert.IsTrue(response.success, response.errorData?.ToString() ?? "ListItemTemplates call failed"); + Assert.IsNotNull(response.items, "Item templates should not be null"); + Assert.IsTrue(response.items.Any(t => t.id == createdTemplateId), "Expected the created template to be visible to the player"); + + var template = response.items.First(t => t.id == createdTemplateId); + Assert.AreEqual(createdTemplateName, template.name, "Template name should match the created template"); + Assert.AreEqual(LootLockerItemType.stackable, template.item_type, "Template should be stackable"); + Assert.IsTrue(template.consumable, "Template should be consumable"); + Assert.IsTrue(template.deletable, "Template should be deletable"); + Assert.AreNotEqual(default(DateTime), template.created_at, "Template created_at should be populated"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_ListPlayerItems_ReturnsGrantedItem() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When + bool listCallCompleted = false; + LootLockerListPlayerItemsResponse response = null; + LootLockerSDKManager.ListPlayerItems(1, 100, onComplete: (r) => + { + response = r; + listCallCompleted = true; + }); + yield return new WaitUntil(() => listCallCompleted); + + // Then + Assert.IsTrue(response.success, response.errorData?.ToString() ?? "ListPlayerItems call failed"); + Assert.IsNotNull(response.items, "Player items should not be null"); + Assert.IsTrue(response.items.Any(i => i.id == grantedInventoryId), "Expected the granted item to be listed"); + + var item = response.items.First(i => i.id == grantedInventoryId); + Assert.AreEqual(5, item.count, "Granted count should be 5"); + Assert.AreEqual(createdTemplateName, item.name, "Item name should be the template name"); + Assert.AreEqual(LootLockerItemType.stackable, item.item_type, "Item should be stackable"); + Assert.IsTrue(item.deletable, "Item should be deletable"); + Assert.IsNotNull(item.source, "Item source should be populated"); + Assert.AreNotEqual(default(DateTime), item.created_at, "Item created_at should be populated"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_ListPlayerItems_WithFilters_ReturnsFilteredItems() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When + bool listCallCompleted = false; + LootLockerListPlayerItemsResponse response = null; + LootLockerSDKManager.ListPlayerItems(1, 100, name: createdTemplateName, itemType: LootLockerItemType.stackable, + consumable: true, sort: LootLockerItemSortField.created_at, order: LootLockerSortOrder.DESC, onComplete: (r) => + { + response = r; + listCallCompleted = true; + }); + yield return new WaitUntil(() => listCallCompleted); + + // Then + Assert.IsTrue(response.success, response.errorData?.ToString() ?? "Filtered ListPlayerItems call failed"); + Assert.IsNotNull(response.items, "Player items should not be null"); + Assert.IsTrue(response.items.Any(i => i.id == grantedInventoryId), "Expected the granted item to match the filters"); + Assert.IsTrue(response.items.All(i => i.item_type == LootLockerItemType.stackable), "All returned items should be stackable"); + Assert.IsTrue(response.items.All(i => i.consumable), "All returned items should be consumable"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_GetPlayerItem_ReturnsItemWithTemplate() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When + bool getCallCompleted = false; + LootLockerGetPlayerItemResponse response = null; + LootLockerSDKManager.GetPlayerItem(grantedInventoryId, (r) => + { + response = r; + getCallCompleted = true; + }); + yield return new WaitUntil(() => getCallCompleted); + + // Then + Assert.IsTrue(response.success, response.errorData?.ToString() ?? "GetPlayerItem call failed"); + Assert.AreEqual(grantedInventoryId, response.id, "Returned item id should match the requested id"); + Assert.AreEqual(5, response.count, "Item count should be 5"); + Assert.AreEqual(LootLockerItemType.stackable, response.item_type, "Item should be stackable"); + Assert.IsTrue(response.deletable, "Item should be deletable"); + Assert.IsNotNull(response.template, "Item template should be populated on the single item response"); + Assert.AreEqual(createdTemplateId, response.template.id, "Template id should match the created template"); + Assert.AreEqual(createdTemplateName, response.template.name, "Template name should match the created template"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_ConsumePlayerItem_PartialStack_DecrementsCount() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When + bool consumeCallCompleted = false; + LootLockerConsumeItemResponse consumeResponse = null; + LootLockerSDKManager.ConsumePlayerItem(grantedInventoryId, 2, (r) => + { + consumeResponse = r; + consumeCallCompleted = true; + }); + yield return new WaitUntil(() => consumeCallCompleted); + + // Then + Assert.IsTrue(consumeResponse.success, consumeResponse.errorData?.ToString() ?? "ConsumePlayerItem call failed"); + Assert.IsTrue(consumeResponse.consumed, "Item should report as consumed"); + + bool getCallCompleted = false; + LootLockerGetPlayerItemResponse getResponse = null; + LootLockerSDKManager.GetPlayerItem(grantedInventoryId, (r) => + { + getResponse = r; + getCallCompleted = true; + }); + yield return new WaitUntil(() => getCallCompleted); + + Assert.IsTrue(getResponse.success, getResponse.errorData?.ToString() ?? "GetPlayerItem after consume failed"); + Assert.AreEqual(3, getResponse.count, "Consuming 2 of 5 should leave 3"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_ConsumePlayerItem_WholeStack_RemovesItem() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When - passing the full count consumes the entire row + bool consumeCallCompleted = false; + LootLockerConsumeItemResponse consumeResponse = null; + LootLockerSDKManager.ConsumePlayerItem(grantedInventoryId, 5, (r) => + { + consumeResponse = r; + consumeCallCompleted = true; + }); + yield return new WaitUntil(() => consumeCallCompleted); + + // Then + Assert.IsTrue(consumeResponse.success, consumeResponse.errorData?.ToString() ?? "ConsumePlayerItem call failed"); + Assert.IsTrue(consumeResponse.consumed, "Item should report as consumed"); + + bool listCallCompleted = false; + LootLockerListPlayerItemsResponse listResponse = null; + LootLockerSDKManager.ListPlayerItems(1, 100, onComplete: (r) => + { + listResponse = r; + listCallCompleted = true; + }); + yield return new WaitUntil(() => listCallCompleted); + + Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerItems after consume failed"); + Assert.IsFalse(listResponse.items.Any(i => i.id == grantedInventoryId), "Fully consumed item should no longer be listed"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_SplitPlayerItemStack_CreatesSecondStack() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When + bool splitCallCompleted = false; + LootLockerSplitItemStackResponse splitResponse = null; + LootLockerSDKManager.SplitPlayerItemStack(grantedInventoryId, 2, (r) => + { + splitResponse = r; + splitCallCompleted = true; + }); + yield return new WaitUntil(() => splitCallCompleted); + + // Then + Assert.IsTrue(splitResponse.success, splitResponse.errorData?.ToString() ?? "SplitPlayerItemStack call failed"); + Assert.IsNotNull(splitResponse.id, "Split should return the id of the new stack"); + Assert.AreNotEqual(grantedInventoryId, splitResponse.id, "The new stack should have a different id"); + + bool listCallCompleted = false; + LootLockerListPlayerItemsResponse listResponse = null; + LootLockerSDKManager.ListPlayerItems(1, 100, onComplete: (r) => + { + listResponse = r; + listCallCompleted = true; + }); + yield return new WaitUntil(() => listCallCompleted); + + Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerItems after split failed"); + var original = listResponse.items.FirstOrDefault(i => i.id == grantedInventoryId); + var split = listResponse.items.FirstOrDefault(i => i.id == splitResponse.id); + Assert.IsNotNull(original, "Original stack should still be listed"); + Assert.IsNotNull(split, "New stack should be listed"); + Assert.AreEqual(3, original.count, "Original stack should be left with 3"); + Assert.AreEqual(2, split.count, "New stack should hold 2"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_MergePlayerItemStacks_CombinesCounts() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // Given - split off a second stack to merge back + bool splitCallCompleted = false; + LootLockerSplitItemStackResponse splitResponse = null; + LootLockerSDKManager.SplitPlayerItemStack(grantedInventoryId, 2, (r) => + { + splitResponse = r; + splitCallCompleted = true; + }); + yield return new WaitUntil(() => splitCallCompleted); + Assert.IsTrue(splitResponse.success, splitResponse.errorData?.ToString() ?? "SplitPlayerItemStack call failed"); + + // When - merge the new stack back into the original + bool mergeCallCompleted = false; + LootLockerResponse mergeResponse = null; + LootLockerSDKManager.MergePlayerItemStacks(splitResponse.id, grantedInventoryId, (r) => + { + mergeResponse = r; + mergeCallCompleted = true; + }); + yield return new WaitUntil(() => mergeCallCompleted); + + // Then + Assert.IsTrue(mergeResponse.success, mergeResponse.errorData?.ToString() ?? "MergePlayerItemStacks call failed"); + + bool listCallCompleted = false; + LootLockerListPlayerItemsResponse listResponse = null; + LootLockerSDKManager.ListPlayerItems(1, 100, onComplete: (r) => + { + listResponse = r; + listCallCompleted = true; + }); + yield return new WaitUntil(() => listCallCompleted); + + Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerItems after merge failed"); + var target = listResponse.items.FirstOrDefault(i => i.id == grantedInventoryId); + Assert.IsNotNull(target, "Target stack should still be listed"); + Assert.AreEqual(5, target.count, "Merged stack should hold the combined count of 5"); + Assert.IsFalse(listResponse.items.Any(i => i.id == splitResponse.id), "Source stack should be gone after merging"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_DeletePlayerItem_RemovesItem() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // When + bool deleteCallCompleted = false; + LootLockerResponse deleteResponse = null; + LootLockerSDKManager.DeletePlayerItem(grantedInventoryId, (r) => + { + deleteResponse = r; + deleteCallCompleted = true; + }); + yield return new WaitUntil(() => deleteCallCompleted); + + // Then + Assert.IsTrue(deleteResponse.success, deleteResponse.errorData?.ToString() ?? "DeletePlayerItem call failed"); + + bool listCallCompleted = false; + LootLockerListPlayerItemsResponse listResponse = null; + LootLockerSDKManager.ListPlayerItems(1, 100, onComplete: (r) => + { + listResponse = r; + listCallCompleted = true; + }); + yield return new WaitUntil(() => listCallCompleted); + + Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerItems after delete failed"); + Assert.IsFalse(listResponse.items.Any(i => i.id == grantedInventoryId), "Deleted item should no longer be listed"); + } + + [UnityTest, Category("LootLocker"), Category("LootLockerCI")] + [Timeout(360_000)] + public IEnumerator Items_DeletePlayerItem_NotDeletable_ReturnsForbidden() + { + Assert.IsFalse(SetupFailed, "Failed to setup game"); + + // Given - a second template that is explicitly not deletable + bool createTemplateCallCompleted = false; + string nonDeletableTemplateId = string.Empty; + LootLockerTestItems.CreateItemTemplate(LootLockerTestItems.GetRandomItemTemplateName(), "stackable", false, false, new[] { PublicAudience }, (templateResponse) => + { + if (templateResponse == null || !templateResponse.success) + { + Debug.LogError("Failed to create non-deletable item template: " + templateResponse?.errorData?.message); + createTemplateCallCompleted = true; + return; + } + + nonDeletableTemplateId = templateResponse.id; + createTemplateCallCompleted = true; + }); + yield return new WaitUntil(() => createTemplateCallCompleted); + Assert.IsNotEmpty(nonDeletableTemplateId, "Failed to create the non-deletable template"); + + bool grantCallCompleted = false; + string nonDeletableInventoryId = string.Empty; + LootLockerTestItems.GrantItemToPlayer(sessionPlayerId, nonDeletableTemplateId, 1, (grantResponse) => + { + if (grantResponse == null || !grantResponse.success) + { + Debug.LogError("Failed to grant non-deletable item: " + grantResponse?.errorData?.message); + grantCallCompleted = true; + return; + } + + nonDeletableInventoryId = grantResponse.id; + grantCallCompleted = true; + }); + yield return new WaitUntil(() => grantCallCompleted); + Assert.IsNotEmpty(nonDeletableInventoryId, "Failed to grant the non-deletable item"); + + // When + bool deleteCallCompleted = false; + LootLockerResponse deleteResponse = null; + LootLockerSDKManager.DeletePlayerItem(nonDeletableInventoryId, (r) => + { + deleteResponse = r; + deleteCallCompleted = true; + }); + yield return new WaitUntil(() => deleteCallCompleted); + + // Then + Assert.IsFalse(deleteResponse.success, "Deleting a non-deletable item should fail"); + Assert.AreEqual(403, deleteResponse.statusCode, "Deleting a non-deletable item should return 403 Forbidden"); + } + } +} diff --git a/Tests/LootLockerTests/PlayMode/ItemTests.cs.meta b/Tests/LootLockerTests/PlayMode/ItemTests.cs.meta new file mode 100644 index 000000000..354a1e1fb --- /dev/null +++ b/Tests/LootLockerTests/PlayMode/ItemTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7fed9865e8244b08bd29cda5c063248a +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: \ No newline at end of file From 32e4697866d60ce1d64f1b10c3483a8287fe7142 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Wed, 30 Sep 2026 07:16:32 +0200 Subject: [PATCH 28/30] refactor(unity): rename player item APIs to player inventory items Carry the "inventory" concept through to the end user for the Assets 2.0 (Items / Item Templates) surface, and align the metadata source with the backend rename. - Metadata source `inventory` -> `inventory_item` (numeric value unchanged). - Public methods renamed, e.g. `ListPlayerItems` -> `ListPlayerInventoryItems`, `GetPlayerItem` -> `GetPlayerInventoryItem`, `ConsumePlayerItem` -> `ConsumePlayerInventoryItem`, `DeletePlayerItem` -> `DeletePlayerInventoryItem`, `SplitPlayerItemStack` -> `SplitPlayerInventoryItemStack`, `MergePlayerItemStacks` -> `MergePlayerInventoryItemStacks`, `GrantItemToPlayer` -> `GrantItemToPlayerInventory`. - DTOs renamed to match, e.g. `LootLockerItem` -> `LootLockerInventoryItem`. - Endpoint constants and the test surface renamed to match. These methods deal with items and item templates. They are unrelated to the pre-existing asset/asset-instance inventory methods (`GetInventory`, `ListPlayerInventory`, `ListCharacterInventory`, ...), which share the `player/inventory` URL prefix. Doc comments on both families now state this explicitly. Item templates are not inventory, so `ListItemTemplates` and the template, type, sort-field and reward types keep their names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Runtime/Client/LootLockerEndPoints.cs | 12 +-- Runtime/Game/LootLockerSDKManager.cs | 84 +++++++++++---- Runtime/Game/Requests/ItemRequest.cs | 23 ++-- Runtime/Game/Requests/MetadataRequests.cs | 4 +- .../LootLockerTestConfigurationEndpoints.cs | 10 +- .../LootLockerTestConfigurationItem.cs | 26 ++--- .../LootLockerTestConfigurationMetadata.cs | 4 +- Tests/LootLockerTests/PlayMode/ItemTests.cs | 100 +++++++++--------- Tests/LootLockerTests/PlayMode/JsonTests.cs | 18 ++-- 9 files changed, 166 insertions(+), 115 deletions(-) diff --git a/Runtime/Client/LootLockerEndPoints.cs b/Runtime/Client/LootLockerEndPoints.cs index c34f75b43..499611a39 100644 --- a/Runtime/Client/LootLockerEndPoints.cs +++ b/Runtime/Client/LootLockerEndPoints.cs @@ -353,12 +353,12 @@ public class LootLockerEndPoints // Items [Header("Items")] public static EndPointClass listItemTemplates = new EndPointClass("player/inventory/v1", LootLockerHTTPMethod.GET); - public static EndPointClass listPlayerItems = new EndPointClass("player/inventory/v1/my", LootLockerHTTPMethod.GET); - public static EndPointClass getPlayerItem = new EndPointClass("player/inventory/v1/{0}", LootLockerHTTPMethod.GET); - public static EndPointClass deletePlayerItem = new EndPointClass("player/inventory/v1/{0}", LootLockerHTTPMethod.DELETE); - public static EndPointClass consumePlayerItem = new EndPointClass("player/inventory/v1/{0}/consume", LootLockerHTTPMethod.POST); - public static EndPointClass splitPlayerItemStack = new EndPointClass("player/inventory/v1/{0}/split", LootLockerHTTPMethod.POST); - public static EndPointClass mergePlayerItemStacks = new EndPointClass("player/inventory/v1/merge", LootLockerHTTPMethod.POST); + public static EndPointClass listPlayerInventoryItems = new EndPointClass("player/inventory/v1/my", LootLockerHTTPMethod.GET); + public static EndPointClass getPlayerInventoryItem = new EndPointClass("player/inventory/v1/{0}", LootLockerHTTPMethod.GET); + public static EndPointClass deletePlayerInventoryItem = new EndPointClass("player/inventory/v1/{0}", LootLockerHTTPMethod.DELETE); + public static EndPointClass consumePlayerInventoryItem = new EndPointClass("player/inventory/v1/{0}/consume", LootLockerHTTPMethod.POST); + public static EndPointClass splitPlayerInventoryItemStack = new EndPointClass("player/inventory/v1/{0}/split", LootLockerHTTPMethod.POST); + public static EndPointClass mergePlayerInventoryItemStacks = new EndPointClass("player/inventory/v1/merge", LootLockerHTTPMethod.POST); } [Serializable] diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index 9a19e2f44..d63b3734a 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -3476,6 +3476,11 @@ public static void ListPlayerInfo(string[] playerIdsToLookUp, int[] playerLegacy /// /// Get the players inventory. /// + /// + /// Operates on assets and asset instances. For the item-based inventory, see + /// and friends, which deal with items and + /// item templates and are a separate system. + /// /// Amount of assets to retrieve /// The instance ID the list should start from /// onComplete Action for handling the response of type LootLockerInventoryResponse @@ -3541,6 +3546,11 @@ public static void GetInventory(int count, Action o /// /// List player inventory with default parameters (no filters, first page, default page size). /// + /// + /// Operates on assets and asset instances. For the item-based inventory, see + /// and friends, which deal with items and + /// item templates and are a separate system. + /// /// onComplete Action for handling the response /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. public static void ListPlayerInventoryWithDefaultParameters(Action onComplete, string forPlayerWithUlid = null) @@ -3564,6 +3574,11 @@ public static void ListPlayerInventoryWithDefaultParameters(ActionOptional : Page number to retrieve. /// onComplete Action for handling the response /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + /// + /// Operates on assets and asset instances. For the item-based inventory, see + /// and friends, which deal with items and + /// item templates and are a separate system. + /// public static void ListPlayerInventory(LootLockerListSimplifiedInventoryRequest request, int perPage, int page, Action onComplete, string forPlayerWithUlid = null) { if (!CheckInitialized(false, forPlayerWithUlid)) @@ -6629,6 +6644,10 @@ public static void DeleteAssetInstanceFromPlayerInventory(int assetInstanceID, A #region Items + // These methods operate on items and item templates. They are unrelated to the asset-based + // inventory API (GetInventory, ListPlayerInventory, ...), which deals with assets and asset + // instances. The two share a URL prefix but are separate systems. + /// /// Returns a paginated list of all visible item templates. /// @@ -6658,6 +6677,12 @@ public static void ListItemTemplates(int page, int perPage, Action /// Returns a paginated list of the current player's inventory items, including their item templates. /// + /// + /// Operates on items and item templates. This is unrelated to the asset-based + /// inventory API (, , + /// , ), which deals with + /// assets and asset instances. The two share a URL prefix but are separate systems. + /// /// The page of items to return. /// The number of items to return per page. /// (Optional) Return only items whose template name starts with the specified value. Set to null to not use this filter. @@ -6667,15 +6692,15 @@ public static void ListItemTemplates(int page, int perPage, Action(Optional) The direction in which to sort the items. Set to null to not use this filter. /// Delegate for handling the server response /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. - public static void ListPlayerItems(int page, int perPage, string name = null, LootLockerItemType? itemType = null, bool? consumable = null, LootLockerItemSortField? sort = null, LootLockerSortOrder? order = null, Action onComplete = null, string forPlayerWithUlid = null) + public static void ListPlayerInventoryItems(int page, int perPage, string name = null, LootLockerItemType? itemType = null, bool? consumable = null, LootLockerItemSortField? sort = null, LootLockerSortOrder? order = null, Action onComplete = null, string forPlayerWithUlid = null) { if (!CheckInitialized(false, forPlayerWithUlid)) { - onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); return; } - EndPointClass endPoint = LootLockerEndPoints.listPlayerItems; + EndPointClass endPoint = LootLockerEndPoints.listPlayerInventoryItems; var queryParams = new LootLocker.Utilities.HTTP.QueryParamaterBuilder(); if (page > 0) @@ -6698,18 +6723,22 @@ public static void ListPlayerItems(int page, int perPage, string name = null, Lo /// /// Returns a single inventory item for the current player, including its item template. /// + /// + /// Operates on items and item templates. This is unrelated to the asset-based + /// inventory API, which deals with assets and asset instances. + /// /// The id of the inventory item to fetch. /// Delegate for handling the server response /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. - public static void GetPlayerItem(string inventoryId, Action onComplete, string forPlayerWithUlid = null) + public static void GetPlayerInventoryItem(string inventoryId, Action onComplete, string forPlayerWithUlid = null) { if (!CheckInitialized(false, forPlayerWithUlid)) { - onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); return; } - EndPointClass endPoint = LootLockerEndPoints.getPlayerItem; + EndPointClass endPoint = LootLockerEndPoints.getPlayerInventoryItem; string getVariable = endPoint.WithPathParameter(inventoryId); @@ -6719,10 +6748,15 @@ public static void GetPlayerItem(string inventoryId, Action /// Deletes an inventory item from the current player's inventory. /// + /// + /// Operates on items and item templates. This is unrelated to the asset-based + /// inventory API (), which deals with + /// assets and asset instances. + /// /// The id of the inventory item to delete. /// Delegate for handling the server response /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. - public static void DeletePlayerItem(string inventoryId, Action onComplete, string forPlayerWithUlid = null) + public static void DeletePlayerInventoryItem(string inventoryId, Action onComplete, string forPlayerWithUlid = null) { if (!CheckInitialized(false, forPlayerWithUlid)) { @@ -6730,7 +6764,7 @@ public static void DeletePlayerItem(string inventoryId, Action /// Consumes one or more items from a stackable inventory item. If is null, a single item is consumed. /// + /// + /// Operates on items and item templates. This is unrelated to the asset-based + /// inventory API, which deals with assets and asset instances. + /// /// The id of the stackable inventory item to consume. /// (Optional) The number of items to consume. If null, the backend consumes 1. To consume the whole stack, pass the item's current count. /// Delegate for handling the server response /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. - public static void ConsumePlayerItem(string inventoryId, int? count, Action onComplete, string forPlayerWithUlid = null) + public static void ConsumePlayerInventoryItem(string inventoryId, int? count, Action onComplete, string forPlayerWithUlid = null) { if (!CheckInitialized(false, forPlayerWithUlid)) { - onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); return; } - EndPointClass endPoint = LootLockerEndPoints.consumePlayerItem; + EndPointClass endPoint = LootLockerEndPoints.consumePlayerInventoryItem; string getVariable = endPoint.WithPathParameter(inventoryId); - string json = LootLockerJson.SerializeObject(new LootLockerConsumeItemRequest { count = count }); + string json = LootLockerJson.SerializeObject(new LootLockerConsumeInventoryItemRequest { count = count }); LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } @@ -6763,22 +6801,26 @@ public static void ConsumePlayerItem(string inventoryId, int? count, Action /// Splits a stackable inventory item into two stacks, moving items into a new stack. /// + /// + /// Operates on items and item templates. This is unrelated to the asset-based + /// inventory API, which deals with assets and asset instances. + /// /// The id of the stackable inventory item to split. /// The number of items to move into the new stack. /// Delegate for handling the server response /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. - public static void SplitPlayerItemStack(string inventoryId, int count, Action onComplete, string forPlayerWithUlid = null) + public static void SplitPlayerInventoryItemStack(string inventoryId, int count, Action onComplete, string forPlayerWithUlid = null) { if (!CheckInitialized(false, forPlayerWithUlid)) { - onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); + onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid)); return; } - EndPointClass endPoint = LootLockerEndPoints.splitPlayerItemStack; + EndPointClass endPoint = LootLockerEndPoints.splitPlayerInventoryItemStack; string getVariable = endPoint.WithPathParameter(inventoryId); - string json = LootLockerJson.SerializeObject(new LootLockerSplitItemStackRequest { count = count }); + string json = LootLockerJson.SerializeObject(new LootLockerSplitInventoryItemStackRequest { count = count }); LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } @@ -6786,11 +6828,15 @@ public static void SplitPlayerItemStack(string inventoryId, int count, Action /// Merges two stacks of the same item into one. /// + /// + /// Operates on items and item templates. This is unrelated to the asset-based + /// inventory API, which deals with assets and asset instances. + /// /// The id of the inventory item to merge from. /// The id of the inventory item to merge into. /// Delegate for handling the server response /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. - public static void MergePlayerItemStacks(string sourceInventoryId, string targetInventoryId, Action onComplete, string forPlayerWithUlid = null) + public static void MergePlayerInventoryItemStacks(string sourceInventoryId, string targetInventoryId, Action onComplete, string forPlayerWithUlid = null) { if (!CheckInitialized(false, forPlayerWithUlid)) { @@ -6798,9 +6844,9 @@ public static void MergePlayerItemStacks(string sourceInventoryId, string target return; } - EndPointClass endPoint = LootLockerEndPoints.mergePlayerItemStacks; + EndPointClass endPoint = LootLockerEndPoints.mergePlayerInventoryItemStacks; - string json = LootLockerJson.SerializeObject(new LootLockerMergeItemStacksRequest { source_inventory_id = sourceInventoryId, target_inventory_id = targetInventoryId }); + string json = LootLockerJson.SerializeObject(new LootLockerMergeInventoryItemStacksRequest { source_inventory_id = sourceInventoryId, target_inventory_id = targetInventoryId }); LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } diff --git a/Runtime/Game/Requests/ItemRequest.cs b/Runtime/Game/Requests/ItemRequest.cs index b3f375259..4112d9501 100644 --- a/Runtime/Game/Requests/ItemRequest.cs +++ b/Runtime/Game/Requests/ItemRequest.cs @@ -118,7 +118,12 @@ public class LootLockerItemTemplate /// /// An item instance held in a player's inventory. /// - public class LootLockerItem + /// + /// This represents an item granted from an item template. It is unrelated to the + /// asset-based inventory API (, GetInventory, + /// ListPlayerInventory), which deals with assets and asset instances. + /// + public class LootLockerInventoryItem { /// The unique id of this inventory item. public string id { get; set; } @@ -186,7 +191,7 @@ public class LootLockerGrantedItem /// /// Request to consume a stackable item from the player's inventory. /// - public class LootLockerConsumeItemRequest + public class LootLockerConsumeInventoryItemRequest { /// /// The number of items to consume. Defaults to 1 when omitted. To consume an entire stack, @@ -205,7 +210,7 @@ public bool ShouldSerializecount() /// /// Request to split a stackable item in the player's inventory into two stacks. /// - public class LootLockerSplitItemStackRequest + public class LootLockerSplitInventoryItemStackRequest { /// The number of items to move into the new stack. public int count { get; set; } @@ -214,7 +219,7 @@ public class LootLockerSplitItemStackRequest /// /// Request to merge two stacks of the same item into one. /// - public class LootLockerMergeItemStacksRequest + public class LootLockerMergeInventoryItemStacksRequest { /// The id of the source inventory item to merge from. public string source_inventory_id { get; set; } @@ -242,10 +247,10 @@ public class LootLockerListItemTemplatesResponse : LootLockerResponse /// /// Response containing a paginated list of the player's inventory items. /// - public class LootLockerListPlayerItemsResponse : LootLockerResponse + public class LootLockerListPlayerInventoryItemsResponse : LootLockerResponse { /// The list of the player's inventory items. - public LootLockerItem[] items { get; set; } + public LootLockerInventoryItem[] items { get; set; } /// Pagination details for the response. public LootLockerExtendedPagination pagination { get; set; } @@ -254,7 +259,7 @@ public class LootLockerListPlayerItemsResponse : LootLockerResponse /// /// Response containing the player's inventory item, returned at the top level with its template and metadata. /// - public class LootLockerGetPlayerItemResponse : LootLockerResponse + public class LootLockerGetPlayerInventoryItemResponse : LootLockerResponse { /// The unique id of this inventory item. public string id { get; set; } @@ -296,7 +301,7 @@ public class LootLockerGetPlayerItemResponse : LootLockerResponse /// /// Response containing the result of consuming an item, including any items granted as a result. /// - public class LootLockerConsumeItemResponse : LootLockerResponse + public class LootLockerConsumeInventoryItemResponse : LootLockerResponse { /// True if the item was consumed. public bool consumed { get; set; } @@ -308,7 +313,7 @@ public class LootLockerConsumeItemResponse : LootLockerResponse /// /// Response containing the id of the newly created stack after splitting an item. /// - public class LootLockerSplitItemStackResponse : LootLockerResponse + public class LootLockerSplitInventoryItemStackResponse : LootLockerResponse { /// The id of the newly created item stack. public string id { get; set; } diff --git a/Runtime/Game/Requests/MetadataRequests.cs b/Runtime/Game/Requests/MetadataRequests.cs index f99d0fa0d..4a9e4fdcc 100644 --- a/Runtime/Game/Requests/MetadataRequests.cs +++ b/Runtime/Game/Requests/MetadataRequests.cs @@ -25,8 +25,8 @@ public enum LootLockerMetadataSources player = 5, self = 6, asset = 7, - item = 8, // This is the source for legacy items, while the "inventory" source is for item instances - inventory = 9, // This is the source for item instances (player inventory items) + item = 8, // This is the source for legacy items, while the "inventory_item" source is for item instances + inventory_item = 9, // This is the source for item instances (player inventory items) item_template = 10, // This is the source for item templates }; diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs index b638d9352..c5ed49b6a 100644 --- a/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs @@ -117,11 +117,11 @@ public class LootLockerTestConfigurationEndpoints public static EndPointClass deleteItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1/{0}", LootLockerHTTPMethod.DELETE, LootLockerCallerRole.Admin); public static EndPointClass grantItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1/grant", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); public static EndPointClass setItemTemplateAudiences = new EndPointClass("game/#GAMEID#/templates/v1/{0}/audiences", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); - public static EndPointClass adminListPlayerItems = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin); - public static EndPointClass adminConsumePlayerItem = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/{1}/consume", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); - public static EndPointClass adminDeletePlayerItem = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/{1}", LootLockerHTTPMethod.DELETE, LootLockerCallerRole.Admin); - public static EndPointClass adminSplitPlayerItemStack = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/{1}/split", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); - public static EndPointClass adminMergePlayerItemStacks = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/merge", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass adminListPlayerInventoryItems = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin); + public static EndPointClass adminConsumePlayerInventoryItem = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/{1}/consume", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass adminDeletePlayerInventoryItem = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/{1}", LootLockerHTTPMethod.DELETE, LootLockerCallerRole.Admin); + public static EndPointClass adminSplitPlayerInventoryItemStack = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/{1}/split", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); + public static EndPointClass adminMergePlayerInventoryItemStacks = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/merge", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); [Header("LootLocker Admin API Metadata Operations")] public static EndPointClass metadataOperations = new EndPointClass("game/#GAMEID#/metadata", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin); diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs index e6e5158bf..35c01b040 100644 --- a/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs @@ -98,20 +98,20 @@ public static void SetItemTemplateAudiences(string itemTemplateId, string[] audi LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, json, onComplete, true); } - public static void ListPlayerItemsAdmin(int playerId, Action onComplete) + public static void ListPlayerInventoryItemsAdmin(int playerId, Action onComplete) { if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) { - onComplete?.Invoke(new LootLockerTestAdminListPlayerItemsResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); + onComplete?.Invoke(new LootLockerTestAdminListPlayerInventoryItemsResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } }); return; } - var endpoint = LootLockerTestConfigurationEndpoints.adminListPlayerItems; + var endpoint = LootLockerTestConfigurationEndpoints.adminListPlayerInventoryItems; string formattedEndpoint = string.Format(endpoint.endPoint, playerId); LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, null, onComplete: (serverResponse) => { - var response = LootLockerResponse.Deserialize(serverResponse); + var response = LootLockerResponse.Deserialize(serverResponse); onComplete?.Invoke(response); }, true); } @@ -120,7 +120,7 @@ public static void ListPlayerItemsAdmin(int playerId, Action of 0 lets the backend apply its default of 1. /// - public static void ConsumePlayerItemAdmin(int playerId, string inventoryId, int count, Action onComplete) + public static void ConsumePlayerInventoryItemAdmin(int playerId, string inventoryId, int count, Action onComplete) { if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) { @@ -128,7 +128,7 @@ public static void ConsumePlayerItemAdmin(int playerId, string inventoryId, int return; } - var endpoint = LootLockerTestConfigurationEndpoints.adminConsumePlayerItem; + var endpoint = LootLockerTestConfigurationEndpoints.adminConsumePlayerInventoryItem; string formattedEndpoint = string.Format(endpoint.endPoint, playerId, inventoryId); string json = LootLockerJson.SerializeObject(new LootLockerTestAdminConsumeItemRequest { player_id = playerId, count = count }); @@ -139,7 +139,7 @@ public static void ConsumePlayerItemAdmin(int playerId, string inventoryId, int }, true); } - public static void DeletePlayerItemAdmin(int playerId, string inventoryId, Action onComplete) + public static void DeletePlayerInventoryItemAdmin(int playerId, string inventoryId, Action onComplete) { if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) { @@ -147,13 +147,13 @@ public static void DeletePlayerItemAdmin(int playerId, string inventoryId, Actio return; } - var endpoint = LootLockerTestConfigurationEndpoints.adminDeletePlayerItem; + var endpoint = LootLockerTestConfigurationEndpoints.adminDeletePlayerInventoryItem; string formattedEndpoint = string.Format(endpoint.endPoint, playerId, inventoryId); LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, null, onComplete, true); } - public static void SplitPlayerItemStackAdmin(int playerId, string inventoryId, int count, Action onComplete) + public static void SplitPlayerInventoryItemStackAdmin(int playerId, string inventoryId, int count, Action onComplete) { if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) { @@ -161,7 +161,7 @@ public static void SplitPlayerItemStackAdmin(int playerId, string inventoryId, i return; } - var endpoint = LootLockerTestConfigurationEndpoints.adminSplitPlayerItemStack; + var endpoint = LootLockerTestConfigurationEndpoints.adminSplitPlayerInventoryItemStack; string formattedEndpoint = string.Format(endpoint.endPoint, playerId, inventoryId); string json = LootLockerJson.SerializeObject(new LootLockerTestSplitItemStackRequest { count = count }); @@ -172,7 +172,7 @@ public static void SplitPlayerItemStackAdmin(int playerId, string inventoryId, i }, true); } - public static void MergePlayerItemStacksAdmin(int playerId, string sourceInventoryId, string targetInventoryId, Action onComplete) + public static void MergePlayerInventoryItemStacksAdmin(int playerId, string sourceInventoryId, string targetInventoryId, Action onComplete) { if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken)) { @@ -180,7 +180,7 @@ public static void MergePlayerItemStacksAdmin(int playerId, string sourceInvento return; } - var endpoint = LootLockerTestConfigurationEndpoints.adminMergePlayerItemStacks; + var endpoint = LootLockerTestConfigurationEndpoints.adminMergePlayerInventoryItemStacks; string formattedEndpoint = string.Format(endpoint.endPoint, playerId); string json = LootLockerJson.SerializeObject(new LootLockerTestMergeItemStacksRequest { @@ -250,7 +250,7 @@ public class LootLockerTestMergeItemStacksRequest public string target_inventory_id { get; set; } } - public class LootLockerTestAdminListPlayerItemsResponse : LootLockerResponse + public class LootLockerTestAdminListPlayerInventoryItemsResponse : LootLockerResponse { public LootLockerTestAdminPlayerItem[] items { get; set; } } diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs index 8ff483081..2c8742877 100644 --- a/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs +++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs @@ -48,8 +48,8 @@ public enum LootLockerTestMetadataSources player = 5, self = 6, asset = 7, - item = 8, // This is the source for asset instances (player inventory items), while the "asset" source is for the asset in general - inventory = 9, // This is the source for inventory items + item = 8, // This is the source for legacy items, while the "inventory_item" source is for item instances + inventory_item = 9, // This is the source for item instances (player inventory items) item_template = 10, // This is the source for item templates }; diff --git a/Tests/LootLockerTests/PlayMode/ItemTests.cs b/Tests/LootLockerTests/PlayMode/ItemTests.cs index f43700cfc..46ed96bc0 100644 --- a/Tests/LootLockerTests/PlayMode/ItemTests.cs +++ b/Tests/LootLockerTests/PlayMode/ItemTests.cs @@ -190,14 +190,14 @@ public IEnumerator Items_ListItemTemplates_ReturnsCreatedTemplate() [UnityTest, Category("LootLocker"), Category("LootLockerCI")] [Timeout(360_000)] - public IEnumerator Items_ListPlayerItems_ReturnsGrantedItem() + public IEnumerator Items_ListPlayerInventoryItems_ReturnsGrantedItem() { Assert.IsFalse(SetupFailed, "Failed to setup game"); // When bool listCallCompleted = false; - LootLockerListPlayerItemsResponse response = null; - LootLockerSDKManager.ListPlayerItems(1, 100, onComplete: (r) => + LootLockerListPlayerInventoryItemsResponse response = null; + LootLockerSDKManager.ListPlayerInventoryItems(1, 100, onComplete: (r) => { response = r; listCallCompleted = true; @@ -205,7 +205,7 @@ public IEnumerator Items_ListPlayerItems_ReturnsGrantedItem() yield return new WaitUntil(() => listCallCompleted); // Then - Assert.IsTrue(response.success, response.errorData?.ToString() ?? "ListPlayerItems call failed"); + Assert.IsTrue(response.success, response.errorData?.ToString() ?? "ListPlayerInventoryItems call failed"); Assert.IsNotNull(response.items, "Player items should not be null"); Assert.IsTrue(response.items.Any(i => i.id == grantedInventoryId), "Expected the granted item to be listed"); @@ -220,14 +220,14 @@ public IEnumerator Items_ListPlayerItems_ReturnsGrantedItem() [UnityTest, Category("LootLocker"), Category("LootLockerCI")] [Timeout(360_000)] - public IEnumerator Items_ListPlayerItems_WithFilters_ReturnsFilteredItems() + public IEnumerator Items_ListPlayerInventoryItems_WithFilters_ReturnsFilteredItems() { Assert.IsFalse(SetupFailed, "Failed to setup game"); // When bool listCallCompleted = false; - LootLockerListPlayerItemsResponse response = null; - LootLockerSDKManager.ListPlayerItems(1, 100, name: createdTemplateName, itemType: LootLockerItemType.stackable, + LootLockerListPlayerInventoryItemsResponse response = null; + LootLockerSDKManager.ListPlayerInventoryItems(1, 100, name: createdTemplateName, itemType: LootLockerItemType.stackable, consumable: true, sort: LootLockerItemSortField.created_at, order: LootLockerSortOrder.DESC, onComplete: (r) => { response = r; @@ -236,7 +236,7 @@ public IEnumerator Items_ListPlayerItems_WithFilters_ReturnsFilteredItems() yield return new WaitUntil(() => listCallCompleted); // Then - Assert.IsTrue(response.success, response.errorData?.ToString() ?? "Filtered ListPlayerItems call failed"); + Assert.IsTrue(response.success, response.errorData?.ToString() ?? "Filtered ListPlayerInventoryItems call failed"); Assert.IsNotNull(response.items, "Player items should not be null"); Assert.IsTrue(response.items.Any(i => i.id == grantedInventoryId), "Expected the granted item to match the filters"); Assert.IsTrue(response.items.All(i => i.item_type == LootLockerItemType.stackable), "All returned items should be stackable"); @@ -245,14 +245,14 @@ public IEnumerator Items_ListPlayerItems_WithFilters_ReturnsFilteredItems() [UnityTest, Category("LootLocker"), Category("LootLockerCI")] [Timeout(360_000)] - public IEnumerator Items_GetPlayerItem_ReturnsItemWithTemplate() + public IEnumerator Items_GetPlayerInventoryItem_ReturnsItemWithTemplate() { Assert.IsFalse(SetupFailed, "Failed to setup game"); // When bool getCallCompleted = false; - LootLockerGetPlayerItemResponse response = null; - LootLockerSDKManager.GetPlayerItem(grantedInventoryId, (r) => + LootLockerGetPlayerInventoryItemResponse response = null; + LootLockerSDKManager.GetPlayerInventoryItem(grantedInventoryId, (r) => { response = r; getCallCompleted = true; @@ -260,7 +260,7 @@ public IEnumerator Items_GetPlayerItem_ReturnsItemWithTemplate() yield return new WaitUntil(() => getCallCompleted); // Then - Assert.IsTrue(response.success, response.errorData?.ToString() ?? "GetPlayerItem call failed"); + Assert.IsTrue(response.success, response.errorData?.ToString() ?? "GetPlayerInventoryItem call failed"); Assert.AreEqual(grantedInventoryId, response.id, "Returned item id should match the requested id"); Assert.AreEqual(5, response.count, "Item count should be 5"); Assert.AreEqual(LootLockerItemType.stackable, response.item_type, "Item should be stackable"); @@ -272,14 +272,14 @@ public IEnumerator Items_GetPlayerItem_ReturnsItemWithTemplate() [UnityTest, Category("LootLocker"), Category("LootLockerCI")] [Timeout(360_000)] - public IEnumerator Items_ConsumePlayerItem_PartialStack_DecrementsCount() + public IEnumerator Items_ConsumePlayerInventoryItem_PartialStack_DecrementsCount() { Assert.IsFalse(SetupFailed, "Failed to setup game"); // When bool consumeCallCompleted = false; - LootLockerConsumeItemResponse consumeResponse = null; - LootLockerSDKManager.ConsumePlayerItem(grantedInventoryId, 2, (r) => + LootLockerConsumeInventoryItemResponse consumeResponse = null; + LootLockerSDKManager.ConsumePlayerInventoryItem(grantedInventoryId, 2, (r) => { consumeResponse = r; consumeCallCompleted = true; @@ -287,32 +287,32 @@ public IEnumerator Items_ConsumePlayerItem_PartialStack_DecrementsCount() yield return new WaitUntil(() => consumeCallCompleted); // Then - Assert.IsTrue(consumeResponse.success, consumeResponse.errorData?.ToString() ?? "ConsumePlayerItem call failed"); + Assert.IsTrue(consumeResponse.success, consumeResponse.errorData?.ToString() ?? "ConsumePlayerInventoryItem call failed"); Assert.IsTrue(consumeResponse.consumed, "Item should report as consumed"); bool getCallCompleted = false; - LootLockerGetPlayerItemResponse getResponse = null; - LootLockerSDKManager.GetPlayerItem(grantedInventoryId, (r) => + LootLockerGetPlayerInventoryItemResponse getResponse = null; + LootLockerSDKManager.GetPlayerInventoryItem(grantedInventoryId, (r) => { getResponse = r; getCallCompleted = true; }); yield return new WaitUntil(() => getCallCompleted); - Assert.IsTrue(getResponse.success, getResponse.errorData?.ToString() ?? "GetPlayerItem after consume failed"); + Assert.IsTrue(getResponse.success, getResponse.errorData?.ToString() ?? "GetPlayerInventoryItem after consume failed"); Assert.AreEqual(3, getResponse.count, "Consuming 2 of 5 should leave 3"); } [UnityTest, Category("LootLocker"), Category("LootLockerCI")] [Timeout(360_000)] - public IEnumerator Items_ConsumePlayerItem_WholeStack_RemovesItem() + public IEnumerator Items_ConsumePlayerInventoryItem_WholeStack_RemovesItem() { Assert.IsFalse(SetupFailed, "Failed to setup game"); // When - passing the full count consumes the entire row bool consumeCallCompleted = false; - LootLockerConsumeItemResponse consumeResponse = null; - LootLockerSDKManager.ConsumePlayerItem(grantedInventoryId, 5, (r) => + LootLockerConsumeInventoryItemResponse consumeResponse = null; + LootLockerSDKManager.ConsumePlayerInventoryItem(grantedInventoryId, 5, (r) => { consumeResponse = r; consumeCallCompleted = true; @@ -320,32 +320,32 @@ public IEnumerator Items_ConsumePlayerItem_WholeStack_RemovesItem() yield return new WaitUntil(() => consumeCallCompleted); // Then - Assert.IsTrue(consumeResponse.success, consumeResponse.errorData?.ToString() ?? "ConsumePlayerItem call failed"); + Assert.IsTrue(consumeResponse.success, consumeResponse.errorData?.ToString() ?? "ConsumePlayerInventoryItem call failed"); Assert.IsTrue(consumeResponse.consumed, "Item should report as consumed"); bool listCallCompleted = false; - LootLockerListPlayerItemsResponse listResponse = null; - LootLockerSDKManager.ListPlayerItems(1, 100, onComplete: (r) => + LootLockerListPlayerInventoryItemsResponse listResponse = null; + LootLockerSDKManager.ListPlayerInventoryItems(1, 100, onComplete: (r) => { listResponse = r; listCallCompleted = true; }); yield return new WaitUntil(() => listCallCompleted); - Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerItems after consume failed"); + Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerInventoryItems after consume failed"); Assert.IsFalse(listResponse.items.Any(i => i.id == grantedInventoryId), "Fully consumed item should no longer be listed"); } [UnityTest, Category("LootLocker"), Category("LootLockerCI")] [Timeout(360_000)] - public IEnumerator Items_SplitPlayerItemStack_CreatesSecondStack() + public IEnumerator Items_SplitPlayerInventoryItemStack_CreatesSecondStack() { Assert.IsFalse(SetupFailed, "Failed to setup game"); // When bool splitCallCompleted = false; - LootLockerSplitItemStackResponse splitResponse = null; - LootLockerSDKManager.SplitPlayerItemStack(grantedInventoryId, 2, (r) => + LootLockerSplitInventoryItemStackResponse splitResponse = null; + LootLockerSDKManager.SplitPlayerInventoryItemStack(grantedInventoryId, 2, (r) => { splitResponse = r; splitCallCompleted = true; @@ -353,20 +353,20 @@ public IEnumerator Items_SplitPlayerItemStack_CreatesSecondStack() yield return new WaitUntil(() => splitCallCompleted); // Then - Assert.IsTrue(splitResponse.success, splitResponse.errorData?.ToString() ?? "SplitPlayerItemStack call failed"); + Assert.IsTrue(splitResponse.success, splitResponse.errorData?.ToString() ?? "SplitPlayerInventoryItemStack call failed"); Assert.IsNotNull(splitResponse.id, "Split should return the id of the new stack"); Assert.AreNotEqual(grantedInventoryId, splitResponse.id, "The new stack should have a different id"); bool listCallCompleted = false; - LootLockerListPlayerItemsResponse listResponse = null; - LootLockerSDKManager.ListPlayerItems(1, 100, onComplete: (r) => + LootLockerListPlayerInventoryItemsResponse listResponse = null; + LootLockerSDKManager.ListPlayerInventoryItems(1, 100, onComplete: (r) => { listResponse = r; listCallCompleted = true; }); yield return new WaitUntil(() => listCallCompleted); - Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerItems after split failed"); + Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerInventoryItems after split failed"); var original = listResponse.items.FirstOrDefault(i => i.id == grantedInventoryId); var split = listResponse.items.FirstOrDefault(i => i.id == splitResponse.id); Assert.IsNotNull(original, "Original stack should still be listed"); @@ -377,25 +377,25 @@ public IEnumerator Items_SplitPlayerItemStack_CreatesSecondStack() [UnityTest, Category("LootLocker"), Category("LootLockerCI")] [Timeout(360_000)] - public IEnumerator Items_MergePlayerItemStacks_CombinesCounts() + public IEnumerator Items_MergePlayerInventoryItemStacks_CombinesCounts() { Assert.IsFalse(SetupFailed, "Failed to setup game"); // Given - split off a second stack to merge back bool splitCallCompleted = false; - LootLockerSplitItemStackResponse splitResponse = null; - LootLockerSDKManager.SplitPlayerItemStack(grantedInventoryId, 2, (r) => + LootLockerSplitInventoryItemStackResponse splitResponse = null; + LootLockerSDKManager.SplitPlayerInventoryItemStack(grantedInventoryId, 2, (r) => { splitResponse = r; splitCallCompleted = true; }); yield return new WaitUntil(() => splitCallCompleted); - Assert.IsTrue(splitResponse.success, splitResponse.errorData?.ToString() ?? "SplitPlayerItemStack call failed"); + Assert.IsTrue(splitResponse.success, splitResponse.errorData?.ToString() ?? "SplitPlayerInventoryItemStack call failed"); // When - merge the new stack back into the original bool mergeCallCompleted = false; LootLockerResponse mergeResponse = null; - LootLockerSDKManager.MergePlayerItemStacks(splitResponse.id, grantedInventoryId, (r) => + LootLockerSDKManager.MergePlayerInventoryItemStacks(splitResponse.id, grantedInventoryId, (r) => { mergeResponse = r; mergeCallCompleted = true; @@ -403,18 +403,18 @@ public IEnumerator Items_MergePlayerItemStacks_CombinesCounts() yield return new WaitUntil(() => mergeCallCompleted); // Then - Assert.IsTrue(mergeResponse.success, mergeResponse.errorData?.ToString() ?? "MergePlayerItemStacks call failed"); + Assert.IsTrue(mergeResponse.success, mergeResponse.errorData?.ToString() ?? "MergePlayerInventoryItemStacks call failed"); bool listCallCompleted = false; - LootLockerListPlayerItemsResponse listResponse = null; - LootLockerSDKManager.ListPlayerItems(1, 100, onComplete: (r) => + LootLockerListPlayerInventoryItemsResponse listResponse = null; + LootLockerSDKManager.ListPlayerInventoryItems(1, 100, onComplete: (r) => { listResponse = r; listCallCompleted = true; }); yield return new WaitUntil(() => listCallCompleted); - Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerItems after merge failed"); + Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerInventoryItems after merge failed"); var target = listResponse.items.FirstOrDefault(i => i.id == grantedInventoryId); Assert.IsNotNull(target, "Target stack should still be listed"); Assert.AreEqual(5, target.count, "Merged stack should hold the combined count of 5"); @@ -423,14 +423,14 @@ public IEnumerator Items_MergePlayerItemStacks_CombinesCounts() [UnityTest, Category("LootLocker"), Category("LootLockerCI")] [Timeout(360_000)] - public IEnumerator Items_DeletePlayerItem_RemovesItem() + public IEnumerator Items_DeletePlayerInventoryItem_RemovesItem() { Assert.IsFalse(SetupFailed, "Failed to setup game"); // When bool deleteCallCompleted = false; LootLockerResponse deleteResponse = null; - LootLockerSDKManager.DeletePlayerItem(grantedInventoryId, (r) => + LootLockerSDKManager.DeletePlayerInventoryItem(grantedInventoryId, (r) => { deleteResponse = r; deleteCallCompleted = true; @@ -438,24 +438,24 @@ public IEnumerator Items_DeletePlayerItem_RemovesItem() yield return new WaitUntil(() => deleteCallCompleted); // Then - Assert.IsTrue(deleteResponse.success, deleteResponse.errorData?.ToString() ?? "DeletePlayerItem call failed"); + Assert.IsTrue(deleteResponse.success, deleteResponse.errorData?.ToString() ?? "DeletePlayerInventoryItem call failed"); bool listCallCompleted = false; - LootLockerListPlayerItemsResponse listResponse = null; - LootLockerSDKManager.ListPlayerItems(1, 100, onComplete: (r) => + LootLockerListPlayerInventoryItemsResponse listResponse = null; + LootLockerSDKManager.ListPlayerInventoryItems(1, 100, onComplete: (r) => { listResponse = r; listCallCompleted = true; }); yield return new WaitUntil(() => listCallCompleted); - Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerItems after delete failed"); + Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerInventoryItems after delete failed"); Assert.IsFalse(listResponse.items.Any(i => i.id == grantedInventoryId), "Deleted item should no longer be listed"); } [UnityTest, Category("LootLocker"), Category("LootLockerCI")] [Timeout(360_000)] - public IEnumerator Items_DeletePlayerItem_NotDeletable_ReturnsForbidden() + public IEnumerator Items_DeletePlayerInventoryItem_NotDeletable_ReturnsForbidden() { Assert.IsFalse(SetupFailed, "Failed to setup game"); @@ -497,7 +497,7 @@ public IEnumerator Items_DeletePlayerItem_NotDeletable_ReturnsForbidden() // When bool deleteCallCompleted = false; LootLockerResponse deleteResponse = null; - LootLockerSDKManager.DeletePlayerItem(nonDeletableInventoryId, (r) => + LootLockerSDKManager.DeletePlayerInventoryItem(nonDeletableInventoryId, (r) => { deleteResponse = r; deleteCallCompleted = true; diff --git a/Tests/LootLockerTests/PlayMode/JsonTests.cs b/Tests/LootLockerTests/PlayMode/JsonTests.cs index 408f6c099..f9dbc1697 100644 --- a/Tests/LootLockerTests/PlayMode/JsonTests.cs +++ b/Tests/LootLockerTests/PlayMode/JsonTests.cs @@ -320,7 +320,7 @@ public void Json_DeserializationCaseConversion_CaseIsConvertedToSnake() public void Json_SerializingConsumeItemRequestWithoutCount_OmitsCount() { // Given - var request = new LootLockerConsumeItemRequest(); + var request = new LootLockerConsumeInventoryItemRequest(); // When string serializedJson = LootLockerJson.SerializeObject(request); @@ -335,7 +335,7 @@ public void Json_SerializingConsumeItemRequestWithoutCount_OmitsCount() public void Json_SerializingConsumeItemRequestWithCount_IncludesCount() { // Given - var request = new LootLockerConsumeItemRequest { count = 3 }; + var request = new LootLockerConsumeInventoryItemRequest { count = 3 }; // When string serializedJson = LootLockerJson.SerializeObject(request); @@ -352,7 +352,7 @@ public void Json_SerializingConsumeItemRequestWithCount_IncludesCount() public void Json_SerializingSplitItemStackRequest_IncludesCount() { // Given - var request = new LootLockerSplitItemStackRequest { count = 5 }; + var request = new LootLockerSplitInventoryItemStackRequest { count = 5 }; // When string serializedJson = LootLockerJson.SerializeObject(request); @@ -369,7 +369,7 @@ public void Json_SerializingSplitItemStackRequest_IncludesCount() public void Json_SerializingMergeItemStacksRequest_IncludesBothInventoryIds() { // Given - var request = new LootLockerMergeItemStacksRequest + var request = new LootLockerMergeInventoryItemStacksRequest { source_inventory_id = "01HZZZZZZZZZZZZZZZZZZZZZZZ", target_inventory_id = "01HYYYYYYYYYYYYYYYYYYYYYYY" @@ -391,14 +391,14 @@ public void Json_SerializingMergeItemStacksRequest_IncludesBothInventoryIds() } [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] - public void Json_DeserializingListPlayerItemsResponse_Succeeds() + public void Json_DeserializingListPlayerInventoryItemsResponse_Succeeds() { // Given const string listPlayerItemsResponse = "{\"success\":true,\"items\":[{\"id\":\"01HZZZZZZZZZZZZZZZZZZZZZZZ\",\"player_id\":3,\"item_template_id\":\"01HAAAAAAAAAAAAAAAAAAAAAAA\",\"item_type\":\"stackable\",\"consumable\":true,\"count\":7,\"source\":\"grant\",\"name\":\"Health Potion\",\"deletable\":true,\"created_at\":\"2024-01-01T00:00:00Z\"}],\"pagination\":{\"total\":1,\"offset\":0,\"per_page\":25,\"last_page\":1,\"current_page\":1,\"next_page\":null,\"prev_page\":null}}"; // When - var deserialized = LootLockerJson.DeserializeObject(listPlayerItemsResponse); + var deserialized = LootLockerJson.DeserializeObject(listPlayerItemsResponse); // Then Assert.IsNotNull(deserialized, "Not deserialized, is null"); @@ -421,14 +421,14 @@ public void Json_DeserializingListPlayerItemsResponse_Succeeds() } [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")] - public void Json_DeserializingGetPlayerItemResponseWithTemplateAndMetadata_Succeeds() + public void Json_DeserializingGetPlayerInventoryItemResponseWithTemplateAndMetadata_Succeeds() { // Given const string getPlayerItemResponse = "{\"success\":true,\"id\":\"01HZZZZZZZZZZZZZZZZZZZZZZZ\",\"player_id\":3,\"item_template_id\":\"01HAAAAAAAAAAAAAAAAAAAAAAA\",\"item_type\":\"instanced\",\"consumable\":false,\"count\":1,\"source\":\"grant\",\"deletable\":true,\"created_at\":\"2024-01-01T00:00:00Z\",\"template\":{\"id\":\"01HAAAAAAAAAAAAAAAAAAAAAAA\",\"name\":\"Sword\",\"game_id\":42,\"limited\":0,\"item_type\":\"instanced\",\"consumable\":false,\"deletable\":true},\"metadata\":[{\"key\":\"damage\",\"value\":12,\"type\":\"number\",\"access\":[\"game_api.read\"],\"tags\":[\"combat\"]}]}"; // When - var deserialized = LootLockerJson.DeserializeObject(getPlayerItemResponse); + var deserialized = LootLockerJson.DeserializeObject(getPlayerItemResponse); // Then Assert.IsNotNull(deserialized, "Not deserialized, is null"); @@ -457,7 +457,7 @@ public void Json_DeserializingConsumeItemResponseWithGrantedItems_Succeeds() "{\"success\":true,\"consumed\":true,\"granted\":[{\"source_id\":\"01HBBBBBBBBBBBBBBBBBBBBBBB\",\"count\":2,\"type\":\"currency\",\"name\":\"Gold\",\"code\":\"gold\"}]}"; // When - var deserialized = LootLockerJson.DeserializeObject(consumeItemResponse); + var deserialized = LootLockerJson.DeserializeObject(consumeItemResponse); // Then Assert.IsNotNull(deserialized, "Not deserialized, is null"); From eeb50a37d3b294b4852854105b9c45a90765493c Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Wed, 30 Sep 2026 07:17:00 +0200 Subject: [PATCH 29/30] fix(unity): allow insecure http in local test runs The generated test project did not set `insecureHttpOption`, so every request to the local devenv (plain http on localhost:8080) failed with `InvalidOperationException: Insecure connection not allowed`. The CI workflow sets this for `TARGET_ENVIRONMENT=LOCAL`, but the local runner script did not, which meant local functional tests could never reach the local backend. Set it when `-UseLocalEnv` is passed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/run-tests.ps1 | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/scripts/run-tests.ps1 b/.github/scripts/run-tests.ps1 index fe5154e33..15d5f7139 100644 --- a/.github/scripts/run-tests.ps1 +++ b/.github/scripts/run-tests.ps1 @@ -124,6 +124,10 @@ function Initialize-TestProject { if (-not [string]::IsNullOrEmpty($scriptingDefines)) { $psContent += $nl + ' scriptingDefineSymbols:' + $nl + ' 1: ' + $scriptingDefines } + if ($UseLocalEnv) { + # The local devenv is plain http; Unity blocks insecure connections unless this is set. + $psContent += $nl + ' insecureHttpOption: 2' + } [IO.File]::WriteAllText((Join-Path $TempProject 'ProjectSettings\ProjectSettings.asset'), $psContent) } From 52dabb9d84be059dc35269f8615eaeffaf4e4290 Mon Sep 17 00:00:00 2001 From: Erik Bylund Date: Wed, 30 Sep 2026 08:58:04 +0200 Subject: [PATCH 30/30] fix(unity): support optional item consumption and local test settings Add the no-count consume overload so callers can use the documented default. Apply localhost HTTP and command-line routing settings to reused and custom Unity projects, not just freshly generated test projects. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/scripts/run-tests.ps1 | 40 ++++++++++++++++++++++++++++ Runtime/Game/LootLockerSDKManager.cs | 15 +++++++++++ 2 files changed, 55 insertions(+) diff --git a/.github/scripts/run-tests.ps1 b/.github/scripts/run-tests.ps1 index 15d5f7139..18acde9c3 100644 --- a/.github/scripts/run-tests.ps1 +++ b/.github/scripts/run-tests.ps1 @@ -148,6 +148,46 @@ if (-not [string]::IsNullOrWhiteSpace($CustomProject) -and (Test-Path $CustomPro $ProjectPath = $TempProject } +# Reused and custom projects need the same local settings as freshly initialized projects. +if ($UseLocalEnv) { + $ProjectSettingsFile = Join-Path $ProjectPath "ProjectSettings\ProjectSettings.asset" + if (Test-Path $ProjectSettingsFile) { + $nl = [char]10 + $settingsContent = [IO.File]::ReadAllText($ProjectSettingsFile) + if ($settingsContent -notmatch 'LOOTLOCKER_COMMANDLINE_SETTINGS') { + $defineProperty = [Regex]::Match($settingsContent, '(?m)^ scriptingDefineSymbols:[^\r\n]*(?:\r?\n|$)') + if ($defineProperty.Success) { + $groupOne = [Regex]::Match($settingsContent.Substring($defineProperty.Index + $defineProperty.Length), '\A(?: [^\r\n]*\r?\n)*? 1:[ \t]*[^\r\n]*') + if ($groupOne.Success) { + $start = $defineProperty.Index + $defineProperty.Length + $groupOne.Index + $newGroup = if ($groupOne.Value -match '^ 1:[ \t]*$') { + ' 1: LOOTLOCKER_COMMANDLINE_SETTINGS' + } else { + $groupOne.Value + ';LOOTLOCKER_COMMANDLINE_SETTINGS' + } + $settingsContent = $settingsContent.Remove($start, $groupOne.Length).Insert($start, $newGroup) + } else { + $settingsContent = $settingsContent.Insert($defineProperty.Index + $defineProperty.Length, ' 1: LOOTLOCKER_COMMANDLINE_SETTINGS' + $nl) + } + } else { + $settingsContent = $settingsContent.TrimEnd() + $nl + ' scriptingDefineSymbols:' + $nl + ' 1: LOOTLOCKER_COMMANDLINE_SETTINGS' + $nl + } + } + if ($settingsContent -match '(?m)^[ \t]*insecureHttpOption:') { + $updatedContent = [Regex]::Replace($settingsContent, '(?m)^[ \t]*insecureHttpOption:.*$', ' insecureHttpOption: 2') + } else { + $updatedContent = $settingsContent.TrimEnd() + $nl + ' insecureHttpOption: 2' + $nl + } + if ($updatedContent -ne $settingsContent) { + [IO.File]::WriteAllText($ProjectSettingsFile, $updatedContent) + Write-Step "Enabled insecure http in $ProjectSettingsFile" + } + } else { + Write-Fail "ProjectSettings.asset not found at $ProjectSettingsFile; cannot enable local http." + exit 1 + } +} + $ResultsFile = Join-Path $ProjectPath "TestResults.xml" $LogFile = Join-Path $ProjectPath "test-run.log" diff --git a/Runtime/Game/LootLockerSDKManager.cs b/Runtime/Game/LootLockerSDKManager.cs index d63b3734a..204a42240 100644 --- a/Runtime/Game/LootLockerSDKManager.cs +++ b/Runtime/Game/LootLockerSDKManager.cs @@ -6798,6 +6798,21 @@ public static void ConsumePlayerInventoryItem(string inventoryId, int? count, Ac LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); }); } + /// + /// Consumes a single item from a stackable inventory item. + /// + /// + /// Operates on items and item templates. This is unrelated to the asset-based + /// inventory API, which deals with assets and asset instances. + /// + /// The id of the stackable inventory item to consume. + /// Delegate for handling the server response + /// Optional : Execute the request for the specified player. If not supplied, the default player will be used. + public static void ConsumePlayerInventoryItem(string inventoryId, Action onComplete, string forPlayerWithUlid = null) + { + ConsumePlayerInventoryItem(inventoryId, null, onComplete, forPlayerWithUlid); + } + /// /// Splits a stackable inventory item into two stacks, moving items into a new stack. ///