diff --git a/.github/scripts/run-tests.ps1 b/.github/scripts/run-tests.ps1
index fe5154e33..18acde9c3 100644
--- a/.github/scripts/run-tests.ps1
+++ b/.github/scripts/run-tests.ps1
@@ -124,6 +124,10 @@ function Initialize-TestProject {
if (-not [string]::IsNullOrEmpty($scriptingDefines)) {
$psContent += $nl + ' scriptingDefineSymbols:' + $nl + ' 1: ' + $scriptingDefines
}
+ if ($UseLocalEnv) {
+ # The local devenv is plain http; Unity blocks insecure connections unless this is set.
+ $psContent += $nl + ' insecureHttpOption: 2'
+ }
[IO.File]::WriteAllText((Join-Path $TempProject 'ProjectSettings\ProjectSettings.asset'), $psContent)
}
@@ -144,6 +148,46 @@ if (-not [string]::IsNullOrWhiteSpace($CustomProject) -and (Test-Path $CustomPro
$ProjectPath = $TempProject
}
+# Reused and custom projects need the same local settings as freshly initialized projects.
+if ($UseLocalEnv) {
+ $ProjectSettingsFile = Join-Path $ProjectPath "ProjectSettings\ProjectSettings.asset"
+ if (Test-Path $ProjectSettingsFile) {
+ $nl = [char]10
+ $settingsContent = [IO.File]::ReadAllText($ProjectSettingsFile)
+ if ($settingsContent -notmatch 'LOOTLOCKER_COMMANDLINE_SETTINGS') {
+ $defineProperty = [Regex]::Match($settingsContent, '(?m)^ scriptingDefineSymbols:[^\r\n]*(?:\r?\n|$)')
+ if ($defineProperty.Success) {
+ $groupOne = [Regex]::Match($settingsContent.Substring($defineProperty.Index + $defineProperty.Length), '\A(?: [^\r\n]*\r?\n)*? 1:[ \t]*[^\r\n]*')
+ if ($groupOne.Success) {
+ $start = $defineProperty.Index + $defineProperty.Length + $groupOne.Index
+ $newGroup = if ($groupOne.Value -match '^ 1:[ \t]*$') {
+ ' 1: LOOTLOCKER_COMMANDLINE_SETTINGS'
+ } else {
+ $groupOne.Value + ';LOOTLOCKER_COMMANDLINE_SETTINGS'
+ }
+ $settingsContent = $settingsContent.Remove($start, $groupOne.Length).Insert($start, $newGroup)
+ } else {
+ $settingsContent = $settingsContent.Insert($defineProperty.Index + $defineProperty.Length, ' 1: LOOTLOCKER_COMMANDLINE_SETTINGS' + $nl)
+ }
+ } else {
+ $settingsContent = $settingsContent.TrimEnd() + $nl + ' scriptingDefineSymbols:' + $nl + ' 1: LOOTLOCKER_COMMANDLINE_SETTINGS' + $nl
+ }
+ }
+ if ($settingsContent -match '(?m)^[ \t]*insecureHttpOption:') {
+ $updatedContent = [Regex]::Replace($settingsContent, '(?m)^[ \t]*insecureHttpOption:.*$', ' insecureHttpOption: 2')
+ } else {
+ $updatedContent = $settingsContent.TrimEnd() + $nl + ' insecureHttpOption: 2' + $nl
+ }
+ if ($updatedContent -ne $settingsContent) {
+ [IO.File]::WriteAllText($ProjectSettingsFile, $updatedContent)
+ Write-Step "Enabled insecure http in $ProjectSettingsFile"
+ }
+ } else {
+ Write-Fail "ProjectSettings.asset not found at $ProjectSettingsFile; cannot enable local http."
+ exit 1
+ }
+}
+
$ResultsFile = Join-Path $ProjectPath "TestResults.xml"
$LogFile = Join-Path $ProjectPath "test-run.log"
diff --git a/Runtime/Client/LootLockerEndPoints.cs b/Runtime/Client/LootLockerEndPoints.cs
index 6a5aee8f8..499611a39 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 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 2d104dacf..204a42240 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))
@@ -6626,7 +6641,232 @@ public static void DeleteAssetInstanceFromPlayerInventory(int assetInstanceID, A
LootLockerAPIManager.DeleteAssetInstanceFromPlayerInventory(forPlayerWithUlid, data, onComplete);
}
#endregion
-
+
+ #region Items
+
+ // These methods operate on items and item templates. They are unrelated to the asset-based
+ // inventory API (GetInventory, ListPlayerInventory, ...), which deals with assets and asset
+ // instances. The two share a URL prefix but are separate systems.
+
+ ///
+ /// Returns a paginated list of all visible item templates.
+ ///
+ /// The page of item templates to return.
+ /// The number of item templates to return per page.
+ /// Delegate for handling the server response
+ /// Optional : Execute the request for the specified player. If not supplied, the default player will be used.
+ public static void ListItemTemplates(int page, int perPage, Action onComplete, string forPlayerWithUlid = null)
+ {
+ if (!CheckInitialized(false, forPlayerWithUlid))
+ {
+ onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid));
+ return;
+ }
+
+ EndPointClass endPoint = LootLockerEndPoints.listItemTemplates;
+
+ var queryParams = new LootLocker.Utilities.HTTP.QueryParamaterBuilder();
+ if (page > 0)
+ queryParams.Add("page", page);
+ if (perPage > 0)
+ queryParams.Add("per_page", perPage);
+
+ LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint + queryParams.Build(), endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); });
+ }
+
+ ///
+ /// Returns a paginated list of the current player's inventory items, including their item templates.
+ ///
+ ///
+ /// Operates on items and item templates. This is unrelated to the asset-based
+ /// inventory API (, ,
+ /// , ), which deals with
+ /// assets and asset instances. The two share a URL prefix but are separate systems.
+ ///
+ /// The page of items to return.
+ /// The number of items to return per page.
+ /// (Optional) Return only items whose template name starts with the specified value. Set to null to not use this filter.
+ /// (Optional) Return only items of the specified type. Set to null to not use this filter.
+ /// (Optional) Return only items that are (or are not) consumable. Set to null to not use this filter.
+ /// (Optional) The field by which to sort the items. Set to null to not use this filter.
+ /// (Optional) The direction in which to sort the items. Set to null to not use this filter.
+ /// Delegate for handling the server response
+ /// Optional : Execute the request for the specified player. If not supplied, the default player will be used.
+ public static void ListPlayerInventoryItems(int page, int perPage, string name = null, LootLockerItemType? itemType = null, bool? consumable = null, LootLockerItemSortField? sort = null, LootLockerSortOrder? order = null, Action onComplete = null, string forPlayerWithUlid = null)
+ {
+ if (!CheckInitialized(false, forPlayerWithUlid))
+ {
+ onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid));
+ return;
+ }
+
+ EndPointClass endPoint = LootLockerEndPoints.listPlayerInventoryItems;
+
+ var queryParams = new LootLocker.Utilities.HTTP.QueryParamaterBuilder();
+ if (page > 0)
+ queryParams.Add("page", page);
+ if (perPage > 0)
+ queryParams.Add("per_page", perPage);
+ queryParams.Add("name", name);
+ if (itemType.HasValue)
+ queryParams.Add("item_type", itemType.Value.ToString());
+ if (consumable.HasValue)
+ queryParams.Add("consumable", consumable.Value.ToString().ToLower());
+ if (sort.HasValue)
+ queryParams.Add("sort", sort.Value.ToString());
+ if (order.HasValue)
+ queryParams.Add("order", order.Value.ToString());
+
+ LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint + queryParams.Build(), endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); });
+ }
+
+ ///
+ /// Returns a single inventory item for the current player, including its item template.
+ ///
+ ///
+ /// Operates on items and item templates. This is unrelated to the asset-based
+ /// inventory API, which deals with assets and asset instances.
+ ///
+ /// The id of the inventory item to fetch.
+ /// Delegate for handling the server response
+ /// Optional : Execute the request for the specified player. If not supplied, the default player will be used.
+ public static void GetPlayerInventoryItem(string inventoryId, Action onComplete, string forPlayerWithUlid = null)
+ {
+ if (!CheckInitialized(false, forPlayerWithUlid))
+ {
+ onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid));
+ return;
+ }
+
+ EndPointClass endPoint = LootLockerEndPoints.getPlayerInventoryItem;
+
+ string getVariable = endPoint.WithPathParameter(inventoryId);
+
+ LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); });
+ }
+
+ ///
+ /// Deletes an inventory item from the current player's inventory.
+ ///
+ ///
+ /// Operates on items and item templates. This is unrelated to the asset-based
+ /// inventory API (), which deals with
+ /// assets and asset instances.
+ ///
+ /// The id of the inventory item to delete.
+ /// Delegate for handling the server response
+ /// Optional : Execute the request for the specified player. If not supplied, the default player will be used.
+ public static void DeletePlayerInventoryItem(string inventoryId, Action onComplete, string forPlayerWithUlid = null)
+ {
+ if (!CheckInitialized(false, forPlayerWithUlid))
+ {
+ onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid));
+ return;
+ }
+
+ EndPointClass endPoint = LootLockerEndPoints.deletePlayerInventoryItem;
+
+ string getVariable = endPoint.WithPathParameter(inventoryId);
+
+ LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, null, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); });
+ }
+
+ ///
+ /// Consumes one or more items from a stackable inventory item. If is null, a single item is consumed.
+ ///
+ ///
+ /// Operates on items and item templates. This is unrelated to the asset-based
+ /// inventory API, which deals with assets and asset instances.
+ ///
+ /// The id of the stackable inventory item to consume.
+ /// (Optional) The number of items to consume. If null, the backend consumes 1. To consume the whole stack, pass the item's current count.
+ /// Delegate for handling the server response
+ /// Optional : Execute the request for the specified player. If not supplied, the default player will be used.
+ public static void ConsumePlayerInventoryItem(string inventoryId, int? count, Action onComplete, string forPlayerWithUlid = null)
+ {
+ if (!CheckInitialized(false, forPlayerWithUlid))
+ {
+ onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid));
+ return;
+ }
+
+ EndPointClass endPoint = LootLockerEndPoints.consumePlayerInventoryItem;
+
+ string getVariable = endPoint.WithPathParameter(inventoryId);
+ string json = LootLockerJson.SerializeObject(new LootLockerConsumeInventoryItemRequest { count = count });
+
+ LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); });
+ }
+
+ ///
+ /// Consumes a single item from a stackable inventory item.
+ ///
+ ///
+ /// Operates on items and item templates. This is unrelated to the asset-based
+ /// inventory API, which deals with assets and asset instances.
+ ///
+ /// The id of the stackable inventory item to consume.
+ /// Delegate for handling the server response
+ /// Optional : Execute the request for the specified player. If not supplied, the default player will be used.
+ public static void ConsumePlayerInventoryItem(string inventoryId, Action onComplete, string forPlayerWithUlid = null)
+ {
+ ConsumePlayerInventoryItem(inventoryId, null, onComplete, forPlayerWithUlid);
+ }
+
+ ///
+ /// Splits a stackable inventory item into two stacks, moving items into a new stack.
+ ///
+ ///
+ /// Operates on items and item templates. This is unrelated to the asset-based
+ /// inventory API, which deals with assets and asset instances.
+ ///
+ /// The id of the stackable inventory item to split.
+ /// The number of items to move into the new stack.
+ /// Delegate for handling the server response
+ /// Optional : Execute the request for the specified player. If not supplied, the default player will be used.
+ public static void SplitPlayerInventoryItemStack(string inventoryId, int count, Action onComplete, string forPlayerWithUlid = null)
+ {
+ if (!CheckInitialized(false, forPlayerWithUlid))
+ {
+ onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid));
+ return;
+ }
+
+ EndPointClass endPoint = LootLockerEndPoints.splitPlayerInventoryItemStack;
+
+ string getVariable = endPoint.WithPathParameter(inventoryId);
+ string json = LootLockerJson.SerializeObject(new LootLockerSplitInventoryItemStackRequest { count = count });
+
+ LootLockerServerRequest.CallAPI(forPlayerWithUlid, getVariable, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); });
+ }
+
+ ///
+ /// Merges two stacks of the same item into one.
+ ///
+ ///
+ /// Operates on items and item templates. This is unrelated to the asset-based
+ /// inventory API, which deals with assets and asset instances.
+ ///
+ /// The id of the inventory item to merge from.
+ /// The id of the inventory item to merge into.
+ /// Delegate for handling the server response
+ /// Optional : Execute the request for the specified player. If not supplied, the default player will be used.
+ public static void MergePlayerInventoryItemStacks(string sourceInventoryId, string targetInventoryId, Action onComplete, string forPlayerWithUlid = null)
+ {
+ if (!CheckInitialized(false, forPlayerWithUlid))
+ {
+ onComplete?.Invoke(LootLockerResponseFactory.SDKNotInitializedError(forPlayerWithUlid));
+ return;
+ }
+
+ EndPointClass endPoint = LootLockerEndPoints.mergePlayerInventoryItemStacks;
+
+ string json = LootLockerJson.SerializeObject(new LootLockerMergeInventoryItemStacksRequest { source_inventory_id = sourceInventoryId, target_inventory_id = targetInventoryId });
+
+ LootLockerServerRequest.CallAPI(forPlayerWithUlid, endPoint.endPoint, endPoint.httpMethod, json, onComplete: (serverResponse) => { LootLockerResponse.Deserialize(onComplete, serverResponse); });
+ }
+ #endregion
+
#region AssetInstance progressions
/// @ingroup AssetInstanceProgressions
diff --git a/Runtime/Game/Requests/ItemRequest.cs b/Runtime/Game/Requests/ItemRequest.cs
new file mode 100644
index 000000000..4112d9501
--- /dev/null
+++ b/Runtime/Game/Requests/ItemRequest.cs
@@ -0,0 +1,322 @@
+using System;
+using LootLocker.LootLockerEnums;
+using LootLocker.Requests;
+
+namespace LootLocker.LootLockerEnums
+{
+ ///
+ /// The type of an item, determining whether the item instances are stackable or individually tracked.
+ ///
+ public enum LootLockerItemType
+ {
+ /// Each granted item is a separate, individually tracked instance.
+ instanced,
+ /// The item is stored as a single entry with a count that can be incremented or decremented.
+ stackable
+ }
+
+ ///
+ /// The field by which to order a player item list response.
+ ///
+ public enum LootLockerItemSortField
+ {
+ /// Order by when the item was created.
+ created_at,
+ /// Order by when the item was last updated.
+ updated_at,
+ /// Order by the source that granted the item.
+ source
+ }
+
+ ///
+ /// The kind of reward that was granted as a result of a behaviour.
+ ///
+ public enum LootLockerRewardKind
+ {
+ /// The reward kind could not be determined.
+ unknown,
+ /// An asset.
+ asset,
+ /// Progression points.
+ progression_points,
+ /// A progression reset.
+ progression_reset,
+ /// A currency.
+ currency,
+ /// A group.
+ group,
+ /// A reward.
+ reward,
+ /// A platform key.
+ platform_key,
+ /// A publisher currency.
+ publisher_currency,
+ /// Publisher progression points.
+ publisher_progression_points,
+ /// Player metadata.
+ player_metadata,
+ /// A file.
+ file,
+ /// A Discord role.
+ discord_role,
+ /// An item template.
+ item_template
+ }
+
+ ///
+ /// The direction in which to order a player item list response.
+ ///
+ public enum LootLockerSortOrder
+ {
+ /// Order ascending.
+ ASC,
+ /// Order descending.
+ DESC
+ }
+}
+
+namespace LootLocker.Requests
+{
+ //==================================================
+ // Item Template Definitions
+ //==================================================
+
+ ///
+ /// An item template defines a type of item that can be granted to players.
+ ///
+ public class LootLockerItemTemplate
+ {
+ /// The unique id of the item template.
+ public string id { get; set; }
+
+ /// The name of the item template.
+ public string name { get; set; }
+
+ /// Indicates whether this item template is limited in how many of it can exist.
+ public int limited { get; set; }
+
+ /// The type of the item (instanced or stackable).
+ public LootLockerItemType item_type { get; set; }
+
+ /// True if the item can be consumed by the player.
+ public bool consumable { get; set; }
+
+ /// True if the item can be deleted by the player.
+ public bool deletable { get; set; }
+
+ /// The time that this item template was created.
+ public DateTime created_at { get; set; }
+
+ /// The time that this item template was last updated, or null if it has never been updated.
+ public DateTime? updated_at { get; set; }
+ }
+
+ //==================================================
+ // Player Item Definitions
+ //==================================================
+
+ ///
+ /// An item instance held in a player's inventory.
+ ///
+ ///
+ /// This represents an item granted from an item template. It is unrelated to the
+ /// asset-based inventory API (, GetInventory,
+ /// ListPlayerInventory), which deals with assets and asset instances.
+ ///
+ public class LootLockerInventoryItem
+ {
+ /// The unique id of this inventory item.
+ public string id { get; set; }
+
+ /// The id of the player that owns this inventory item.
+ public int player_id { get; set; }
+
+ /// The id of the item template this inventory item is based on.
+ public string item_template_id { get; set; }
+
+ /// The type of the item (instanced or stackable).
+ public LootLockerItemType item_type { get; set; }
+
+ /// True if the item can be consumed by the player.
+ public bool consumable { get; set; }
+
+ /// True if the item can be deleted by the player.
+ public bool deletable { get; set; }
+
+ /// The number of items in this stack (only relevant for stackable items).
+ public int count { get; set; }
+
+ /// The name of the item template this inventory item is based on.
+ public string name { get; set; }
+
+ /// The source of this inventory item, or null if not specified.
+ public string source { get; set; }
+
+ /// The time that this inventory item was created.
+ public DateTime created_at { get; set; }
+
+ /// The time that this inventory item was last updated, or null if it has never been updated.
+ public DateTime? updated_at { get; set; }
+
+ /// Arbitrary metadata attached to this inventory item.
+ public LootLockerMetadataEntry[] metadata { get; set; }
+ }
+
+ ///
+ /// An item granted to a player as the result of consuming another item (or via an on-grant behaviour).
+ /// Matches the go-backend `GrantResult` shape returned in `granted` / `behaviour_results`.
+ ///
+ public class LootLockerGrantedItem
+ {
+ /// The source id of the granted entry (inventory id, currency id, etc.).
+ public string source_id { get; set; }
+
+ /// The number of items granted.
+ public int count { get; set; }
+
+ /// The type of the granted entry.
+ public LootLockerRewardKind type { get; set; }
+
+ /// The name of the granted entry, if applicable.
+ public string name { get; set; }
+
+ /// The code of the granted entry, if applicable.
+ public string code { get; set; }
+ }
+
+ //==================================================
+ // Request Definitions
+ //==================================================
+
+ ///
+ /// Request to consume a stackable item from the player's inventory.
+ ///
+ public class LootLockerConsumeInventoryItemRequest
+ {
+ ///
+ /// The number of items to consume. Defaults to 1 when omitted. To consume an entire stack,
+ /// pass the item's current count.
+ ///
+ public int? count { get; set; }
+
+ public bool ShouldSerializecount()
+ {
+ // Omit count entirely when unset so the backend applies its default of 1. This also keeps
+ // the Newtonsoft and ZeroDep JSON backends consistent, since only ZeroDep skips nulls.
+ return count.HasValue;
+ }
+ }
+
+ ///
+ /// Request to split a stackable item in the player's inventory into two stacks.
+ ///
+ public class LootLockerSplitInventoryItemStackRequest
+ {
+ /// The number of items to move into the new stack.
+ public int count { get; set; }
+ }
+
+ ///
+ /// Request to merge two stacks of the same item into one.
+ ///
+ public class LootLockerMergeInventoryItemStacksRequest
+ {
+ /// The id of the source inventory item to merge from.
+ public string source_inventory_id { get; set; }
+
+ /// The id of the target inventory item to merge into.
+ public string target_inventory_id { get; set; }
+ }
+
+ //==================================================
+ // Response Definitions
+ //==================================================
+
+ ///
+ /// Response containing a paginated list of visible item templates.
+ ///
+ public class LootLockerListItemTemplatesResponse : LootLockerResponse
+ {
+ /// The list of visible item templates.
+ public LootLockerItemTemplate[] items { get; set; }
+
+ /// Pagination details for the response.
+ public LootLockerExtendedPagination pagination { get; set; }
+ }
+
+ ///
+ /// Response containing a paginated list of the player's inventory items.
+ ///
+ public class LootLockerListPlayerInventoryItemsResponse : LootLockerResponse
+ {
+ /// The list of the player's inventory items.
+ public LootLockerInventoryItem[] items { get; set; }
+
+ /// Pagination details for the response.
+ public LootLockerExtendedPagination pagination { get; set; }
+ }
+
+ ///
+ /// Response containing the player's inventory item, returned at the top level with its template and metadata.
+ ///
+ public class LootLockerGetPlayerInventoryItemResponse : LootLockerResponse
+ {
+ /// The unique id of this inventory item.
+ public string id { get; set; }
+
+ /// The id of the player that owns this inventory item.
+ public int player_id { get; set; }
+
+ /// The id of the item template this inventory item is based on.
+ public string item_template_id { get; set; }
+
+ /// The type of the item (instanced or stackable).
+ public LootLockerItemType item_type { get; set; }
+
+ /// True if the item can be consumed by the player.
+ public bool consumable { get; set; }
+
+ /// True if the item can be deleted by the player.
+ public bool deletable { get; set; }
+
+ /// The number of items in this stack (only relevant for stackable items).
+ public int count { get; set; }
+
+ /// The source of this inventory item, or null if not specified.
+ public string source { get; set; }
+
+ /// The time that this inventory item was created.
+ public DateTime created_at { get; set; }
+
+ /// The time that this inventory item was last updated, or null if it has never been updated.
+ public DateTime? updated_at { get; set; }
+
+ /// The item template this inventory item is based on.
+ public LootLockerItemTemplate template { get; set; }
+
+ /// Arbitrary metadata attached to this inventory item.
+ public LootLockerMetadataEntry[] metadata { get; set; }
+ }
+
+ ///
+ /// Response containing the result of consuming an item, including any items granted as a result.
+ ///
+ public class LootLockerConsumeInventoryItemResponse : LootLockerResponse
+ {
+ /// True if the item was consumed.
+ public bool consumed { get; set; }
+
+ /// Any items granted as a result of consuming the item.
+ public LootLockerGrantedItem[] granted { get; set; }
+ }
+
+ ///
+ /// Response containing the id of the newly created stack after splitting an item.
+ ///
+ public class LootLockerSplitInventoryItemStackResponse : LootLockerResponse
+ {
+ /// The id of the newly created item stack.
+ public string id { get; set; }
+ }
+}
+
diff --git a/Runtime/Game/Requests/ItemRequest.cs.meta b/Runtime/Game/Requests/ItemRequest.cs.meta
new file mode 100644
index 000000000..0f33bdad8
--- /dev/null
+++ b/Runtime/Game/Requests/ItemRequest.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 1c184783fa724925b081edf9211f3855
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/Game/Requests/MetadataRequests.cs b/Runtime/Game/Requests/MetadataRequests.cs
index 896c274c3..4a9e4fdcc 100644
--- a/Runtime/Game/Requests/MetadataRequests.cs
+++ b/Runtime/Game/Requests/MetadataRequests.cs
@@ -25,7 +25,9 @@ public enum LootLockerMetadataSources
player = 5,
self = 6,
asset = 7,
- item = 8, // This is the source for asset instances (player inventory items), while the "asset" source is for the asset in general
+ item = 8, // This is the source for legacy items, while the "inventory_item" source is for item instances
+ inventory_item = 9, // This is the source for item instances (player inventory items)
+ item_template = 10, // This is the source for item templates
};
///
diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs
index c8f22a1e0..c5ed49b6a 100644
--- a/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs
+++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationEndpoints.cs
@@ -109,6 +109,20 @@ public class LootLockerTestConfigurationEndpoints
[Header("LootLocker Admin API Inventory Operations")]
public static EndPointClass adminGrantAssetToPlayerInventory = new EndPointClass("/game/#GAMEID#/player/{0}/inventory/grant", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin);
+ [Header("LootLocker Admin API Item Template Operations")]
+ public static EndPointClass createItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin);
+ public static EndPointClass listItemTemplates = new EndPointClass("game/#GAMEID#/templates/v1", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin);
+ public static EndPointClass getItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1/{0}", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin);
+ public static EndPointClass updateItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1/{0}", LootLockerHTTPMethod.PATCH, LootLockerCallerRole.Admin);
+ public static EndPointClass deleteItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1/{0}", LootLockerHTTPMethod.DELETE, LootLockerCallerRole.Admin);
+ public static EndPointClass grantItemTemplate = new EndPointClass("game/#GAMEID#/templates/v1/grant", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin);
+ public static EndPointClass setItemTemplateAudiences = new EndPointClass("game/#GAMEID#/templates/v1/{0}/audiences", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin);
+ public static EndPointClass adminListPlayerInventoryItems = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory", LootLockerHTTPMethod.GET, LootLockerCallerRole.Admin);
+ public static EndPointClass adminConsumePlayerInventoryItem = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/{1}/consume", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin);
+ public static EndPointClass adminDeletePlayerInventoryItem = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/{1}", LootLockerHTTPMethod.DELETE, LootLockerCallerRole.Admin);
+ public static EndPointClass adminSplitPlayerInventoryItemStack = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/{1}/split", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin);
+ public static EndPointClass adminMergePlayerInventoryItemStacks = new EndPointClass("game/#GAMEID#/templates/v1/players/{0}/inventory/merge", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin);
+
[Header("LootLocker Admin API Metadata Operations")]
public static EndPointClass metadataOperations = new EndPointClass("game/#GAMEID#/metadata", LootLockerHTTPMethod.POST, LootLockerCallerRole.Admin);
diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs
new file mode 100644
index 000000000..35c01b040
--- /dev/null
+++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs
@@ -0,0 +1,276 @@
+using System;
+using LootLocker;
+using Random = UnityEngine.Random;
+
+namespace LootLockerTestConfigurationUtils
+{
+ ///
+ /// Admin API helpers for setting up item templates and player inventory items in tests.
+ ///
+ ///
+ /// These helpers deliberately mirror the go-backend admin routes rather than the published
+ /// ApiDog spec, which is stale for this feature. Notable differences from the spec:
+ /// the list endpoint returns templates (not items), grant returns id
+ /// (not inventory_id), consume requires a body and returns a count (not a bool),
+ /// and the player inventory listing uses template_name and has no deletable.
+ ///
+ public static class LootLockerTestItems
+ {
+ public static string GetRandomItemTemplateName()
+ {
+ string[] colors = { "Green", "Blue", "Red", "Black", "Yellow", "Orange", "Purple", "Indigo", "Clear", "White", "Magenta", "Marine", "Crimson", "Teal" };
+ string[] items = { "Rod", "House", "Wand", "Staff", "Car", "Sword", "Shield", "Gun", "Shovel", "Boomstick", "Rifle", "Hut", "Boat", "Bicycle", "Wheelchair" };
+
+ return colors[Random.Range(0, colors.Length)] + " " + items[Random.Range(0, items.Length)];
+ }
+
+ ///
+ /// Create an item template. Audiences are set inline by the create endpoint, which is
+ /// required for the template to be visible to the player through the game API.
+ ///
+ public static void CreateItemTemplate(string name, string itemType, bool consumable, bool deletable, string[] audiences, Action onComplete)
+ {
+ if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken))
+ {
+ onComplete?.Invoke(new LootLockerTestItemTemplateResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } });
+ return;
+ }
+
+ var request = new LootLockerTestCreateItemTemplateRequest
+ {
+ name = name,
+ item_type = itemType,
+ consumable = consumable,
+ deletable = deletable,
+ audiences = audiences
+ };
+
+ var endpoint = LootLockerTestConfigurationEndpoints.createItemTemplate;
+ string json = LootLockerJson.SerializeObject(request);
+
+ LootLockerAdminRequest.Send(endpoint.endPoint, endpoint.httpMethod, json, onComplete: (serverResponse) =>
+ {
+ var response = LootLockerResponse.Deserialize(serverResponse);
+ onComplete?.Invoke(response);
+ }, true);
+ }
+
+ ///
+ /// Grant an item template to a player. Returns the id of the created inventory item.
+ ///
+ public static void GrantItemToPlayer(int playerId, string itemTemplateId, int count, Action onComplete)
+ {
+ if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken))
+ {
+ onComplete?.Invoke(new LootLockerTestGrantItemResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } });
+ return;
+ }
+
+ var request = new LootLockerTestGrantItemRequest
+ {
+ player_id = playerId,
+ item_template_id = itemTemplateId,
+ count = count
+ };
+
+ var endpoint = LootLockerTestConfigurationEndpoints.grantItemTemplate;
+ string json = LootLockerJson.SerializeObject(request);
+
+ LootLockerAdminRequest.Send(endpoint.endPoint, endpoint.httpMethod, json, onComplete: (serverResponse) =>
+ {
+ var response = LootLockerResponse.Deserialize(serverResponse);
+ onComplete?.Invoke(response);
+ }, true);
+ }
+
+ public static void SetItemTemplateAudiences(string itemTemplateId, string[] audiences, Action onComplete)
+ {
+ if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken))
+ {
+ onComplete?.Invoke(new LootLockerResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } });
+ return;
+ }
+
+ var endpoint = LootLockerTestConfigurationEndpoints.setItemTemplateAudiences;
+ string formattedEndpoint = string.Format(endpoint.endPoint, itemTemplateId);
+ string json = LootLockerJson.SerializeObject(new LootLockerTestSetItemTemplateAudiencesRequest { audiences = audiences });
+
+ LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, json, onComplete, true);
+ }
+
+ public static void ListPlayerInventoryItemsAdmin(int playerId, Action onComplete)
+ {
+ if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken))
+ {
+ onComplete?.Invoke(new LootLockerTestAdminListPlayerInventoryItemsResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } });
+ return;
+ }
+
+ var endpoint = LootLockerTestConfigurationEndpoints.adminListPlayerInventoryItems;
+ string formattedEndpoint = string.Format(endpoint.endPoint, playerId);
+
+ LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, null, onComplete: (serverResponse) =>
+ {
+ var response = LootLockerResponse.Deserialize(serverResponse);
+ onComplete?.Invoke(response);
+ }, true);
+ }
+
+ ///
+ /// Consume items through the admin API. The endpoint requires a body carrying the
+ /// player id; of 0 lets the backend apply its default of 1.
+ ///
+ public static void ConsumePlayerInventoryItemAdmin(int playerId, string inventoryId, int count, Action onComplete)
+ {
+ if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken))
+ {
+ onComplete?.Invoke(new LootLockerTestAdminConsumeItemResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } });
+ return;
+ }
+
+ var endpoint = LootLockerTestConfigurationEndpoints.adminConsumePlayerInventoryItem;
+ string formattedEndpoint = string.Format(endpoint.endPoint, playerId, inventoryId);
+ string json = LootLockerJson.SerializeObject(new LootLockerTestAdminConsumeItemRequest { player_id = playerId, count = count });
+
+ LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, json, onComplete: (serverResponse) =>
+ {
+ var response = LootLockerResponse.Deserialize(serverResponse);
+ onComplete?.Invoke(response);
+ }, true);
+ }
+
+ public static void DeletePlayerInventoryItemAdmin(int playerId, string inventoryId, Action onComplete)
+ {
+ if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken))
+ {
+ onComplete?.Invoke(new LootLockerResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } });
+ return;
+ }
+
+ var endpoint = LootLockerTestConfigurationEndpoints.adminDeletePlayerInventoryItem;
+ string formattedEndpoint = string.Format(endpoint.endPoint, playerId, inventoryId);
+
+ LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, null, onComplete, true);
+ }
+
+ public static void SplitPlayerInventoryItemStackAdmin(int playerId, string inventoryId, int count, Action onComplete)
+ {
+ if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken))
+ {
+ onComplete?.Invoke(new LootLockerTestSplitItemStackResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } });
+ return;
+ }
+
+ var endpoint = LootLockerTestConfigurationEndpoints.adminSplitPlayerInventoryItemStack;
+ string formattedEndpoint = string.Format(endpoint.endPoint, playerId, inventoryId);
+ string json = LootLockerJson.SerializeObject(new LootLockerTestSplitItemStackRequest { count = count });
+
+ LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, json, onComplete: (serverResponse) =>
+ {
+ var response = LootLockerResponse.Deserialize(serverResponse);
+ onComplete?.Invoke(response);
+ }, true);
+ }
+
+ public static void MergePlayerInventoryItemStacksAdmin(int playerId, string sourceInventoryId, string targetInventoryId, Action onComplete)
+ {
+ if (string.IsNullOrEmpty(LootLockerConfig.current.adminToken))
+ {
+ onComplete?.Invoke(new LootLockerResponse { success = false, errorData = new LootLockerErrorData { message = "Not logged in" } });
+ return;
+ }
+
+ var endpoint = LootLockerTestConfigurationEndpoints.adminMergePlayerInventoryItemStacks;
+ string formattedEndpoint = string.Format(endpoint.endPoint, playerId);
+ string json = LootLockerJson.SerializeObject(new LootLockerTestMergeItemStacksRequest
+ {
+ source_inventory_id = sourceInventoryId,
+ target_inventory_id = targetInventoryId
+ });
+
+ LootLockerAdminRequest.Send(formattedEndpoint, endpoint.httpMethod, json, onComplete, true);
+ }
+ }
+
+ public class LootLockerTestCreateItemTemplateRequest
+ {
+ public string name { get; set; }
+ public string item_type { get; set; }
+ public bool consumable { get; set; }
+ public bool deletable { get; set; }
+ public string[] audiences { get; set; }
+ }
+
+ public class LootLockerTestItemTemplateResponse : LootLockerResponse
+ {
+ public string id { get; set; }
+ }
+
+ public class LootLockerTestGrantItemRequest
+ {
+ public int player_id { get; set; }
+ public string item_template_id { get; set; }
+ public int count { get; set; }
+ }
+
+ public class LootLockerTestGrantItemResponse : LootLockerResponse
+ {
+ public string id { get; set; }
+ }
+
+ public class LootLockerTestSetItemTemplateAudiencesRequest
+ {
+ public string[] audiences { get; set; }
+ }
+
+ public class LootLockerTestAdminConsumeItemRequest
+ {
+ public int player_id { get; set; }
+ public int count { get; set; }
+ }
+
+ public class LootLockerTestAdminConsumeItemResponse : LootLockerResponse
+ {
+ public int consumed { get; set; }
+ }
+
+ public class LootLockerTestSplitItemStackRequest
+ {
+ public int count { get; set; }
+ }
+
+ public class LootLockerTestSplitItemStackResponse : LootLockerResponse
+ {
+ public string id { get; set; }
+ }
+
+ public class LootLockerTestMergeItemStacksRequest
+ {
+ public string source_inventory_id { get; set; }
+ public string target_inventory_id { get; set; }
+ }
+
+ public class LootLockerTestAdminListPlayerInventoryItemsResponse : LootLockerResponse
+ {
+ public LootLockerTestAdminPlayerItem[] items { get; set; }
+ }
+
+ ///
+ /// An item as returned by the admin player inventory listing. Note that this shape differs
+ /// from the game API item: the template name is exposed as template_name and there is
+ /// no deletable field.
+ ///
+ public class LootLockerTestAdminPlayerItem
+ {
+ public string id { get; set; }
+ public int player_id { get; set; }
+ public string item_template_id { get; set; }
+ public string template_name { get; set; }
+ public string item_type { get; set; }
+ public bool consumable { get; set; }
+ public int count { get; set; }
+ public string source { get; set; }
+ public string created_at { get; set; }
+ public string updated_at { get; set; }
+ }
+}
diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs.meta b/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs.meta
new file mode 100644
index 000000000..3bbd24fd3
--- /dev/null
+++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationItem.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 99fdfa0f2eea476a8a97204dcb3005e6
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
\ No newline at end of file
diff --git a/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs b/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs
index 329e69284..2c8742877 100644
--- a/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs
+++ b/Tests/LootLockerTestUtils/LootLockerTestConfigurationMetadata.cs
@@ -48,7 +48,9 @@ public enum LootLockerTestMetadataSources
player = 5,
self = 6,
asset = 7,
- item = 8, // This is the source for asset instances (player inventory items), while the "asset" source is for the asset in general
+ item = 8, // This is the source for legacy items, while the "inventory_item" source is for item instances
+ inventory_item = 9, // This is the source for item instances (player inventory items)
+ item_template = 10, // This is the source for item templates
};
///
diff --git a/Tests/LootLockerTests/PlayMode/ItemTests.cs b/Tests/LootLockerTests/PlayMode/ItemTests.cs
new file mode 100644
index 000000000..46ed96bc0
--- /dev/null
+++ b/Tests/LootLockerTests/PlayMode/ItemTests.cs
@@ -0,0 +1,512 @@
+using System;
+using System.Collections;
+using System.Linq;
+using LootLocker;
+using LootLocker.LootLockerEnums;
+using LootLocker.Requests;
+using LootLockerTestConfigurationUtils;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+namespace LootLockerTests.PlayMode
+{
+ public class ItemTests
+ {
+ private LootLockerTestGame gameUnderTest = null;
+ private LootLockerConfig configCopy = null;
+ private static int TestCounter = 0;
+ private bool SetupFailed = false;
+ private int sessionPlayerId = 0;
+ private string createdTemplateId = string.Empty;
+ private string createdTemplateName = string.Empty;
+ private string grantedInventoryId = string.Empty;
+
+ private const string PublicAudience = "public";
+
+ [UnitySetUp]
+ public IEnumerator Setup()
+ {
+ TestCounter++;
+ configCopy = LootLockerConfig.current;
+ Debug.Log($"##### Start of {this.GetType().Name} test no.{TestCounter} setup #####");
+
+ if (!LootLockerConfig.ClearSettings())
+ {
+ Debug.LogError("Could not clear LootLocker config");
+ }
+
+ bool gameCreationCallCompleted = false;
+ LootLockerTestGame.CreateGame(testName: this.GetType().Name + TestCounter + " ", onComplete: (success, errorMessage, game) =>
+ {
+ if (!success)
+ {
+ Debug.LogError(errorMessage);
+ SetupFailed = true;
+ }
+ gameUnderTest = game;
+ gameCreationCallCompleted = true;
+ });
+ yield return new WaitUntil(() => gameCreationCallCompleted);
+ if (SetupFailed)
+ {
+ yield break;
+ }
+
+ gameUnderTest?.SwitchToStageEnvironment();
+
+ bool enableGuestLoginCallCompleted = false;
+ gameUnderTest?.EnableGuestLogin((success, errorMessage) =>
+ {
+ if (!success)
+ {
+ Debug.LogError(errorMessage);
+ SetupFailed = true;
+ }
+ enableGuestLoginCallCompleted = true;
+ });
+ yield return new WaitUntil(() => enableGuestLoginCallCompleted);
+ if (SetupFailed)
+ {
+ yield break;
+ }
+
+ Assert.IsTrue(gameUnderTest?.InitializeLootLockerSDK(), "Successfully created test game and initialized LootLocker");
+
+ // Create a stackable, consumable, deletable template with a public audience. The
+ // audience is required: the game API only lists templates that have an audience row,
+ // so without it the template would be invisible even to the player it was granted to.
+ createdTemplateName = LootLockerTestItems.GetRandomItemTemplateName();
+ bool createTemplateCallCompleted = false;
+ LootLockerTestItems.CreateItemTemplate(createdTemplateName, "stackable", true, true, new[] { PublicAudience }, (templateResponse) =>
+ {
+ if (templateResponse == null || !templateResponse.success)
+ {
+ Debug.LogError("Failed to create item template: " + templateResponse?.errorData?.message);
+ SetupFailed = true;
+ createTemplateCallCompleted = true;
+ return;
+ }
+
+ createdTemplateId = templateResponse.id;
+ createTemplateCallCompleted = true;
+ });
+ yield return new WaitUntil(() => createTemplateCallCompleted);
+ if (SetupFailed)
+ {
+ yield break;
+ }
+
+ bool guestLoginCompleted = false;
+ LootLockerSDKManager.StartGuestSession(Guid.NewGuid().ToString(), response =>
+ {
+ SetupFailed |= !response.success;
+ sessionPlayerId = response.player_id;
+ guestLoginCompleted = true;
+ });
+ yield return new WaitUntil(() => guestLoginCompleted);
+ if (SetupFailed)
+ {
+ yield break;
+ }
+
+ bool grantCallCompleted = false;
+ LootLockerTestItems.GrantItemToPlayer(sessionPlayerId, createdTemplateId, 5, (grantResponse) =>
+ {
+ if (grantResponse == null || !grantResponse.success)
+ {
+ Debug.LogError("Failed to grant item to player: " + grantResponse?.errorData?.message);
+ SetupFailed = true;
+ grantCallCompleted = true;
+ return;
+ }
+
+ grantedInventoryId = grantResponse.id;
+ grantCallCompleted = true;
+ });
+ yield return new WaitUntil(() => grantCallCompleted);
+
+ Debug.Log($"##### Start of {this.GetType().Name} test no.{TestCounter} test case #####");
+ }
+
+ [UnityTearDown]
+ public IEnumerator TearDown()
+ {
+ Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} test case #####");
+ if (gameUnderTest != null)
+ {
+ bool gameDeletionCallCompleted = false;
+ gameUnderTest.DeleteGame(((success, errorMessage) =>
+ {
+ if (!success)
+ {
+ Debug.LogError(errorMessage);
+ }
+
+ gameUnderTest = null;
+ gameDeletionCallCompleted = true;
+ }));
+ yield return new WaitUntil(() => gameDeletionCallCompleted);
+ }
+
+ LootLockerStateData.ClearAllSavedStates();
+ LootLockerConfig.CreateNewSettings(configCopy);
+ Debug.Log($"##### End of {this.GetType().Name} test no.{TestCounter} tear down #####");
+ }
+
+ ///
+ /// The designated fast test: it covers the whole setup chain (create template with an
+ /// audience, grant to player) plus the central read path, so it gives early signal
+ /// without paying for the full suite.
+ ///
+ [UnityTest, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")]
+ [Timeout(360_000)]
+ public IEnumerator Items_ListItemTemplates_ReturnsCreatedTemplate()
+ {
+ Assert.IsFalse(SetupFailed, "Failed to setup game");
+
+ // When
+ bool listCallCompleted = false;
+ LootLockerListItemTemplatesResponse response = null;
+ LootLockerSDKManager.ListItemTemplates(1, 100, (r) =>
+ {
+ response = r;
+ listCallCompleted = true;
+ });
+ yield return new WaitUntil(() => listCallCompleted);
+
+ // Then
+ Assert.IsTrue(response.success, response.errorData?.ToString() ?? "ListItemTemplates call failed");
+ Assert.IsNotNull(response.items, "Item templates should not be null");
+ Assert.IsTrue(response.items.Any(t => t.id == createdTemplateId), "Expected the created template to be visible to the player");
+
+ var template = response.items.First(t => t.id == createdTemplateId);
+ Assert.AreEqual(createdTemplateName, template.name, "Template name should match the created template");
+ Assert.AreEqual(LootLockerItemType.stackable, template.item_type, "Template should be stackable");
+ Assert.IsTrue(template.consumable, "Template should be consumable");
+ Assert.IsTrue(template.deletable, "Template should be deletable");
+ Assert.AreNotEqual(default(DateTime), template.created_at, "Template created_at should be populated");
+ }
+
+ [UnityTest, Category("LootLocker"), Category("LootLockerCI")]
+ [Timeout(360_000)]
+ public IEnumerator Items_ListPlayerInventoryItems_ReturnsGrantedItem()
+ {
+ Assert.IsFalse(SetupFailed, "Failed to setup game");
+
+ // When
+ bool listCallCompleted = false;
+ LootLockerListPlayerInventoryItemsResponse response = null;
+ LootLockerSDKManager.ListPlayerInventoryItems(1, 100, onComplete: (r) =>
+ {
+ response = r;
+ listCallCompleted = true;
+ });
+ yield return new WaitUntil(() => listCallCompleted);
+
+ // Then
+ Assert.IsTrue(response.success, response.errorData?.ToString() ?? "ListPlayerInventoryItems call failed");
+ Assert.IsNotNull(response.items, "Player items should not be null");
+ Assert.IsTrue(response.items.Any(i => i.id == grantedInventoryId), "Expected the granted item to be listed");
+
+ var item = response.items.First(i => i.id == grantedInventoryId);
+ Assert.AreEqual(5, item.count, "Granted count should be 5");
+ Assert.AreEqual(createdTemplateName, item.name, "Item name should be the template name");
+ Assert.AreEqual(LootLockerItemType.stackable, item.item_type, "Item should be stackable");
+ Assert.IsTrue(item.deletable, "Item should be deletable");
+ Assert.IsNotNull(item.source, "Item source should be populated");
+ Assert.AreNotEqual(default(DateTime), item.created_at, "Item created_at should be populated");
+ }
+
+ [UnityTest, Category("LootLocker"), Category("LootLockerCI")]
+ [Timeout(360_000)]
+ public IEnumerator Items_ListPlayerInventoryItems_WithFilters_ReturnsFilteredItems()
+ {
+ Assert.IsFalse(SetupFailed, "Failed to setup game");
+
+ // When
+ bool listCallCompleted = false;
+ LootLockerListPlayerInventoryItemsResponse response = null;
+ LootLockerSDKManager.ListPlayerInventoryItems(1, 100, name: createdTemplateName, itemType: LootLockerItemType.stackable,
+ consumable: true, sort: LootLockerItemSortField.created_at, order: LootLockerSortOrder.DESC, onComplete: (r) =>
+ {
+ response = r;
+ listCallCompleted = true;
+ });
+ yield return new WaitUntil(() => listCallCompleted);
+
+ // Then
+ Assert.IsTrue(response.success, response.errorData?.ToString() ?? "Filtered ListPlayerInventoryItems call failed");
+ Assert.IsNotNull(response.items, "Player items should not be null");
+ Assert.IsTrue(response.items.Any(i => i.id == grantedInventoryId), "Expected the granted item to match the filters");
+ Assert.IsTrue(response.items.All(i => i.item_type == LootLockerItemType.stackable), "All returned items should be stackable");
+ Assert.IsTrue(response.items.All(i => i.consumable), "All returned items should be consumable");
+ }
+
+ [UnityTest, Category("LootLocker"), Category("LootLockerCI")]
+ [Timeout(360_000)]
+ public IEnumerator Items_GetPlayerInventoryItem_ReturnsItemWithTemplate()
+ {
+ Assert.IsFalse(SetupFailed, "Failed to setup game");
+
+ // When
+ bool getCallCompleted = false;
+ LootLockerGetPlayerInventoryItemResponse response = null;
+ LootLockerSDKManager.GetPlayerInventoryItem(grantedInventoryId, (r) =>
+ {
+ response = r;
+ getCallCompleted = true;
+ });
+ yield return new WaitUntil(() => getCallCompleted);
+
+ // Then
+ Assert.IsTrue(response.success, response.errorData?.ToString() ?? "GetPlayerInventoryItem call failed");
+ Assert.AreEqual(grantedInventoryId, response.id, "Returned item id should match the requested id");
+ Assert.AreEqual(5, response.count, "Item count should be 5");
+ Assert.AreEqual(LootLockerItemType.stackable, response.item_type, "Item should be stackable");
+ Assert.IsTrue(response.deletable, "Item should be deletable");
+ Assert.IsNotNull(response.template, "Item template should be populated on the single item response");
+ Assert.AreEqual(createdTemplateId, response.template.id, "Template id should match the created template");
+ Assert.AreEqual(createdTemplateName, response.template.name, "Template name should match the created template");
+ }
+
+ [UnityTest, Category("LootLocker"), Category("LootLockerCI")]
+ [Timeout(360_000)]
+ public IEnumerator Items_ConsumePlayerInventoryItem_PartialStack_DecrementsCount()
+ {
+ Assert.IsFalse(SetupFailed, "Failed to setup game");
+
+ // When
+ bool consumeCallCompleted = false;
+ LootLockerConsumeInventoryItemResponse consumeResponse = null;
+ LootLockerSDKManager.ConsumePlayerInventoryItem(grantedInventoryId, 2, (r) =>
+ {
+ consumeResponse = r;
+ consumeCallCompleted = true;
+ });
+ yield return new WaitUntil(() => consumeCallCompleted);
+
+ // Then
+ Assert.IsTrue(consumeResponse.success, consumeResponse.errorData?.ToString() ?? "ConsumePlayerInventoryItem call failed");
+ Assert.IsTrue(consumeResponse.consumed, "Item should report as consumed");
+
+ bool getCallCompleted = false;
+ LootLockerGetPlayerInventoryItemResponse getResponse = null;
+ LootLockerSDKManager.GetPlayerInventoryItem(grantedInventoryId, (r) =>
+ {
+ getResponse = r;
+ getCallCompleted = true;
+ });
+ yield return new WaitUntil(() => getCallCompleted);
+
+ Assert.IsTrue(getResponse.success, getResponse.errorData?.ToString() ?? "GetPlayerInventoryItem after consume failed");
+ Assert.AreEqual(3, getResponse.count, "Consuming 2 of 5 should leave 3");
+ }
+
+ [UnityTest, Category("LootLocker"), Category("LootLockerCI")]
+ [Timeout(360_000)]
+ public IEnumerator Items_ConsumePlayerInventoryItem_WholeStack_RemovesItem()
+ {
+ Assert.IsFalse(SetupFailed, "Failed to setup game");
+
+ // When - passing the full count consumes the entire row
+ bool consumeCallCompleted = false;
+ LootLockerConsumeInventoryItemResponse consumeResponse = null;
+ LootLockerSDKManager.ConsumePlayerInventoryItem(grantedInventoryId, 5, (r) =>
+ {
+ consumeResponse = r;
+ consumeCallCompleted = true;
+ });
+ yield return new WaitUntil(() => consumeCallCompleted);
+
+ // Then
+ Assert.IsTrue(consumeResponse.success, consumeResponse.errorData?.ToString() ?? "ConsumePlayerInventoryItem call failed");
+ Assert.IsTrue(consumeResponse.consumed, "Item should report as consumed");
+
+ bool listCallCompleted = false;
+ LootLockerListPlayerInventoryItemsResponse listResponse = null;
+ LootLockerSDKManager.ListPlayerInventoryItems(1, 100, onComplete: (r) =>
+ {
+ listResponse = r;
+ listCallCompleted = true;
+ });
+ yield return new WaitUntil(() => listCallCompleted);
+
+ Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerInventoryItems after consume failed");
+ Assert.IsFalse(listResponse.items.Any(i => i.id == grantedInventoryId), "Fully consumed item should no longer be listed");
+ }
+
+ [UnityTest, Category("LootLocker"), Category("LootLockerCI")]
+ [Timeout(360_000)]
+ public IEnumerator Items_SplitPlayerInventoryItemStack_CreatesSecondStack()
+ {
+ Assert.IsFalse(SetupFailed, "Failed to setup game");
+
+ // When
+ bool splitCallCompleted = false;
+ LootLockerSplitInventoryItemStackResponse splitResponse = null;
+ LootLockerSDKManager.SplitPlayerInventoryItemStack(grantedInventoryId, 2, (r) =>
+ {
+ splitResponse = r;
+ splitCallCompleted = true;
+ });
+ yield return new WaitUntil(() => splitCallCompleted);
+
+ // Then
+ Assert.IsTrue(splitResponse.success, splitResponse.errorData?.ToString() ?? "SplitPlayerInventoryItemStack call failed");
+ Assert.IsNotNull(splitResponse.id, "Split should return the id of the new stack");
+ Assert.AreNotEqual(grantedInventoryId, splitResponse.id, "The new stack should have a different id");
+
+ bool listCallCompleted = false;
+ LootLockerListPlayerInventoryItemsResponse listResponse = null;
+ LootLockerSDKManager.ListPlayerInventoryItems(1, 100, onComplete: (r) =>
+ {
+ listResponse = r;
+ listCallCompleted = true;
+ });
+ yield return new WaitUntil(() => listCallCompleted);
+
+ Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerInventoryItems after split failed");
+ var original = listResponse.items.FirstOrDefault(i => i.id == grantedInventoryId);
+ var split = listResponse.items.FirstOrDefault(i => i.id == splitResponse.id);
+ Assert.IsNotNull(original, "Original stack should still be listed");
+ Assert.IsNotNull(split, "New stack should be listed");
+ Assert.AreEqual(3, original.count, "Original stack should be left with 3");
+ Assert.AreEqual(2, split.count, "New stack should hold 2");
+ }
+
+ [UnityTest, Category("LootLocker"), Category("LootLockerCI")]
+ [Timeout(360_000)]
+ public IEnumerator Items_MergePlayerInventoryItemStacks_CombinesCounts()
+ {
+ Assert.IsFalse(SetupFailed, "Failed to setup game");
+
+ // Given - split off a second stack to merge back
+ bool splitCallCompleted = false;
+ LootLockerSplitInventoryItemStackResponse splitResponse = null;
+ LootLockerSDKManager.SplitPlayerInventoryItemStack(grantedInventoryId, 2, (r) =>
+ {
+ splitResponse = r;
+ splitCallCompleted = true;
+ });
+ yield return new WaitUntil(() => splitCallCompleted);
+ Assert.IsTrue(splitResponse.success, splitResponse.errorData?.ToString() ?? "SplitPlayerInventoryItemStack call failed");
+
+ // When - merge the new stack back into the original
+ bool mergeCallCompleted = false;
+ LootLockerResponse mergeResponse = null;
+ LootLockerSDKManager.MergePlayerInventoryItemStacks(splitResponse.id, grantedInventoryId, (r) =>
+ {
+ mergeResponse = r;
+ mergeCallCompleted = true;
+ });
+ yield return new WaitUntil(() => mergeCallCompleted);
+
+ // Then
+ Assert.IsTrue(mergeResponse.success, mergeResponse.errorData?.ToString() ?? "MergePlayerInventoryItemStacks call failed");
+
+ bool listCallCompleted = false;
+ LootLockerListPlayerInventoryItemsResponse listResponse = null;
+ LootLockerSDKManager.ListPlayerInventoryItems(1, 100, onComplete: (r) =>
+ {
+ listResponse = r;
+ listCallCompleted = true;
+ });
+ yield return new WaitUntil(() => listCallCompleted);
+
+ Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerInventoryItems after merge failed");
+ var target = listResponse.items.FirstOrDefault(i => i.id == grantedInventoryId);
+ Assert.IsNotNull(target, "Target stack should still be listed");
+ Assert.AreEqual(5, target.count, "Merged stack should hold the combined count of 5");
+ Assert.IsFalse(listResponse.items.Any(i => i.id == splitResponse.id), "Source stack should be gone after merging");
+ }
+
+ [UnityTest, Category("LootLocker"), Category("LootLockerCI")]
+ [Timeout(360_000)]
+ public IEnumerator Items_DeletePlayerInventoryItem_RemovesItem()
+ {
+ Assert.IsFalse(SetupFailed, "Failed to setup game");
+
+ // When
+ bool deleteCallCompleted = false;
+ LootLockerResponse deleteResponse = null;
+ LootLockerSDKManager.DeletePlayerInventoryItem(grantedInventoryId, (r) =>
+ {
+ deleteResponse = r;
+ deleteCallCompleted = true;
+ });
+ yield return new WaitUntil(() => deleteCallCompleted);
+
+ // Then
+ Assert.IsTrue(deleteResponse.success, deleteResponse.errorData?.ToString() ?? "DeletePlayerInventoryItem call failed");
+
+ bool listCallCompleted = false;
+ LootLockerListPlayerInventoryItemsResponse listResponse = null;
+ LootLockerSDKManager.ListPlayerInventoryItems(1, 100, onComplete: (r) =>
+ {
+ listResponse = r;
+ listCallCompleted = true;
+ });
+ yield return new WaitUntil(() => listCallCompleted);
+
+ Assert.IsTrue(listResponse.success, listResponse.errorData?.ToString() ?? "ListPlayerInventoryItems after delete failed");
+ Assert.IsFalse(listResponse.items.Any(i => i.id == grantedInventoryId), "Deleted item should no longer be listed");
+ }
+
+ [UnityTest, Category("LootLocker"), Category("LootLockerCI")]
+ [Timeout(360_000)]
+ public IEnumerator Items_DeletePlayerInventoryItem_NotDeletable_ReturnsForbidden()
+ {
+ Assert.IsFalse(SetupFailed, "Failed to setup game");
+
+ // Given - a second template that is explicitly not deletable
+ bool createTemplateCallCompleted = false;
+ string nonDeletableTemplateId = string.Empty;
+ LootLockerTestItems.CreateItemTemplate(LootLockerTestItems.GetRandomItemTemplateName(), "stackable", false, false, new[] { PublicAudience }, (templateResponse) =>
+ {
+ if (templateResponse == null || !templateResponse.success)
+ {
+ Debug.LogError("Failed to create non-deletable item template: " + templateResponse?.errorData?.message);
+ createTemplateCallCompleted = true;
+ return;
+ }
+
+ nonDeletableTemplateId = templateResponse.id;
+ createTemplateCallCompleted = true;
+ });
+ yield return new WaitUntil(() => createTemplateCallCompleted);
+ Assert.IsNotEmpty(nonDeletableTemplateId, "Failed to create the non-deletable template");
+
+ bool grantCallCompleted = false;
+ string nonDeletableInventoryId = string.Empty;
+ LootLockerTestItems.GrantItemToPlayer(sessionPlayerId, nonDeletableTemplateId, 1, (grantResponse) =>
+ {
+ if (grantResponse == null || !grantResponse.success)
+ {
+ Debug.LogError("Failed to grant non-deletable item: " + grantResponse?.errorData?.message);
+ grantCallCompleted = true;
+ return;
+ }
+
+ nonDeletableInventoryId = grantResponse.id;
+ grantCallCompleted = true;
+ });
+ yield return new WaitUntil(() => grantCallCompleted);
+ Assert.IsNotEmpty(nonDeletableInventoryId, "Failed to grant the non-deletable item");
+
+ // When
+ bool deleteCallCompleted = false;
+ LootLockerResponse deleteResponse = null;
+ LootLockerSDKManager.DeletePlayerInventoryItem(nonDeletableInventoryId, (r) =>
+ {
+ deleteResponse = r;
+ deleteCallCompleted = true;
+ });
+ yield return new WaitUntil(() => deleteCallCompleted);
+
+ // Then
+ Assert.IsFalse(deleteResponse.success, "Deleting a non-deletable item should fail");
+ Assert.AreEqual(403, deleteResponse.statusCode, "Deleting a non-deletable item should return 403 Forbidden");
+ }
+ }
+}
diff --git a/Tests/LootLockerTests/PlayMode/ItemTests.cs.meta b/Tests/LootLockerTests/PlayMode/ItemTests.cs.meta
new file mode 100644
index 000000000..354a1e1fb
--- /dev/null
+++ b/Tests/LootLockerTests/PlayMode/ItemTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 7fed9865e8244b08bd29cda5c063248a
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
\ No newline at end of file
diff --git a/Tests/LootLockerTests/PlayMode/JsonTests.cs b/Tests/LootLockerTests/PlayMode/JsonTests.cs
index f78fe1a6d..f9dbc1697 100644
--- a/Tests/LootLockerTests/PlayMode/JsonTests.cs
+++ b/Tests/LootLockerTests/PlayMode/JsonTests.cs
@@ -1,4 +1,5 @@
-using LootLocker;
+using LootLocker;
+using LootLocker.LootLockerEnums;
using LootLocker.Requests;
using System;
using System.Collections;
@@ -315,6 +316,186 @@ public void Json_DeserializationCaseConversion_CaseIsConvertedToSnake()
#endif
}
+ [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")]
+ public void Json_SerializingConsumeItemRequestWithoutCount_OmitsCount()
+ {
+ // Given
+ var request = new LootLockerConsumeInventoryItemRequest();
+
+ // When
+ string serializedJson = LootLockerJson.SerializeObject(request);
+
+ // Then
+ Assert.IsNotNull(serializedJson, "Not serialized, is null");
+ Assert.IsFalse(serializedJson.Contains("count"),
+ "Not serialized correctly, contains count even though it was never set");
+ }
+
+ [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")]
+ public void Json_SerializingConsumeItemRequestWithCount_IncludesCount()
+ {
+ // Given
+ var request = new LootLockerConsumeInventoryItemRequest { count = 3 };
+
+ // When
+ string serializedJson = LootLockerJson.SerializeObject(request);
+
+ // Then
+ Assert.IsNotNull(serializedJson, "Not serialized, is null");
+ Assert.IsTrue(serializedJson.Contains("count"),
+ "Not serialized correctly, does not contain count");
+ Assert.IsTrue(serializedJson.Contains("3"),
+ "Not serialized correctly, does not contain the count value");
+ }
+
+ [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")]
+ public void Json_SerializingSplitItemStackRequest_IncludesCount()
+ {
+ // Given
+ var request = new LootLockerSplitInventoryItemStackRequest { count = 5 };
+
+ // When
+ string serializedJson = LootLockerJson.SerializeObject(request);
+
+ // Then
+ Assert.IsNotNull(serializedJson, "Not serialized, is null");
+ Assert.IsTrue(serializedJson.Contains("count"),
+ "Not serialized correctly, does not contain count");
+ Assert.IsTrue(serializedJson.Contains("5"),
+ "Not serialized correctly, does not contain the count value");
+ }
+
+ [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")]
+ public void Json_SerializingMergeItemStacksRequest_IncludesBothInventoryIds()
+ {
+ // Given
+ var request = new LootLockerMergeInventoryItemStacksRequest
+ {
+ source_inventory_id = "01HZZZZZZZZZZZZZZZZZZZZZZZ",
+ target_inventory_id = "01HYYYYYYYYYYYYYYYYYYYYYYY"
+ };
+
+ // When
+ string serializedJson = LootLockerJson.SerializeObject(request);
+
+ // Then
+ Assert.IsNotNull(serializedJson, "Not serialized, is null");
+ Assert.IsTrue(serializedJson.Contains("source_inventory_id"),
+ "Not serialized correctly, does not contain source_inventory_id");
+ Assert.IsTrue(serializedJson.Contains("target_inventory_id"),
+ "Not serialized correctly, does not contain target_inventory_id");
+ Assert.IsTrue(serializedJson.Contains("01HZZZZZZZZZZZZZZZZZZZZZZZ"),
+ "Not serialized correctly, does not contain the source inventory id value");
+ Assert.IsTrue(serializedJson.Contains("01HYYYYYYYYYYYYYYYYYYYYYYY"),
+ "Not serialized correctly, does not contain the target inventory id value");
+ }
+
+ [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")]
+ public void Json_DeserializingListPlayerInventoryItemsResponse_Succeeds()
+ {
+ // Given
+ const string listPlayerItemsResponse =
+ "{\"success\":true,\"items\":[{\"id\":\"01HZZZZZZZZZZZZZZZZZZZZZZZ\",\"player_id\":3,\"item_template_id\":\"01HAAAAAAAAAAAAAAAAAAAAAAA\",\"item_type\":\"stackable\",\"consumable\":true,\"count\":7,\"source\":\"grant\",\"name\":\"Health Potion\",\"deletable\":true,\"created_at\":\"2024-01-01T00:00:00Z\"}],\"pagination\":{\"total\":1,\"offset\":0,\"per_page\":25,\"last_page\":1,\"current_page\":1,\"next_page\":null,\"prev_page\":null}}";
+
+ // When
+ var deserialized = LootLockerJson.DeserializeObject(listPlayerItemsResponse);
+
+ // Then
+ Assert.IsNotNull(deserialized, "Not deserialized, is null");
+ Assert.IsNotNull(deserialized.items, "Not deserialized, items is null");
+ Assert.AreEqual(1, deserialized.items.Length, "Not deserialized, wrong number of items");
+ Assert.AreEqual("01HZZZZZZZZZZZZZZZZZZZZZZZ", deserialized.items[0].id, "Wrong item id");
+ Assert.AreEqual(3, deserialized.items[0].player_id, "Wrong player id");
+ Assert.AreEqual("01HAAAAAAAAAAAAAAAAAAAAAAA", deserialized.items[0].item_template_id, "Wrong item template id");
+ Assert.AreEqual(LootLockerItemType.stackable, deserialized.items[0].item_type, "Wrong item type");
+ Assert.IsTrue(deserialized.items[0].consumable, "Wrong consumable value");
+ Assert.AreEqual(7, deserialized.items[0].count, "Wrong count");
+ Assert.AreEqual("Health Potion", deserialized.items[0].name, "Wrong name");
+ Assert.IsTrue(deserialized.items[0].deletable, "Wrong deletable value");
+ Assert.AreNotEqual(default(DateTime), deserialized.items[0].created_at, "Wrong created_at value");
+ Assert.IsNull(deserialized.items[0].updated_at, "Wrong updated_at value");
+ Assert.IsNotNull(deserialized.pagination, "Not deserialized, pagination is null");
+ Assert.AreEqual(1, deserialized.pagination.total, "Wrong pagination total");
+ Assert.AreEqual(25, deserialized.pagination.per_page, "Wrong pagination per_page");
+ Assert.IsNull(deserialized.pagination.next_page, "Wrong pagination next_page");
+ }
+
+ [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")]
+ public void Json_DeserializingGetPlayerInventoryItemResponseWithTemplateAndMetadata_Succeeds()
+ {
+ // Given
+ const string getPlayerItemResponse =
+ "{\"success\":true,\"id\":\"01HZZZZZZZZZZZZZZZZZZZZZZZ\",\"player_id\":3,\"item_template_id\":\"01HAAAAAAAAAAAAAAAAAAAAAAA\",\"item_type\":\"instanced\",\"consumable\":false,\"count\":1,\"source\":\"grant\",\"deletable\":true,\"created_at\":\"2024-01-01T00:00:00Z\",\"template\":{\"id\":\"01HAAAAAAAAAAAAAAAAAAAAAAA\",\"name\":\"Sword\",\"game_id\":42,\"limited\":0,\"item_type\":\"instanced\",\"consumable\":false,\"deletable\":true},\"metadata\":[{\"key\":\"damage\",\"value\":12,\"type\":\"number\",\"access\":[\"game_api.read\"],\"tags\":[\"combat\"]}]}";
+
+ // When
+ var deserialized = LootLockerJson.DeserializeObject(getPlayerItemResponse);
+
+ // Then
+ Assert.IsNotNull(deserialized, "Not deserialized, is null");
+ Assert.AreEqual("01HZZZZZZZZZZZZZZZZZZZZZZZ", deserialized.id, "Wrong item id");
+ Assert.AreEqual(LootLockerItemType.instanced, deserialized.item_type, "Wrong item type");
+ Assert.IsTrue(deserialized.deletable, "Wrong deletable value");
+ Assert.AreNotEqual(default(DateTime), deserialized.created_at, "Wrong created_at value");
+ Assert.IsNotNull(deserialized.template, "Not deserialized, template is null");
+ Assert.AreEqual("Sword", deserialized.template.name, "Wrong template name");
+ Assert.AreEqual(LootLockerItemType.instanced, deserialized.template.item_type, "Wrong template item type");
+ Assert.IsNotNull(deserialized.metadata, "Not deserialized, metadata is null");
+ Assert.AreEqual(1, deserialized.metadata.Length, "Not deserialized, wrong number of metadata entries");
+ Assert.AreEqual("damage", deserialized.metadata[0].key, "Wrong metadata key");
+ Assert.AreEqual(LootLockerMetadataTypes.Number, deserialized.metadata[0].type, "Wrong metadata type");
+ Assert.IsNotNull(deserialized.metadata[0].access, "Not deserialized, metadata access is null");
+ Assert.AreEqual("game_api.read", deserialized.metadata[0].access[0], "Wrong metadata access");
+ Assert.IsNotNull(deserialized.metadata[0].tags, "Not deserialized, metadata tags is null");
+ Assert.AreEqual("combat", deserialized.metadata[0].tags[0], "Wrong metadata tag");
+ }
+
+ [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")]
+ public void Json_DeserializingConsumeItemResponseWithGrantedItems_Succeeds()
+ {
+ // Given
+ const string consumeItemResponse =
+ "{\"success\":true,\"consumed\":true,\"granted\":[{\"source_id\":\"01HBBBBBBBBBBBBBBBBBBBBBBB\",\"count\":2,\"type\":\"currency\",\"name\":\"Gold\",\"code\":\"gold\"}]}";
+
+ // When
+ var deserialized = LootLockerJson.DeserializeObject(consumeItemResponse);
+
+ // Then
+ Assert.IsNotNull(deserialized, "Not deserialized, is null");
+ Assert.IsTrue(deserialized.consumed, "Wrong consumed value");
+ Assert.IsNotNull(deserialized.granted, "Not deserialized, granted is null");
+ Assert.AreEqual(1, deserialized.granted.Length, "Not deserialized, wrong number of granted items");
+ Assert.AreEqual("01HBBBBBBBBBBBBBBBBBBBBBBB", deserialized.granted[0].source_id, "Wrong granted source id");
+ Assert.AreEqual(2, deserialized.granted[0].count, "Wrong granted count");
+ Assert.AreEqual(LootLockerRewardKind.currency, deserialized.granted[0].type, "Wrong granted type");
+ Assert.AreEqual("Gold", deserialized.granted[0].name, "Wrong granted name");
+ Assert.AreEqual("gold", deserialized.granted[0].code, "Wrong granted code");
+ }
+
+ [Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")]
+ public void Json_DeserializingListItemTemplatesResponse_Succeeds()
+ {
+ // Given
+ const string listItemTemplatesResponse =
+ "{\"success\":true,\"items\":[{\"id\":\"01HAAAAAAAAAAAAAAAAAAAAAAA\",\"name\":\"Sword\",\"game_id\":42,\"limited\":10,\"item_type\":\"instanced\",\"consumable\":false,\"deletable\":true,\"created_at\":\"2024-01-01T00:00:00Z\",\"updated_at\":\"2024-01-02T00:00:00Z\"}],\"pagination\":{\"total\":1,\"offset\":0,\"per_page\":25,\"last_page\":1,\"current_page\":1,\"next_page\":null,\"prev_page\":null}}";
+
+ // When
+ var deserialized = LootLockerJson.DeserializeObject(listItemTemplatesResponse);
+
+ // Then
+ Assert.IsNotNull(deserialized, "Not deserialized, is null");
+ Assert.IsNotNull(deserialized.items, "Not deserialized, items is null");
+ Assert.AreEqual(1, deserialized.items.Length, "Not deserialized, wrong number of item templates");
+ Assert.AreEqual("01HAAAAAAAAAAAAAAAAAAAAAAA", deserialized.items[0].id, "Wrong item template id");
+ Assert.AreEqual("Sword", deserialized.items[0].name, "Wrong item template name");
+ Assert.AreEqual(LootLockerItemType.instanced, deserialized.items[0].item_type, "Wrong item template item type");
+ Assert.AreEqual(10, deserialized.items[0].limited, "Wrong item template limited value");
+ Assert.IsTrue(deserialized.items[0].deletable, "Wrong item template deletable value");
+ Assert.AreNotEqual(default(DateTime), deserialized.items[0].created_at, "Wrong item template created_at value");
+ Assert.IsNotNull(deserialized.items[0].updated_at, "Wrong item template updated_at value");
+ Assert.IsNotNull(deserialized.pagination, "Not deserialized, pagination is null");
+ Assert.AreEqual(1, deserialized.pagination.total, "Wrong pagination total");
+ }
+
#if !LOOTLOCKER_USE_NEWTONSOFTJSON
[Test, Category("LootLocker"), Category("LootLockerCI"), Category("LootLockerCIFast")]
public void Json_SimpleTypeSerialization_Succeeds()
@@ -450,6 +631,7 @@ public void Json_SerializingAndDesierializingEnumArrays_Works()
Assert.IsNotEmpty(deserialized.enumArray);
Assert.AreNotEqual(defaultEnumValue, deserialized.enumArray[0]);
}
+
}
class CustomOptions : JsonOptions