using System.Collections.Concurrent; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Extensions; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Eft.Common.Tables; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Utils; namespace SPTarkov.Server.Core.Services; /// /// Service for keeping track of items and their exact position inside a bots container /// [Injectable] public class BotInventoryContainerService(ISptLogger logger, ItemHelper itemHelper) { // botId/containerName private readonly ConcurrentDictionary> _botContainers = new(); /// /// Add a container + details to a bots cache ready to accept loot /// /// Unique identifier of bot /// name of container e.g. "Backpack" /// Inventory item loot will be linked to in bots inventory public void AddEmptyContainerToBot(MongoId botId, EquipmentSlots containerName, Item containerInventoryItem) { // Add bot to dict if it doesn't exist _botContainers.TryAdd(botId, new()); // Get the bots' currently cached containers var containers = GetOrCreateBotContainerDictionary(botId); // Add container to bot if (!containers.ContainsKey(containerName)) { var containerDbItem = itemHelper.GetItem(containerInventoryItem.Template); containers.Add(containerName, new ContainerDetails(containerDbItem.Value, containerInventoryItem)); } } /// /// Attempt to add an item + children to a container /// /// Bots unique id /// Name of container to add to e.g. "Backpack" /// Item and its children to add to container /// Inventory to add Item+children to /// Width of item with its children /// Height of item with its children /// ItemAddedResult public ItemAddedResult TryAddItemToBotContainer( MongoId botId, EquipmentSlots containerName, List itemAndChildren, BotBaseInventory botInventory, int itemWidth, int itemHeight ) { if (itemAndChildren.Count == 0) { return ItemAddedResult.INCOMPATIBLE_ITEM; } var addResult = ItemAddedResult.UNKNOWN; // Find bot and the container we will attempt to add into if ( !GetOrCreateBotContainerDictionary(botId).TryGetValue(containerName, out var containerDetails) || containerDetails.ContainerGridDetails.Count == 0 ) { // No grids, cannot add item return ItemAddedResult.NO_CONTAINERS; } if (!ItemAllowedInContainer(containerDetails, itemAndChildren)) // Multiple containers, maybe next one allows item, only break out of loop for the containers grids { return ItemAddedResult.INCOMPATIBLE_ITEM; } // Try to fit item into one of the containers' grids var rootItem = itemAndChildren.First(); var gridIndex = -1; // start at -1 as we increment index first thing each grid we iterate over foreach (var gridDb in containerDetails.ContainerDbItem.Properties.Grids) { gridIndex++; var gridDetails = containerDetails.ContainerGridDetails[gridIndex]; if (gridDetails.GridFull) { // Skip to next grid continue; } if (IsItemBiggerThanGrid(gridDetails.GridMap, itemWidth, itemHeight)) { // Skip to next grid continue; } // Look for a slot in the grid to place item var findSlotResult = gridDetails.GridMap.FindSlotForItem(itemWidth, itemHeight); if (findSlotResult.Success.GetValueOrDefault(false)) { // It Fits! // Set items parent to Id of container rootItem.ParentId = containerDetails.ContainerInventoryItem.Id; rootItem.SlotId = gridDb.Name; // Can be name of container e.g. "Backpack" OR "2/3/4/5" depending on which grid of a container item is added to rootItem.Location = new ItemLocation { X = findSlotResult.X, Y = findSlotResult.Y, R = findSlotResult.Rotation ?? false ? ItemRotation.Vertical : ItemRotation.Horizontal, }; // Flag result as success to report to caller addResult = ItemAddedResult.SUCCESS; // Update grid with slots taken up by above item FillGridRegion( gridDetails.GridMap, findSlotResult.X.Value, findSlotResult.Y.Value, findSlotResult.Rotation.GetValueOrDefault() ? itemHeight : itemWidth, findSlotResult.Rotation.GetValueOrDefault() ? itemWidth : itemHeight ); // Add item into bots inventory botInventory.Items.AddRange(itemAndChildren); // Exit loop, we've found a slot for item break; } // Didn't fit, flag as no space, hopefully next grid has space addResult = ItemAddedResult.NO_SPACE; FlagGridIfFull(gridDetails, itemWidth, itemHeight); } return addResult; } /// /// Attempt to add an item + children to a container at a specific x/y grid position /// /// Bots unique id /// Name of container to add to e.g. "Backpack" /// Item and its children to add to container /// Inventory to add Item+children to /// Width of item with its children /// Height of item with its children /// Details for where to place item in container grid /// ItemAddedResult public ItemAddedResult AddItemToBotContainerFixedPosition( MongoId botId, EquipmentSlots containerName, List itemAndChildren, BotBaseInventory botInventory, int itemWidth, int itemHeight, ItemLocation fixedLocation ) { if (itemAndChildren.Count == 0) { return ItemAddedResult.INCOMPATIBLE_ITEM; } // Default result var addResult = ItemAddedResult.UNKNOWN; // Find bot and the container we are attempting to store item in var botContainers = GetOrCreateBotContainerDictionary(botId); if (!botContainers.TryGetValue(containerName, out var containerDetails) || containerDetails.ContainerGridDetails.Count == 0) { // No grids, cannot add item return ItemAddedResult.NO_CONTAINERS; } if (!ItemAllowedInContainer(containerDetails, itemAndChildren)) // Multiple containers, maybe next one allows item, only break out of loop for the containers grids { return ItemAddedResult.INCOMPATIBLE_ITEM; } // Try to fit item into one of the containers' grids var rootItem = itemAndChildren.FirstOrDefault(); if (rootItem is null) { return ItemAddedResult.UNKNOWN; } foreach (var gridDetails in containerDetails.ContainerGridDetails) { if (gridDetails.GridFull) { // No space, skip early continue; } if (IsItemBiggerThanGrid(gridDetails.GridMap, itemWidth, itemHeight)) { // Skip early continue; } // Look for a slot in the grid to place item var result = gridDetails.GridMap.TryFillContainerMapWithItem( fixedLocation.X.Value, fixedLocation.Y.Value, itemWidth, itemHeight, fixedLocation.R == ItemRotation.Vertical, out _ ); if (result) { // It Fits! // Parent root item to container rootItem.ParentId = containerDetails.ContainerInventoryItem.Id; rootItem.SlotId = containerName.ToString(); rootItem.Location = new ItemLocation { X = fixedLocation.X.Value, Y = fixedLocation.Y.Value, R = fixedLocation.R, }; // Flag result as success to report to caller addResult = ItemAddedResult.SUCCESS; // Update internal grid with slots taken up by above item FillGridRegion( gridDetails.GridMap, fixedLocation.X.Value, fixedLocation.Y.Value, fixedLocation.R == ItemRotation.Vertical ? itemHeight : itemWidth, fixedLocation.R == ItemRotation.Vertical ? itemWidth : itemHeight ); // Item fits + Added to layout grid, add item and children //containerDetails.ItemsAndChildrenInContainer.AddRange(itemAndChildren); // Add item into bots inventory botInventory.Items.AddRange(itemAndChildren); // Exit loop, we've found a position for item and can stop break; } // Didn't fit, flag as no space, hopefully next grid has space addResult = ItemAddedResult.NO_SPACE; FlagGridIfFull(gridDetails, itemWidth, itemHeight); } return addResult; } /// /// Helper - Get the bot-specific container details, create if data doesn't exist /// /// Bot unique identifier /// Dictionary protected Dictionary GetOrCreateBotContainerDictionary(MongoId botId) { if (!_botContainers.TryGetValue(botId, out var botContainers)) { // Create blank dict ready for containers to be added botContainers = new(); } return botContainers; } /// /// Fill region of a 2D array /// /// The 2D grid array to modify /// The starting column index (left) /// The starting row index (top) /// The number of cells to update horizontally /// The number of cells to update vertically protected void FillGridRegion(int[,] grid, int x, int y, int itemWidth, int itemHeight) { // Outer loop iterates through rows (from starting y position) for (var row = y; row < y + itemHeight; row++) { // Inner loop iterates through columns (from starting x position) for (var col = x; col < x + itemWidth; col++) { grid[row, col] = 1; } } } /// /// Flag a container grid as full if a 1x1 item cannot fit or there are no spaces left in the 2d array /// /// /// /// protected static void FlagGridIfFull(ContainerMapDetails gridDetails, int itemWidth, int itemHeight) { // If item is 1x1 and it failed to fit, grid must be full if (itemHeight == 1 && itemWidth == 1) { gridDetails.GridFull = true; // Flag now so later items can skip grid return; } // Check if grid is full and flag if (gridDetails.GridMap.ContainerIsFull()) { gridDetails.GridFull = true; } } /// /// Is the items subtype allowed inside this container / is it excluded from this container /// /// Details on the container we want to add item into /// Item+children we want to add into container /// true = item is allowed private bool ItemAllowedInContainer(ContainerDetails containerDetails, List? itemAndChildren) { // Assume all grids have same limitations var firstSlotGrid = containerDetails.ContainerDbItem.Properties.Grids.FirstOrDefault(); var propFilters = firstSlotGrid?.Properties?.Filters; if (propFilters is null || !propFilters.Any()) // No filters, item is fine to add { return true; } // Check if item base type is excluded var itemDetails = itemHelper.GetItem(itemAndChildren.FirstOrDefault().Template).Value; // if item to add is found in exclude filter, not allowed var excludedFilter = propFilters.FirstOrDefault()?.ExcludedFilter ?? []; if (excludedFilter.Contains(itemDetails?.Parent ?? string.Empty)) { return false; } // If Filter array only contains 1 filter and it is for basetype 'item', allow it var filter = propFilters.FirstOrDefault()?.Filter ?? []; if (filter.Count == 1 && filter.Contains(BaseClasses.ITEM)) { return true; } // If allowed filter has something in it + filter doesn't have basetype 'item', not allowed if (filter.Count > 0 && !filter.Contains(itemDetails?.Parent ?? string.Empty)) { return false; } return true; } /// /// Is the items edge length bigger than the grid trying to hold it /// /// Container grid /// Width of item /// Height of item /// true = item bigger than grid private bool IsItemBiggerThanGrid(int[,] grid, int itemWidth, int itemHeight) { var gridHeight = grid.GetLength(0); var gridWidth = grid.GetLength(1); // Check if it can fit in either orientation var fitsNormally = itemWidth <= gridWidth && itemHeight <= gridHeight; var fitsRotated = itemHeight <= gridWidth && itemWidth <= gridHeight; // Fails both checks return !fitsNormally && !fitsRotated; } /// /// Get a bots container details from cache by its id /// /// Identifier of bot to get details of /// Dictionary of containers and their details public Dictionary? GetBotContainer(MongoId botId) { return GetOrCreateBotContainerDictionary(botId); } /// /// Clear the cache of all bot containers /// public void ClearCache() { _botContainers.Clear(); } /// /// Clear specific bot container details from cache /// /// Bot identifier public void ClearCache(MongoId botId) { _botContainers.Remove(botId, out _); } public record ContainerDetails { public ContainerDetails(TemplateItem containerDbItem, Item containerInventoryItem) { ContainerDbItem = containerDbItem; ContainerInventoryItem = containerInventoryItem; // Add all grids for this container foreach (var grid in containerDbItem.Properties.Grids) { ContainerGridDetails.Add( new ContainerMapDetails { GridMap = new int[grid.Properties.CellsV.GetValueOrDefault(), grid.Properties.CellsH.GetValueOrDefault()], GridFull = false, } ); } } /// /// Grid layout and flag if grid is full /// public List ContainerGridDetails { get; } = []; /// /// Db record for the container holding items /// public TemplateItem ContainerDbItem { get; set; } /// /// Inventory item representing the container /// public Item ContainerInventoryItem { get; set; } // TODO: implement this + add checks inside AddItemToBotContainer for perf improvement public bool ContainerFull { get; set; } = false; } public record ContainerMapDetails { public int[,] GridMap { get; init; } public bool GridFull { get; set; } } }