using System.Text.Json.Serialization; 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; using SPTarkov.Server.Core.Models.Eft.Common.Tables; using SPTarkov.Server.Core.Models.Spt.Config; using SPTarkov.Server.Core.Models.Utils; using SPTarkov.Server.Core.Servers; using SPTarkov.Server.Core.Services; using SPTarkov.Server.Core.Utils; using SPTarkov.Server.Core.Utils.Cloners; using SPTarkov.Server.Core.Utils.Collections; using LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Generators; [Injectable] public class LocationLootGenerator( ISptLogger logger, RandomUtil randomUtil, ItemHelper itemHelper, DatabaseService databaseService, PresetHelper presetHelper, ServerLocalisationService serverLocalisationService, SeasonalEventService seasonalEventService, ItemFilterService itemFilterService, ConfigServer configServer, CounterTrackerHelper counterTrackerHelper, ICloner cloner ) { protected readonly LocationConfig LocationConfig = configServer.GetConfig(); protected readonly SeasonalEventConfig SeasonalEventConfig = configServer.GetConfig(); /// /// Generate Loot for provided location () /// /// Id of location (e.g. bigmap/factory4_day) /// Collection of spawn points with loot public List GenerateLocationLoot(string locationId) { var result = new List(); // Get generation details for location from db var locationDetails = databaseService.GetLocation(locationId); if (locationDetails is null) { logger.Error($"Location: {locationId} not found in database, generated 0 loot items"); return result; } // Clone ammo data to ensure any changes don't affect the db values var staticAmmoDistClone = cloner.Clone(locationDetails.StaticAmmo); // Pull location-specific spawn limits from db var itemsWithSpawnCountLimitsClone = cloner.Clone( LocationConfig.LootMaxSpawnLimits.GetValueOrDefault(locationId.ToLowerInvariant()) ); // Store items with spawn count limits inside so they can be accessed later inside static/dynamic loot spawn methods if (itemsWithSpawnCountLimitsClone is not null) { counterTrackerHelper.AddDataToTrack(itemsWithSpawnCountLimitsClone); } // Create containers with loot result.AddRange(GenerateStaticContainers(locationId.ToLowerInvariant(), staticAmmoDistClone)); // Add dynamic loot to output loot var dynamicSpawnPoints = GenerateDynamicLoot(locationDetails.LooseLoot.Value, staticAmmoDistClone, locationId.ToLowerInvariant()); // Merge dynamic spawns into result result.AddRange(dynamicSpawnPoints); logger.Success(serverLocalisationService.GetText("location-dynamic_items_spawned_success", dynamicSpawnPoints.Count)); logger.Success(serverLocalisationService.GetText("location-generated_success", locationId)); // Clean up tracker counterTrackerHelper.Clear(); return result; } /// Create a list of container objects with randomised loot /// Location to generate for /// Static ammo distribution /// List of container objects public List GenerateStaticContainers( string locationId, Dictionary> staticAmmoDist ) { var staticLootItemCount = 0; var result = new List(); var mapData = databaseService.GetLocation(locationId); var staticWeaponsOnMap = mapData.StaticContainers.Value.StaticWeapons; if (staticWeaponsOnMap is null) { logger.Error(serverLocalisationService.GetText("location-unable_to_find_static_weapon_for_map", locationId)); } // Add mounted weapons to output loot result.AddRange(staticWeaponsOnMap); var allStaticContainersOnMap = mapData.StaticContainers.Value.StaticContainers; if (allStaticContainersOnMap is null) { logger.Error(serverLocalisationService.GetText("location-unable_to_find_static_container_for_map", locationId)); } // Containers that MUST be added to map (e.g. quest containers) var staticForcedOnMap = mapData.StaticContainers.Value.StaticForced; if (staticForcedOnMap is null) { logger.Error(serverLocalisationService.GetText("location-unable_to_find_forced_static_data_for_map", locationId)); } // Remove christmas items from loot data if (!seasonalEventService.ChristmasEventEnabled()) { allStaticContainersOnMap = allStaticContainersOnMap.Where(item => !SeasonalEventConfig.ChristmasContainerIds.Contains(item.Template.Id) ); } var staticRandomisableContainersOnMap = GetRandomisableContainersOnMap(allStaticContainersOnMap); // Keep track of static loot count var staticContainerCount = 0; // Find all 100% spawn containers var staticLootDist = mapData.StaticLoot.Value; var guaranteedContainers = GetGuaranteedContainers(allStaticContainersOnMap); staticContainerCount += guaranteedContainers.Count(); // Add loot to guaranteed containers and add to result foreach ( var containerWithLoot in guaranteedContainers.Select(container => AddLootToContainer(container, staticForcedOnMap, staticLootDist, staticAmmoDist, locationId) ) ) { result.Add(containerWithLoot.Template); staticLootItemCount += containerWithLoot.Template.Items.Count(); } if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Added {guaranteedContainers.Count()} guaranteed containers"); } // Randomisation is turned off for location / globally if (!LocationRandomisationEnabled(locationId)) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( $"Container randomisation disabled, Adding: {staticRandomisableContainersOnMap.Count()} containers to: {locationId}" ); } foreach (var container in staticRandomisableContainersOnMap) { var containerWithLoot = AddLootToContainer(container, staticForcedOnMap, staticLootDist, staticAmmoDist, locationId); result.Add(containerWithLoot.Template); staticLootItemCount += containerWithLoot.Template.Items.Count(); } logger.Success($"A total of {staticLootItemCount} static items spawned"); return result; } // Group containers by their groupId if (mapData.Statics is null) { logger.Warning(serverLocalisationService.GetText("location-unable_to_generate_static_loot", locationId)); return result; } // For each of the container groups, choose from the pool of containers, hydrate container with loot and add to result array foreach (var (groupId, containerGroupCount) in GetGroupIdToContainerMappings(mapData.Statics, staticRandomisableContainersOnMap)) { // Count chosen was 0, skip if (containerGroupCount.ChosenCount == 0) { continue; } if (containerGroupCount.ContainerIdsWithProbability.Count == 0) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Group: {groupId} has no containers with < 100 % spawn chance to choose from, skipping"); } continue; } // EDGE CASE: These are containers without a group and have a probability < 100% if (groupId == string.Empty) { var containerIdsCopy = cloner.Clone(containerGroupCount.ContainerIdsWithProbability); // Roll each containers probability, if it passes, it gets added containerGroupCount.ContainerIdsWithProbability = new Dictionary(); foreach (var (containerId, probability) in containerIdsCopy) { if (randomUtil.GetChance100(probability * 100)) { containerGroupCount.ContainerIdsWithProbability[containerId] = probability; } } // Set desired count to size of array (we want all containers chosen) containerGroupCount.ChosenCount = containerGroupCount.ContainerIdsWithProbability.Count; // EDGE CASE: chosen container count could be 0 if (containerGroupCount.ChosenCount == 0) { continue; } } // Pass possible containers into function to choose some var chosenContainerIds = GetContainersByProbability(groupId, containerGroupCount); foreach (var chosenContainerId in chosenContainerIds) { // Look up container object from full list of containers on map var containerObject = staticRandomisableContainersOnMap.FirstOrDefault(staticContainer => staticContainer.Template.Id == chosenContainerId ); if (containerObject is null) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Container: {chosenContainerId} not found in staticRandomisableContainersOnMap, this is bad"); } continue; } // Add loot to container and push into result object var containerWithLoot = AddLootToContainer(containerObject, staticForcedOnMap, staticLootDist, staticAmmoDist, locationId); result.Add(containerWithLoot.Template); staticContainerCount++; staticLootItemCount += containerWithLoot.Template.Items.Count(); } } logger.Success($"A total of: {staticLootItemCount} static items spawned"); logger.Success(serverLocalisationService.GetText("location-containers_generated_success", staticContainerCount)); return result; } /// /// Is loot container randomisation enabled for this location + globally /// /// Location to check /// true = enabled protected bool LocationRandomisationEnabled(string locationId) { return LocationConfig.ContainerRandomisationSettings.Enabled && LocationConfig.ContainerRandomisationSettings.Maps.ContainsKey(locationId); } /// /// Get containers with a non-100% chance to spawn OR are NOT on the container type randomistion blacklist /// /// All static containers to pick from /// StaticContainerData collection protected IEnumerable GetRandomisableContainersOnMap(IEnumerable staticContainers) { return staticContainers.Where(staticContainer => staticContainer.Probability != 1 && !staticContainer.Template.IsAlwaysSpawn.GetValueOrDefault(false) && !LocationConfig.ContainerRandomisationSettings.ContainerTypesToNotRandomise.Contains( staticContainer.Template.Items.FirstOrDefault().Template ) ); } /// /// Get containers with 100% spawn rate or have a type on the randomistion ignore list /// /// /// IStaticContainerData collection protected IEnumerable GetGuaranteedContainers(IEnumerable staticContainersOnMap) { return staticContainersOnMap.Where(staticContainer => staticContainer.Probability == 1 || staticContainer.Template.IsAlwaysSpawn.GetValueOrDefault(false) || LocationConfig.ContainerRandomisationSettings.ContainerTypesToNotRandomise.Contains( staticContainer.Template.Items.FirstOrDefault().Template ) ); } /// /// Choose a number of containers based on their probability value to fulfil the desired count in /// containerData.chosenCount /// /// Name of the group the containers are being collected for /// Containers and probability values for a groupId /// List of chosen container Ids protected List GetContainersByProbability(string groupId, ContainerGroupCount containerData) { var chosenContainerIds = new List(); var containerIds = containerData.ContainerIdsWithProbability.Keys; if (containerData.ChosenCount > containerIds.Count) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( $"Group: {groupId} wants: {containerData.ChosenCount} containers but pool only has: {containerIds.Count}, adding what's available" ); } return containerIds.ToList(); } // Create probability array with all possible container ids in this group and their relative probability of spawning var containerDistribution = new ProbabilityObjectArray(cloner); foreach (var containerId in containerIds) { var value = containerData.ContainerIdsWithProbability[containerId]; containerDistribution.Add(new ProbabilityObject(containerId, value, value)); } chosenContainerIds.AddRange(containerDistribution.Draw((int)containerData.ChosenCount)); return chosenContainerIds; } /// /// Get a mapping of each groupId and the containers in that group + count of containers to spawn on map /// /// Container group values /// /// dictionary keyed by groupId protected Dictionary GetGroupIdToContainerMappings( StaticContainer staticContainerGroupData, IEnumerable staticContainersOnMap ) { // Create dictionary of all group ids and choose a count of containers the map will spawn of that group var mapping = new Dictionary(); foreach (var (containerGroupId, containerMinMax) in staticContainerGroupData.ContainersGroups) { mapping[containerGroupId] = new ContainerGroupCount { ContainerIdsWithProbability = new Dictionary(), ChosenCount = randomUtil.GetInt( (int) Math.Round( containerMinMax.MinContainers.Value * LocationConfig.ContainerRandomisationSettings.ContainerGroupMinSizeMultiplier ), (int) Math.Round( containerMinMax.MaxContainers.Value * LocationConfig.ContainerRandomisationSettings.ContainerGroupMaxSizeMultiplier ) ), }; } // Add an empty group for containers without a group id but still have a < 100% chance to spawn // Likely bad BSG data, will be fixed...eventually, example of the groupIds: `NEED_TO_BE_FIXED1`,`NEED_TO_BE_FIXED_SE02`, `NEED_TO_BE_FIXED_NW_01` mapping.Add( string.Empty, new ContainerGroupCount { ContainerIdsWithProbability = new Dictionary(), ChosenCount = -1 } ); // Iterate over all containers and add to group keyed by groupId // Containers without a group go into a group with the empty key: "" foreach (var container in staticContainersOnMap) { if (!staticContainerGroupData.Containers.TryGetValue(container.Template.Id, out var groupData)) { logger.Error(serverLocalisationService.GetText("location-unable_to_find_container_in_statics_json", container.Template.Id)); continue; } if (container.Probability >= 1) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( $"Container {container.Template.Id} with group: {groupData.GroupId} had 100 % chance to spawn was picked as random container, skipping" ); } continue; } mapping.TryAdd( groupData.GroupId, new ContainerGroupCount { ChosenCount = 0d, ContainerIdsWithProbability = new Dictionary() } ); mapping[groupData.GroupId].ContainerIdsWithProbability.TryAdd(container.Template.Id, container.Probability.Value); } return mapping; } /// /// Choose loot to put into a static container based on weighting /// Handle forced items + seasonal item removal when not in season /// /// The container itself we will add loot to /// Loot we need to force into the container /// staticLoot.json /// staticAmmo.json /// Name of the map to generate static loot for /// StaticContainerData protected StaticContainerData AddLootToContainer( StaticContainerData staticContainer, IEnumerable? staticForced, Dictionary staticLootDist, Dictionary> staticAmmoDist, string locationName ) { var containerClone = cloner.Clone(staticContainer); var containerTpl = containerClone.Template.Items.FirstOrDefault().Template; // Create new unique parent id to prevent any collisions var parentId = new MongoId(); containerClone.Template.Root = parentId; containerClone.Template.Items.FirstOrDefault().Id = parentId; var containerMap = itemHelper.GetContainerMapping(containerTpl); // Choose count of items to add to container var itemCountToAdd = GetWeightedCountOfContainerItems(containerTpl, staticLootDist, locationName); if (itemCountToAdd == 0) { return containerClone; } // Get all possible loot items for container var containerLootPool = GetPossibleLootItemsForContainer(containerTpl, staticLootDist); // Some containers need to have items forced into it (quest keys etc.) var tplsForced = staticForced .Where(forcedStaticProp => forcedStaticProp.ContainerId == containerClone.Template.Id) .Select(x => x.ItemTpl) .ToHashSet(); // Draw random loot // Allow money to spawn more than once in container var failedToFitAttemptCount = 0; var lockList = itemHelper.GetMoneyTpls(); // Choose items to add to container, factor in weighting + lock money down // Filter out items picked that are already in the above `tplsForced` array var chosenTpls = LocationConfig.AllowDuplicateItemsInStaticContainers ? containerLootPool.Draw(itemCountToAdd).Where(tpl => !tplsForced.Contains(tpl) && !counterTrackerHelper.IncrementCount(tpl)) : containerLootPool .DrawAndRemove(itemCountToAdd, lockList) .Where(tpl => !tplsForced.Contains(tpl) && !counterTrackerHelper.IncrementCount(tpl)); // Add forced loot to chosen item pool var tplsToAddToContainer = tplsForced.Concat(chosenTpls); if (!tplsToAddToContainer.Any()) { logger.Warning($"Added no items to container: {containerTpl}"); } foreach (var tplToAdd in tplsToAddToContainer) { var chosenItemWithChildren = CreateStaticLootItem(tplToAdd, staticAmmoDist, parentId); if (chosenItemWithChildren is null) { continue; } // Check if item should have children removed var items = LocationConfig.TplsToStripChildItemsFrom.Contains(tplToAdd) ? [chosenItemWithChildren.Items.FirstOrDefault()] // Strip children from parent : chosenItemWithChildren.Items; // look for open slot to put chosen item into var result = containerMap.FindSlotForItem(chosenItemWithChildren.Width, chosenItemWithChildren.Height); if (!result.Success.GetValueOrDefault(false)) { if (failedToFitAttemptCount > LocationConfig.FitLootIntoContainerAttempts) // x attempts to fit an item, container is probably full, stop trying to add more { break; } // Can't fit item, skip failedToFitAttemptCount++; continue; } // Find somewhere for item inside container containerMap.TryFillContainerMapWithItem( result.X.Value, result.Y.Value, chosenItemWithChildren.Width, chosenItemWithChildren.Height, result.Rotation.GetValueOrDefault(false), out _ ); // Update root item properties with result of position finder items.First().SlotId = "main"; items.First().Location = new ItemLocation { X = result.X, Y = result.Y, R = result.Rotation.GetValueOrDefault(false) ? ItemRotation.Vertical : ItemRotation.Horizontal, }; // Add loot to container before returning var itemsToAdd = items.Select(item => item.ToLootItem()); // Convert into correct output type first containerClone.Template.Items = containerClone.Template.Items.Union(itemsToAdd); } return containerClone; } /// /// Look up a containers itemCountDistribution data and choose an item count based on the found weights /// /// Container to get item count for /// staticLoot.json /// Map name (to get per-map multiplier for from config) /// item count protected int GetWeightedCountOfContainerItems( MongoId containerTypeId, Dictionary staticLootDist, string locationName ) { // Create probability array to calculate the total count of lootable items inside container var itemCountArray = new ProbabilityObjectArray(cloner); var countDistribution = staticLootDist[containerTypeId]?.ItemCountDistribution; if (countDistribution is null) { logger.Warning( serverLocalisationService.GetText( "location-unable_to_find_count_distribution_for_container", new { containerId = containerTypeId, locationName } ) ); return 0; } foreach (var itemCountDistribution in countDistribution) { // Add each count of items into array itemCountArray.Add( new ProbabilityObject(itemCountDistribution.Count.Value, itemCountDistribution.RelativeProbability.Value, null) ); } return (int)Math.Round(GetStaticLootMultiplierForLocation(locationName) * itemCountArray.Draw()[0]); } /// /// Get all possible loot items that can be placed into a container /// Do not add seasonal items if found + current date is inside seasonal event /// /// Container to get possible loot for /// staticLoot.json /// ProbabilityObjectArray of item tpls + probability protected ProbabilityObjectArray GetPossibleLootItemsForContainer( MongoId containerTypeId, Dictionary staticLootDist ) { if (!staticLootDist.TryGetValue(containerTypeId, out var staticLoot) || staticLoot?.ItemDistribution is null) { logger.Warning(serverLocalisationService.GetText("location-missing_item_distribution_data", containerTypeId)); return new ProbabilityObjectArray(cloner); } var itemDistribution = new ProbabilityObjectArray(cloner); var seasonalEventActive = seasonalEventService.SeasonalEventEnabled(); var seasonalItemTplBlacklist = seasonalEventService.GetInactiveSeasonalEventItems(); foreach (var itemWithProbability in staticLoot.ItemDistribution) { if (!seasonalEventActive && seasonalItemTplBlacklist.Contains(itemWithProbability.Tpl)) { // Prevent seasonal loot when not inside season continue; } if (itemFilterService.IsLootableItemBlacklisted(itemWithProbability.Tpl)) { // Prevent non-loot items getting into pool continue; } itemDistribution.Add( new ProbabilityObject(itemWithProbability.Tpl, itemWithProbability.RelativeProbability.Value, null) ); } return itemDistribution; } /// /// Get the loose loot multiplier for this location, or the default if location not found /// /// Location to get value for /// multiplier protected double GetLooseLootMultiplierForLocation(string location) { return LocationConfig.LooseLootMultiplier.TryGetValue(location, out var value) ? value : LocationConfig.LooseLootMultiplier["default"]; } /// /// Get the static loot multiplier for this location, or the default if location not found /// /// Location to get value for /// multiplier protected double GetStaticLootMultiplierForLocation(string location) { return LocationConfig.StaticLootMultiplier.TryGetValue(location, out var value) ? value : LocationConfig.StaticLootMultiplier["default"]; } /// /// Create array of loose + forced loot using probability system /// /// /// /// Location to generate loot for /// Array of spawn points with loot in them public List GenerateDynamicLoot( LooseLoot dynamicLootDist, Dictionary> staticAmmoDist, string locationName ) { List loot = []; List dynamicForcedSpawnPoints = []; // Remove christmas items from loot data if (!seasonalEventService.ChristmasEventEnabled()) { dynamicLootDist.Spawnpoints = dynamicLootDist.Spawnpoints.Where(point => !point.Template.Id.StartsWith("christmas", StringComparison.OrdinalIgnoreCase) ); dynamicLootDist.SpawnpointsForced = dynamicLootDist.SpawnpointsForced.Where(point => !point.Template.Id.StartsWith("christmas", StringComparison.OrdinalIgnoreCase) ); } // Build the list of forced loot from both `SpawnpointsForced` and any point marked `IsAlwaysSpawn` dynamicForcedSpawnPoints.AddRange(dynamicLootDist.SpawnpointsForced); dynamicForcedSpawnPoints.AddRange(dynamicLootDist.Spawnpoints.Where(point => point.Template.IsAlwaysSpawn.GetValueOrDefault())); loot.AddRange(GetForcedDynamicLoot(dynamicForcedSpawnPoints, locationName, staticAmmoDist)); // Draw from random distribution var desiredSpawnPointCount = Math.Round( GetLooseLootMultiplierForLocation(locationName) * randomUtil.GetNormallyDistributedRandomNumber(dynamicLootDist.SpawnpointCount.Mean, dynamicLootDist.SpawnpointCount.Std) ); var blacklistedSpawnPoints = LocationConfig.LooseLootBlacklist.GetValueOrDefault(locationName); // Init empty array to hold spawn points, letting us pick them pseudo-randomly var spawnPointArray = new ProbabilityObjectArray(cloner); // Positions not in forced but have 100% chance to spawn List guaranteedLoosePoints = []; var allDynamicSpawnPoints = dynamicLootDist.Spawnpoints; foreach (var spawnPoint in allDynamicSpawnPoints) { // Point is blacklisted, skip if (blacklistedSpawnPoints?.Contains(spawnPoint.Template.Id) ?? false) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Ignoring loose loot location: {spawnPoint.Template.Id}"); } continue; } // We've handled IsAlwaysSpawn above, so skip them if (spawnPoint.Template.IsAlwaysSpawn ?? false) { continue; } // 100%, add it to guaranteed if (spawnPoint.Probability == 1) { guaranteedLoosePoints.Add(spawnPoint); continue; } spawnPointArray.Add(new ProbabilityObject(spawnPoint.Template.Id, spawnPoint.Probability ?? 0, spawnPoint)); } // Select a number of spawn points to add loot to // Add ALL loose loot with 100% chance to pool List chosenSpawnPoints = []; chosenSpawnPoints.AddRange(guaranteedLoosePoints); var randomSpawnPointCount = desiredSpawnPointCount - chosenSpawnPoints.Count; // Only draw random spawn points if needed if (randomSpawnPointCount > 0 && spawnPointArray.Count > 0) // Add randomly chosen spawn points, remove from pool after being picked { foreach (var si in spawnPointArray.DrawAndRemove((int)randomSpawnPointCount)) { chosenSpawnPoints.Add(spawnPointArray.Data(si)); } } // Filter out duplicate locationIds // prob can be done better chosenSpawnPoints = chosenSpawnPoints.GroupBy(spawnPoint => spawnPoint.LocationId).Select(group => group.First()).ToList(); // Do we have enough items in pool to fulfill requirement var tooManySpawnPointsRequested = desiredSpawnPointCount - chosenSpawnPoints.Count > 0; if (tooManySpawnPointsRequested) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( serverLocalisationService.GetText( "location-spawn_point_count_requested_vs_found", new { requested = desiredSpawnPointCount + guaranteedLoosePoints.Count, found = chosenSpawnPoints.Count, mapName = locationName, } ) ); } } // Iterate over spawnPoints var seasonalEventActive = seasonalEventService.SeasonalEventEnabled(); var seasonalItemTplBlacklist = seasonalEventService.GetInactiveSeasonalEventItems(); foreach (var spawnPoint in chosenSpawnPoints) { // SpawnPoint is invalid, skip it if (spawnPoint.Template is null) { logger.Warning(serverLocalisationService.GetText("location-missing_dynamic_template", spawnPoint.LocationId)); continue; } // Ensure no blacklisted lootable items are in pool spawnPoint.Template.Items = spawnPoint .Template.Items.Where(item => !itemFilterService.IsLootableItemBlacklisted(item.Template)) .ToList(); // Ensure no seasonal items are in pool if not in-season if (!seasonalEventActive) { spawnPoint.Template.Items = spawnPoint.Template.Items.Where(item => !seasonalItemTplBlacklist.Contains(item.Template)); } // Spawn point has no items after filtering, skip if (spawnPoint.Template.Items is null || !spawnPoint.Template.Items.Any()) { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug(serverLocalisationService.GetText("location-spawnpoint_missing_items", spawnPoint.Template.Id)); } continue; } // Get an array of allowed IDs after above filtering has occured var validComposedKeys = spawnPoint.Template.Items.Select(item => item.ComposedKey).ToHashSet(); // Construct container to hold above filtered items, letting us pick an item for the spot var itemArray = new ProbabilityObjectArray(cloner); foreach (var itemDist in spawnPoint.ItemDistribution) { if (!validComposedKeys.Contains(itemDist.ComposedKey.Key)) { continue; } itemArray.Add(new ProbabilityObject(itemDist.ComposedKey.Key, itemDist.RelativeProbability ?? 0, null)); } if (itemArray.Count == 0) { logger.Warning(serverLocalisationService.GetText("location-loot_pool_is_empty_skipping", spawnPoint.Template.Id)); continue; } // Draw a random item from the spawn points possible items var chosenComposedKey = itemArray.Draw().FirstOrDefault(); var chosenItem = spawnPoint.Template.Items.FirstOrDefault(item => item.ComposedKey == chosenComposedKey); if (chosenItem is null) { logger.Warning( $"Unable to find item with composed key: {chosenComposedKey}, skipping spawn point: {spawnPoint.LocationId} " ); continue; } var createItemResult = CreateDynamicLootItem(chosenItem, spawnPoint.Template.Items, staticAmmoDist); // If count reaches max, skip adding item to loot if (counterTrackerHelper.IncrementCount(createItemResult.Items.FirstOrDefault().Template)) { continue; } // Root id can change when generating a weapon, ensure ids match spawnPoint.Template.Root = createItemResult.Items.FirstOrDefault().Id; // Convert the processed items into the correct output type var convertedItems = createItemResult.Items.Select(item => item.ToLootItem()).ToList(); // Overwrite entire pool with chosen item spawnPoint.Template.Items = convertedItems; loot.Add(spawnPoint.Template); } return loot; } /// /// Force items to be added to loot spawn points, primarily quest items /// /// Forced loot locations that must be added /// Name of map currently having force loot created for /// /// Collection of spawn points with forced loot in them protected List GetForcedDynamicLoot( IEnumerable forcedSpawnPoints, string locationName, Dictionary> staticAmmoDist ) { var result = new List(); var seasonalEventActive = seasonalEventService.SeasonalEventEnabled(); var seasonalItemTplBlacklist = seasonalEventService.GetInactiveSeasonalEventItems(); foreach (var forcedLootLocation in forcedSpawnPoints) { var locationTemplateToAdd = forcedLootLocation.Template; var rootItem = locationTemplateToAdd.Items.FirstOrDefault(); if (counterTrackerHelper.IncrementCount(rootItem.Template)) { continue; } // Skip adding seasonal items when seasonal event is not active if (!seasonalEventActive && seasonalItemTplBlacklist.Contains(rootItem.Template)) { continue; } var chosenItem = forcedLootLocation.Template.Items.FirstOrDefault(item => item.Id == rootItem.Id); var createItemResult = CreateDynamicLootItem(chosenItem, forcedLootLocation.Template.Items, staticAmmoDist); // Update root ID with the above dynamically generated ID forcedLootLocation.Template.Root = createItemResult.Items.FirstOrDefault().Id; // Convert the processed items into the correct output type var convertedItems = createItemResult.Items.Select(item => item.ToLootItem()).ToList(); forcedLootLocation.Template.Items = convertedItems; // Push forced location into array as long as it doesn't exist already var existingLocation = result.Any(spawnPoint => spawnPoint.Id == locationTemplateToAdd.Id); if (!existingLocation) { result.Add(locationTemplateToAdd); } else { if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( $"Attempted to add a forced loot location with Id: {locationTemplateToAdd.Id} to map {locationName} that already has that id in use, skipping" ); } } } return result; } /// /// Create array of item (with child items) and return /// /// Item we want to spawn in the position /// Location loot Template /// Ammo distributions /// ContainerItem object protected ContainerItem CreateDynamicLootItem( SptLootItem chosenItem, IEnumerable lootItems, Dictionary> staticAmmoDist ) { var chosenTpl = chosenItem.Template; var itemDbTemplate = itemHelper.GetItem(chosenTpl).Value; if (itemDbTemplate is null) { logger.Error($"Item tpl: {chosenTpl} cannot be found in database"); } // Item array to return List itemWithMods = []; // Money/Ammo - don't rely on items in spawnPoint.template.Items so we can randomise it ourselves if (itemHelper.IsOfBaseclasses(chosenTpl, [BaseClasses.MONEY, BaseClasses.AMMO])) { var stackCount = itemDbTemplate.Properties.StackMaxSize == 1 ? 1 : randomUtil.GetInt(itemDbTemplate.Properties.StackMinRandom.Value, itemDbTemplate.Properties.StackMaxRandom.Value); itemWithMods.Add( new Item { Id = new MongoId(), Template = chosenTpl, Upd = new Upd { StackObjectsCount = stackCount }, } ); } else if (itemHelper.IsOfBaseclass(chosenTpl, BaseClasses.AMMO_BOX)) { // Fill with cartridges List ammoBoxItem = [new() { Id = new MongoId(), Template = chosenTpl }]; itemHelper.AddCartridgesToAmmoBox(ammoBoxItem, itemDbTemplate); itemWithMods.AddRange(ammoBoxItem); } else if (itemHelper.IsOfBaseclass(chosenTpl, BaseClasses.MAGAZINE)) { // Create array with just magazine List magazineItem = [new() { Id = new MongoId(), Template = chosenTpl }]; if (randomUtil.GetChance100(LocationConfig.StaticMagazineLootHasAmmoChancePercent)) // Add randomised amount of cartridges { itemHelper.FillMagazineWithRandomCartridge( magazineItem, itemDbTemplate, // Magazine template staticAmmoDist, null, LocationConfig.MinFillLooseMagazinePercent / 100d ); } itemWithMods.AddRange(magazineItem); } else { // Also used by armors to get child mods // Get item + children and add into array we return var itemWithChildren = lootItems.GetItemWithChildren(chosenItem.Id); // Ensure all IDs are unique itemWithChildren = cloner.Clone(itemWithChildren).ReplaceIDs().ToList(); if (LocationConfig.TplsToStripChildItemsFrom.Contains(chosenItem.Template)) // Strip children from parent before adding { itemWithChildren = [itemWithChildren.FirstOrDefault()]; } itemWithMods.AddRange(itemWithChildren); } // Get inventory size of item var size = itemHelper.GetItemSize(itemWithMods, itemWithMods.FirstOrDefault().Id); return new ContainerItem { Items = itemWithMods, Width = size.Width, Height = size.Height, }; } /// /// Hydrate an item with children if necessary /// HIGHLY BRITTLE, LEGACY CODE /// /// Item tpl to add children to /// Ammo pool /// /// ContainerItem protected ContainerItem? CreateStaticLootItem( MongoId chosenTpl, Dictionary> staticAmmoDist, string? parentId = null ) { var itemTemplate = itemHelper.GetItem(chosenTpl).Value; if (itemTemplate?.Properties is null) { logger.Error($"Unable to process item: {chosenTpl}. it lacks _props"); return null; } var width = itemTemplate.Properties.Width; var height = itemTemplate.Properties.Height; List items = [new() { Id = new MongoId(), Template = chosenTpl }]; var rootItem = items.FirstOrDefault(); // Use passed in parentId as override for new item if (!string.IsNullOrEmpty(parentId)) { rootItem.ParentId = parentId; } // Money needs its stack size randomised if (itemHelper.IsOfBaseclass(chosenTpl, BaseClasses.MONEY) || itemHelper.IsOfBaseclass(chosenTpl, BaseClasses.AMMO)) { // Edge case - some ammos e.g. flares or M406 grenades shouldn't be stacked var stackCount = itemTemplate.Properties.StackMaxSize == 1 ? 1 : randomUtil.GetInt(itemTemplate.Properties.StackMinRandom.Value, itemTemplate.Properties.StackMaxRandom.Value); rootItem.Upd = new Upd { StackObjectsCount = stackCount }; } // No spawn point, use default template else if (itemHelper.IsOfBaseclass(chosenTpl, BaseClasses.WEAPON)) { rootItem = CreateWeaponRootAndChildren(chosenTpl, staticAmmoDist, parentId, ref items); var size = itemHelper.GetItemSize(items, rootItem.Id); width = size.Width; height = size.Height; } // No spawnPoint to fall back on, generate manually else if (itemHelper.IsOfBaseclass(chosenTpl, BaseClasses.AMMO_BOX)) { itemHelper.AddCartridgesToAmmoBox(items, itemTemplate); } else if (itemHelper.IsOfBaseclass(chosenTpl, BaseClasses.MAGAZINE)) { if (randomUtil.GetChance100(LocationConfig.MagazineLootHasAmmoChancePercent)) { // Create array with just magazine GenerateStaticMagazineItem(staticAmmoDist, rootItem, itemTemplate, items); } } else if (itemHelper.ArmorItemCanHoldMods(chosenTpl)) { items = GetArmorItems(chosenTpl, rootItem, items, itemTemplate); } return new ContainerItem { Items = items, Width = width, Height = height, }; } /// /// Get an armor with its children /// /// Armor tpl /// Base armor item /// Pool of items to look for armor in /// Db template of armor we are looking for /// Armor + children protected List GetArmorItems(MongoId chosenTpl, Item? rootItem, List items, TemplateItem armorDbTemplate) { var defaultPreset = presetHelper.GetDefaultPreset(chosenTpl); if (defaultPreset is not null) { var presetAndModsClone = cloner.Clone(defaultPreset.Items).ReplaceIDs(); presetAndModsClone.RemapRootItemId(); // Use original items parentId otherwise item doesn't get added to container correctly presetAndModsClone.FirstOrDefault().ParentId = rootItem.ParentId; items = presetAndModsClone.ToList(); } else { // We make base item in calling method, no need to do it here if (armorDbTemplate.Properties?.Slots is not null && armorDbTemplate.Properties.Slots.Any()) { items = itemHelper.AddChildSlotItems(items, armorDbTemplate, LocationConfig.EquipmentLootSettings.ModSpawnChancePercent); } } return items; } /// /// Attempt to find default preset for passed in tpl and construct a weapon with children. /// If no preset found, return chosenTpl as Item object /// /// Tpl of item to get preset for /// Pool of cartridges to pick from /// /// Root item + children /// Root Item protected Item CreateWeaponRootAndChildren( MongoId chosenTpl, Dictionary> cartridgePool, string? parentId, ref List items ) { List children = []; // Look up a default preset for desired weapon tpl var defaultPreset = cloner.Clone(presetHelper.GetDefaultPreset(chosenTpl)); if (defaultPreset?.Items is not null) { try { children = itemHelper.ReparentItemAndChildren(defaultPreset.Items.FirstOrDefault(), defaultPreset.Items); } catch (Exception e) { // this item already broke it once without being reproducible tpl = "5839a40f24597726f856b511"; AKS-74UB Default // 5ea03f7400685063ec28bfa8 // ppsh default // 5ba26383d4351e00334c93d9 //mp7_devgru logger.Error( serverLocalisationService.GetText( "location-preset_not_found", new { tpl = chosenTpl, defaultId = defaultPreset.Id, defaultName = defaultPreset.Name, parentId, } ) ); logger.Error(e.StackTrace); throw; } } else { // RSP30 (62178be9d0050232da3485d9/624c0b3340357b5f566e8766/6217726288ed9f0845317459) doesn't have any default presets and kills this code below as it has no children to re-parent if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"createStaticLootItem() No preset found for weapon: {chosenTpl}"); } } var rootItem = items.FirstOrDefault(); if (rootItem is null) { logger.Error(serverLocalisationService.GetText("location-missing_root_item", new { tpl = chosenTpl, parentId })); throw new Exception(serverLocalisationService.GetText("location-critical_error_see_log")); } try { if (children?.Count > 0) { items = itemHelper.ReparentItemAndChildren(rootItem, children); } } catch (Exception e) { logger.Error(serverLocalisationService.GetText("location-unable_to_reparent_item", new { tpl = chosenTpl, parentId })); logger.Error(e.StackTrace); throw; } // Here we should use generalized BotGenerators functions e.g. fillExistingMagazines in the future since // it can handle revolver ammo (it's not restructured to be used here yet.) // General: Make a WeaponController for Ragfair preset stuff and the generating weapons and ammo stuff from // BotGenerator var magazine = items.FirstOrDefault(item => item.SlotId == "mod_magazine"); // some weapon presets come without magazine; only fill the mag if it exists if (magazine is not null) { var magTemplate = itemHelper.GetItem(magazine.Template).Value; var weaponTemplate = itemHelper.GetItem(chosenTpl).Value; // Create array with just magazine var defaultWeapon = itemHelper.GetItem(rootItem.Template).Value; List magazineWithCartridges = [magazine]; itemHelper.FillMagazineWithRandomCartridge( magazineWithCartridges, magTemplate, cartridgePool, weaponTemplate.Properties.AmmoCaliber, 0.25, defaultWeapon.Properties.DefAmmo, defaultWeapon ); // Replace existing magazine with above array items.Remove(magazine); items.AddRange(magazineWithCartridges); } return rootItem; } /// /// Given the provided magazine root item, add cartridges to a magazine /// /// /// Magazine item /// Db item of magazine /// Item pool with magazine protected void GenerateStaticMagazineItem( Dictionary> staticAmmoDist, Item rootItem, TemplateItem itemTemplate, List items ) { List magazineWithCartridges = [rootItem]; itemHelper.FillMagazineWithRandomCartridge( magazineWithCartridges, itemTemplate, staticAmmoDist, null, LocationConfig.MinFillStaticMagazinePercent / 100d ); // Replace existing magazine with above array items.Remove(rootItem); items.AddRange(magazineWithCartridges); } } public record ContainerGroupCount { [JsonPropertyName("containerIdsWithProbability")] public Dictionary? ContainerIdsWithProbability { get; set; } [JsonPropertyName("chosenCount")] public double? ChosenCount { get; set; } } public class ContainerItem { [JsonPropertyName("items")] public IEnumerable? Items { get; set; } [JsonPropertyName("width")] public int? Width { get; set; } [JsonPropertyName("height")] public int? Height { get; set; } }