import { inject, injectable } from "tsyringe"; import { ContainerHelper } from "@spt-aki/helpers/ContainerHelper"; import { ItemHelper } from "@spt-aki/helpers/ItemHelper"; import { PresetHelper } from "@spt-aki/helpers/PresetHelper"; import { RagfairServerHelper } from "@spt-aki/helpers/RagfairServerHelper"; import { IContainerMinMax, IStaticContainer } from "@spt-aki/models/eft/common/ILocation"; import { ILocationBase } from "@spt-aki/models/eft/common/ILocationBase"; import { ILooseLoot, Spawnpoint, SpawnpointTemplate, SpawnpointsForced } from "@spt-aki/models/eft/common/ILooseLoot"; import { Item } from "@spt-aki/models/eft/common/tables/IItem"; import { IStaticAmmoDetails, IStaticContainerData, IStaticForcedProps, IStaticLootDetails, } from "@spt-aki/models/eft/common/tables/ILootBase"; import { ITemplateItem } from "@spt-aki/models/eft/common/tables/ITemplateItem"; import { BaseClasses } from "@spt-aki/models/enums/BaseClasses"; import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes"; import { Money } from "@spt-aki/models/enums/Money"; import { ILocationConfig } from "@spt-aki/models/spt/config/ILocationConfig"; import { ILogger } from "@spt-aki/models/spt/utils/ILogger"; import { ConfigServer } from "@spt-aki/servers/ConfigServer"; import { DatabaseServer } from "@spt-aki/servers/DatabaseServer"; import { LocalisationService } from "@spt-aki/services/LocalisationService"; import { SeasonalEventService } from "@spt-aki/services/SeasonalEventService"; import { HashUtil } from "@spt-aki/utils/HashUtil"; import { JsonUtil } from "@spt-aki/utils/JsonUtil"; import { MathUtil } from "@spt-aki/utils/MathUtil"; import { ObjectId } from "@spt-aki/utils/ObjectId"; import { ProbabilityObject, ProbabilityObjectArray, RandomUtil } from "@spt-aki/utils/RandomUtil"; export interface IContainerItem { items: Item[]; width: number; height: number; } export interface IContainerGroupCount { /** Containers this group has + probabilty to spawn */ containerIdsWithProbability: Record; /** How many containers the map should spawn with this group id */ chosenCount: number; } @injectable() export class LocationGenerator { protected locationConfig: ILocationConfig; constructor( @inject("WinstonLogger") protected logger: ILogger, @inject("DatabaseServer") protected databaseServer: DatabaseServer, @inject("JsonUtil") protected jsonUtil: JsonUtil, @inject("HashUtil") protected hashUtil: HashUtil, @inject("ObjectId") protected objectId: ObjectId, @inject("RandomUtil") protected randomUtil: RandomUtil, @inject("RagfairServerHelper") protected ragfairServerHelper: RagfairServerHelper, @inject("ItemHelper") protected itemHelper: ItemHelper, @inject("MathUtil") protected mathUtil: MathUtil, @inject("SeasonalEventService") protected seasonalEventService: SeasonalEventService, @inject("ContainerHelper") protected containerHelper: ContainerHelper, @inject("PresetHelper") protected presetHelper: PresetHelper, @inject("LocalisationService") protected localisationService: LocalisationService, @inject("ConfigServer") protected configServer: ConfigServer, ) { this.locationConfig = this.configServer.getConfig(ConfigTypes.LOCATION); } /** * Create an array of container objects with randomised loot * @param locationBase Map base to generate containers for * @param staticAmmoDist Static ammo distribution - database.loot.staticAmmo * @returns Array of container objects */ public generateStaticContainers( locationBase: ILocationBase, staticAmmoDist: Record, ): SpawnpointTemplate[] { let staticLootItemCount = 0; const result: SpawnpointTemplate[] = []; const locationId = locationBase.Id.toLowerCase(); const db = this.databaseServer.getTables(); const staticWeaponsOnMap = this.jsonUtil.clone(db.loot.staticContainers[locationBase.Name]?.staticWeapons); if (!staticWeaponsOnMap) { this.logger.error(`Unable to find static weapon data for map: ${locationBase.Name}`); } // Add mounted weapons to output loot result.push(...staticWeaponsOnMap ?? []); const allStaticContainersOnMap = this.jsonUtil.clone( db.loot.staticContainers[locationBase.Name]?.staticContainers, ); if (!allStaticContainersOnMap) { this.logger.error(`Unable to find static container data for map: ${locationBase.Name}`); } const staticRandomisableContainersOnMap = this.getRandomisableContainersOnMap(allStaticContainersOnMap); // Containers that MUST be added to map (quest containers etc) const staticForcedOnMap = this.jsonUtil.clone(db.loot.staticContainers[locationBase.Name]?.staticForced); if (!staticForcedOnMap) { this.logger.error(`Unable to find forced static data for map: ${locationBase.Name}`); } // Keep track of static loot count let staticContainerCount = 0; // Find all 100% spawn containers const staticLootDist = db.loot.staticLoot; const guaranteedContainers = this.getGuaranteedContainers(allStaticContainersOnMap); staticContainerCount += guaranteedContainers.length; // Add loot to guaranteed containers and add to result for (const container of guaranteedContainers) { const containerWithLoot = this.addLootToContainer( container, staticForcedOnMap, staticLootDist, staticAmmoDist, locationId, ); result.push(containerWithLoot.template); staticLootItemCount += containerWithLoot.template.Items.length; } this.logger.debug(`Added ${guaranteedContainers.length} guaranteed containers`); // Randomisation is turned off globally or just turned off for this map if ( !(this.locationConfig.containerRandomisationSettings.enabled && this.locationConfig.containerRandomisationSettings.maps[locationId]) ) { this.logger.debug( `Container randomisation disabled, Adding ${staticRandomisableContainersOnMap.length} containers to ${locationBase.Name}`, ); for (const container of staticRandomisableContainersOnMap) { const containerWithLoot = this.addLootToContainer( container, staticForcedOnMap, staticLootDist, staticAmmoDist, locationId, ); result.push(containerWithLoot.template); staticLootItemCount += containerWithLoot.template.Items.length; } this.logger.success(`A total of ${staticLootItemCount} static items spawned`); return result; } // Group containers by their groupId const staticContainerGroupData: IStaticContainer = db.locations[locationId].statics; if (!staticContainerGroupData) { this.logger.warning(`Map: ${locationId} lacks a statics file, skipping container generation.`) return result; } const mapping = this.getGroupIdToContainerMappings(staticContainerGroupData, staticRandomisableContainersOnMap); // For each of the container groups, choose from the pool of containers, hydrate container with loot and add to result array for (const groupId in mapping) { const data = mapping[groupId]; // Count chosen was 0, skip if (data.chosenCount === 0) { continue; } if (Object.keys(data.containerIdsWithProbability).length === 0) { this.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 === "") { const containerIdsCopy = this.jsonUtil.clone(data.containerIdsWithProbability); // Roll each containers probability, if it passes, it gets added data.containerIdsWithProbability = {}; for (const containerId in containerIdsCopy) { if (this.randomUtil.getChance100(containerIdsCopy[containerId] * 100)) { data.containerIdsWithProbability[containerId] = containerIdsCopy[containerId]; } } // Set desired count to size of array (we want all containers chosen) data.chosenCount = Object.keys(data.containerIdsWithProbability).length; // EDGE CASE: chosen container count could be 0 if (data.chosenCount === 0) { continue; } } // Pass possible containers into function to choose some const chosenContainerIds = this.getContainersByProbabilty(groupId, data); for (const chosenContainerId of chosenContainerIds) { // Look up container object from full list of containers on map const containerObject = staticRandomisableContainersOnMap.find((x) => x.template.Id === chosenContainerId ); if (!containerObject) { this.logger.debug( `Container: ${ chosenContainerIds[chosenContainerId] } not found in staticRandomisableContainersOnMap, this is bad`, ); continue; } // Add loot to container and push into result object const containerWithLoot = this.addLootToContainer( containerObject, staticForcedOnMap, staticLootDist, staticAmmoDist, locationId, ); result.push(containerWithLoot.template); staticContainerCount++; staticLootItemCount += containerWithLoot.template.Items.length; } } this.logger.success(`A total of ${staticLootItemCount} static items spawned`); this.logger.success( this.localisationService.getText("location-containers_generated_success", staticContainerCount), ); return result; } /** * Get containers with a non-100% chance to spawn OR are NOT on the container type randomistion blacklist * @param staticContainers * @returns IStaticContainerData array */ protected getRandomisableContainersOnMap(staticContainers: IStaticContainerData[]): IStaticContainerData[] { return staticContainers.filter((x) => x.probability !== 1 && !x.template.IsAlwaysSpawn && !this.locationConfig.containerRandomisationSettings.containerTypesToNotRandomise.includes( x.template.Items[0]._tpl, ) ); } /** * Get containers with 100% spawn rate or have a type on the randomistion ignore list * @param staticContainersOnMap * @returns IStaticContainerData array */ protected getGuaranteedContainers(staticContainersOnMap: IStaticContainerData[]): IStaticContainerData[] { return staticContainersOnMap.filter((x) => x.probability === 1 || x.template.IsAlwaysSpawn || this.locationConfig.containerRandomisationSettings.containerTypesToNotRandomise.includes( x.template.Items[0]._tpl, ) ); } /** * Choose a number of containers based on their probabilty value to fulfil the desired count in containerData.chosenCount * @param groupId Name of the group the containers are being collected for * @param containerData Containers and probability values for a groupId * @returns List of chosen container Ids */ protected getContainersByProbabilty(groupId: string, containerData: IContainerGroupCount): string[] { const chosenContainerIds: string[] = []; const containerIds = Object.keys(containerData.containerIdsWithProbability); if (containerData.chosenCount > containerIds.length) { this.logger.debug( `Group: ${groupId} wants ${containerData.chosenCount} containers but pool only has ${containerIds.length}, add what's available`, ); return containerIds; } // Create probability array with all possible container ids in this group and their relataive probability of spawning const containerDistribution = new ProbabilityObjectArray(this.mathUtil, this.jsonUtil); containerIds.forEach((x) => containerDistribution.push(new ProbabilityObject(x, containerData.containerIdsWithProbability[x])) ); chosenContainerIds.push(...containerDistribution.draw(containerData.chosenCount)); return chosenContainerIds; } /** * Get a mapping of each groupid and the containers in that group + count of containers to spawn on map * @param containersGroups Container group values * @returns dictionary keyed by groupId */ protected getGroupIdToContainerMappings( staticContainerGroupData: IStaticContainer | Record, staticContainersOnMap: IStaticContainerData[], ): Record { // Create dictionary of all group ids and choose a count of containers the map will spawn of that group const mapping: Record = {}; for (const groupId in staticContainerGroupData.containersGroups) { const groupData = staticContainerGroupData.containersGroups[groupId]; if (!mapping[groupId]) { mapping[groupId] = { containerIdsWithProbability: {}, chosenCount: this.randomUtil.getInt( Math.round( groupData.minContainers * this.locationConfig.containerRandomisationSettings.containerGroupMinSizeMultiplier, ), Math.round( groupData.maxContainers * this.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[""] = { containerIdsWithProbability: {}, chosenCount: -1 }; // Iterate over all containers and add to group keyed by groupId // Containers without a group go into a group with empty key "" for (const container of staticContainersOnMap) { const groupData = staticContainerGroupData.containers[container.template.Id]; if (!groupData) { this.logger.error(`Container ${container.template.Id} not found in statics.json, this is bad`); continue; } if (container.probability === 1) { this.logger.debug( `Container ${container.template.Id} with group ${groupData.groupId} had 100% chance to spawn was picked as random container, skipping`, ); continue; } mapping[groupData.groupId].containerIdsWithProbability[container.template.Id] = container.probability; } return mapping; } /** * Choose loot to put into a static container based on weighting * Handle forced items + seasonal item removal when not in season * @param staticContainer The container itself we will add loot to * @param staticForced Loot we need to force into the container * @param staticLootDist staticLoot.json * @param staticAmmoDist staticAmmo.json * @param locationName Name of the map to generate static loot for * @returns IStaticContainerProps */ protected addLootToContainer( staticContainer: IStaticContainerData, staticForced: IStaticForcedProps[], staticLootDist: Record, staticAmmoDist: Record, locationName: string, ): IStaticContainerData { const container = this.jsonUtil.clone(staticContainer); const containerTpl = container.template.Items[0]._tpl; // Create new unique parent id to prevent any collisions const parentId = this.objectId.generate(); container.template.Root = parentId; container.template.Items[0]._id = parentId; let containerMap = this.getContainerMapping(containerTpl); // Choose count of items to add to container const itemCountToAdd = this.getWeightedCountOfContainerItems(containerTpl, staticLootDist, locationName); // Get all possible loot items for container const containerLootPool = this.getPossibleLootItemsForContainer(containerTpl, staticLootDist); // Some containers need to have items forced into it (quest keys etc) const tplsForced = staticForced.filter((x) => x.containerId === container.template.Id).map((x) => x.itemTpl); // Draw random loot // Money spawn more than once in container let failedToFitCount = 0; const locklist = [Money.ROUBLES, Money.DOLLARS, Money.EUROS]; // Choose items to add to container, factor in weighting + lock money down // Filter out items picked that're already in the above `tplsForced` array const chosenTpls = containerLootPool.draw( itemCountToAdd, this.locationConfig.allowDuplicateItemsInStaticContainers, locklist, ).filter(x => !tplsForced.includes(x)); // Add forced loot to chosen item pool const tplsToAddToContainer = tplsForced.concat(chosenTpls); for (const tplToAdd of tplsToAddToContainer) { const chosenItemWithChildren = this.createStaticLootItem(tplToAdd, staticAmmoDist, parentId); const items = chosenItemWithChildren.items; const width = chosenItemWithChildren.width; const height = chosenItemWithChildren.height; // look for open slot to put chosen item into const result = this.containerHelper.findSlotForItem(containerMap, width, height); if (!result.success) { // 2 attempts to fit an item, container is probably full, stop trying to add more if (failedToFitCount >= this.locationConfig.fitLootIntoContainerAttempts) { break; } // Can't fit item, skip failedToFitCount++; continue; } containerMap = this.containerHelper.fillContainerMapWithItem( containerMap, result.x, result.y, width, height, result.rotation, ); const rotation = result.rotation ? 1 : 0; items[0].slotId = "main"; items[0].location = { x: result.x, y: result.y, r: rotation }; // Add loot to container before returning for (const item of items) { container.template.Items.push(item); } } return container; } /** * Get a 2d grid of a containers item slots * @param containerTpl Tpl id of the container * @returns number[][] */ protected getContainerMapping(containerTpl: string): number[][] { // Get template from db const containerTemplate = this.itemHelper.getItem(containerTpl)[1]; // Get height/width const height = containerTemplate._props.Grids[0]._props.cellsV; const width = containerTemplate._props.Grids[0]._props.cellsH; // Calcualte 2d array and return return Array(height).fill(0).map(() => Array(width).fill(0)); } /** * Look up a containers itemcountDistribution data and choose an item count based on the found weights * @param containerTypeId Container to get item count for * @param staticLootDist staticLoot.json * @param locationName Map name (to get per-map multiplier for from config) * @returns item count */ protected getWeightedCountOfContainerItems( containerTypeId: string, staticLootDist: Record, locationName: string, ): number { // Create probability array to calcualte the total count of lootable items inside container const itemCountArray = new ProbabilityObjectArray(this.mathUtil, this.jsonUtil); const countDistribution = staticLootDist[containerTypeId]?.itemcountDistribution; if (!countDistribution) { this.logger.warning(`Unable to acquire count distrubution for container: ${containerTypeId} on: ${locationName}. defaulting to 0`); return 0; } for (const itemCountDistribution of countDistribution) { // Add each count of items into array itemCountArray.push( new ProbabilityObject(itemCountDistribution.count, itemCountDistribution.relativeProbability), ); } return Math.round(this.getStaticLootMultiplerForLocation(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 * @param containerTypeId Contianer to get possible loot for * @param staticLootDist staticLoot.json * @returns ProbabilityObjectArray of item tpls + probabilty */ protected getPossibleLootItemsForContainer( containerTypeId: string, staticLootDist: Record, ): ProbabilityObjectArray { const seasonalEventActive = this.seasonalEventService.seasonalEventEnabled(); const seasonalItemTplBlacklist = this.seasonalEventService.getInactiveSeasonalEventItems(); const itemDistribution = new ProbabilityObjectArray(this.mathUtil, this.jsonUtil); const itemContainerDistribution = staticLootDist[containerTypeId]?.itemDistribution; if (!itemContainerDistribution) { this.logger.warning(`Unable to acquire item distrubution for container: ${containerTypeId}`); return itemDistribution; } for (const icd of itemContainerDistribution) { if (!seasonalEventActive && seasonalItemTplBlacklist.includes(icd.tpl)) { // Skip seasonal event items if they're not enabled continue; } itemDistribution.push(new ProbabilityObject(icd.tpl, icd.relativeProbability)); } return itemDistribution; } protected getLooseLootMultiplerForLocation(location: string): number { return this.locationConfig.looseLootMultiplier[location]; } protected getStaticLootMultiplerForLocation(location: string): number { return this.locationConfig.staticLootMultiplier[location]; } /** * Create array of loose + forced loot using probability system * @param dynamicLootDist * @param staticAmmoDist * @param locationName Location to generate loot for * @returns Array of spawn points with loot in them */ public generateDynamicLoot( dynamicLootDist: ILooseLoot, staticAmmoDist: Record, locationName: string, ): SpawnpointTemplate[] { const loot: SpawnpointTemplate[] = []; // Add all forced loot to return array this.addForcedLoot(loot, dynamicLootDist.spawnpointsForced, locationName); const allDynamicSpawnpoints = dynamicLootDist.spawnpoints; // Draw from random distribution const desiredSpawnpointCount = Math.round( this.getLooseLootMultiplerForLocation(locationName) * this.randomUtil.getNormallyDistributedRandomNumber(dynamicLootDist.spawnpointCount.mean, dynamicLootDist.spawnpointCount.std), ); // Positions not in forced but have 100% chance to spawn const guaranteedLoosePoints: Spawnpoint[] = []; const blacklistedSpawnpoints = this.locationConfig.looseLootBlacklist[locationName]; const spawnpointArray = new ProbabilityObjectArray(this.mathUtil, this.jsonUtil); for (const spawnpoint of allDynamicSpawnpoints) { // Point is blacklsited, skip if (blacklistedSpawnpoints?.includes(spawnpoint.template.Id)) { this.logger.debug(`Ignoring loose loot location: ${spawnpoint.template.Id}`); continue; } if (spawnpoint.probability === 1) { guaranteedLoosePoints.push(spawnpoint); continue; } spawnpointArray.push(new ProbabilityObject(spawnpoint.template.Id, spawnpoint.probability, spawnpoint)); } // Select a number of spawn points to add loot to // Add ALL loose loot with 100% chance to pool let chosenSpawnpoints: Spawnpoint[] = [...guaranteedLoosePoints]; // Add randomly chosen spawn points for (const si of spawnpointArray.draw(desiredSpawnpointCount, false)) { chosenSpawnpoints.push(spawnpointArray.data(si)); } // Filter out duplicate locationIds chosenSpawnpoints = [...new Map(chosenSpawnpoints.map((x) => [x.locationId, x])).values()]; // Do we have enough items in pool to fulfill requirement const tooManySpawnPointsRequested = (desiredSpawnpointCount - chosenSpawnpoints.length) > 0; if (tooManySpawnPointsRequested) { this.logger.debug( this.localisationService.getText("location-spawn_point_count_requested_vs_found", { requested: desiredSpawnpointCount + guaranteedLoosePoints.length, found: chosenSpawnpoints.length, mapName: locationName, }), ); } // Iterate over spawnpoints const seasonalEventActive = this.seasonalEventService.seasonalEventEnabled(); const seasonalItemTplBlacklist = this.seasonalEventService.getInactiveSeasonalEventItems(); for (const spawnPoint of chosenSpawnpoints) { if (!spawnPoint.template) { this.logger.warning( this.localisationService.getText("location-missing_dynamic_template", spawnPoint.locationId), ); continue; } if (!spawnPoint.template.Items || spawnPoint.template.Items.length === 0) { this.logger.error( this.localisationService.getText("location-spawnpoint_missing_items", spawnPoint.template.Id), ); continue; } const itemArray = new ProbabilityObjectArray(this.mathUtil, this.jsonUtil); for (const itemDist of spawnPoint.itemDistribution) { if ( !seasonalEventActive && seasonalItemTplBlacklist.includes( spawnPoint.template.Items.find((x) => x._id === itemDist.composedKey.key)._tpl, ) ) { // Skip seasonal event items if they're not enabled continue; } itemArray.push(new ProbabilityObject(itemDist.composedKey.key, itemDist.relativeProbability)); } if (itemArray.length === 0) { this.logger.warning(`Loot pool for position: ${spawnPoint.template.Id} is empty. Skipping`); continue; } // Draw a random item from spawn points possible items const chosenComposedKey = itemArray.draw(1)[0]; const createItemResult = this.createDynamicLootItem(chosenComposedKey, spawnPoint, staticAmmoDist); // Root id can change when generating a weapon spawnPoint.template.Root = createItemResult.items[0]._id; spawnPoint.template.Items = createItemResult.items; loot.push(spawnPoint.template); } return loot; } /** * Add forced spawn point loot into loot parameter array * @param lootLocationTemplates array to add forced loot spawn locations to * @param forcedSpawnPoints forced Forced loot locations that must be added * @param locationName Name of map currently having force loot created for */ protected addForcedLoot( lootLocationTemplates: SpawnpointTemplate[], forcedSpawnPoints: SpawnpointsForced[], locationName: string, ): void { const lootToForceSingleAmountOnMap = this.locationConfig.forcedLootSingleSpawnById[locationName]; if (lootToForceSingleAmountOnMap) { // Process loot items defined as requiring only 1 spawn position as they appear in multiple positions on the map for (const itemTpl of lootToForceSingleAmountOnMap) { // Get all spawn positions for item tpl in forced loot array const items = forcedSpawnPoints.filter((x) => x.template.Items[0]._tpl === itemTpl); if (!items || items.length === 0) { this.logger.debug( `Unable to adjust loot item ${itemTpl} as it does not exist inside ${locationName} forced loot.`, ); continue; } // Create probability array of all spawn positions for this spawn id const spawnpointArray = new ProbabilityObjectArray( this.mathUtil, this.jsonUtil, ); for (const si of items) { // use locationId as template.Id is the same across all items spawnpointArray.push(new ProbabilityObject(si.locationId, si.probability, si)); } // Choose 1 out of all found spawn positions for spawn id and add to loot array for (const spawnPointLocationId of spawnpointArray.draw(1, false)) { const itemToAdd = items.find((x) => x.locationId === spawnPointLocationId); const lootItem = itemToAdd.template; lootItem.Root = this.objectId.generate(); lootItem.Items[0]._id = lootItem.Root; lootLocationTemplates.push(lootItem); } } } const seasonalEventActive = this.seasonalEventService.seasonalEventEnabled(); const seasonalItemTplBlacklist = this.seasonalEventService.getInactiveSeasonalEventItems(); // Add remaining forced loot to array for (const forcedLootLocation of forcedSpawnPoints) { const firstLootItemTpl = forcedLootLocation.template.Items[0]._tpl; // Skip spawn positions processed already if (lootToForceSingleAmountOnMap?.includes(firstLootItemTpl)) { continue; } // Skip adding seasonal items when seasonal event is not active if (!seasonalEventActive && seasonalItemTplBlacklist.includes(firstLootItemTpl)) { continue; } const locationTemplateToAdd = forcedLootLocation.template; // Ensure root id matches the first items id locationTemplateToAdd.Root = this.objectId.generate(); locationTemplateToAdd.Items[0]._id = locationTemplateToAdd.Root; // Push forced location into array as long as it doesnt exist already const existingLocation = lootLocationTemplates.find((x) => x.Id === locationTemplateToAdd.Id); if (!existingLocation) { lootLocationTemplates.push(locationTemplateToAdd); } else { this.logger.debug( `Attempted to add a forced loot location with Id: ${locationTemplateToAdd.Id} to map ${locationName} that already has that id in use, skipping`, ); } } } /** * Create array of item (with child items) and return * @param chosenComposedKey Key we want to look up items for * @param spawnPoint Dynamic spawn point item we want will be placed in * @param staticAmmoDist ammo distributions * @returns IContainerItem */ protected createDynamicLootItem( chosenComposedKey: string, spawnPoint: Spawnpoint, staticAmmoDist: Record, ): IContainerItem { const chosenItem = spawnPoint.template.Items.find((x) => x._id === chosenComposedKey); const chosenTpl = chosenItem._tpl; const itemTemplate = this.itemHelper.getItem(chosenTpl)[1]; // Item array to return let itemWithMods: Item[] = []; // Money/Ammo - don't rely on items in spawnPoint.template.Items so we can randomise it ourselves if (this.itemHelper.isOfBaseclasses(chosenTpl, [BaseClasses.MONEY, BaseClasses.AMMO])) { const stackCount = itemTemplate._props.StackMaxSize === 1 ? 1 : this.randomUtil.getInt(itemTemplate._props.StackMinRandom, itemTemplate._props.StackMaxRandom); itemWithMods.push({ _id: this.objectId.generate(), _tpl: chosenTpl, upd: { StackObjectsCount: stackCount }, }); } else if (this.itemHelper.isOfBaseclass(chosenTpl, BaseClasses.AMMO_BOX)) { // Fill with cartridges const ammoBoxItem: Item[] = [{ _id: this.objectId.generate(), _tpl: chosenTpl }]; this.itemHelper.addCartridgesToAmmoBox(ammoBoxItem, itemTemplate); // ammo box template itemWithMods.push(...ammoBoxItem); } else if (this.itemHelper.isOfBaseclass(chosenTpl, BaseClasses.MAGAZINE)) { // Create array with just magazine const magazineItem: Item[] = [{ _id: this.objectId.generate(), _tpl: chosenTpl }]; if (this.randomUtil.getChance100(this.locationConfig.magazineLootHasAmmoChancePercent)) { // Add randomised amount of cartridges this.itemHelper.fillMagazineWithRandomCartridge( magazineItem, itemTemplate, // Magazine template staticAmmoDist, null, this.locationConfig.minFillLooseMagazinePercent / 100, ); } itemWithMods.push(...magazineItem); } else if (this.itemHelper.armorItemCanHoldMods(chosenTpl)) { itemWithMods.push({ _id: this.objectId.generate(), _tpl: chosenTpl, }); if (itemTemplate._props.Slots?.length > 0) { itemWithMods = this.itemHelper.addChildSlotItems(itemWithMods, itemTemplate, this.locationConfig.equipmentLootSettings.modSpawnChancePercent); } } else { // Get item + children and add into array we return const itemWithChildren = this.itemHelper.findAndReturnChildrenAsItems( spawnPoint.template.Items, chosenItem._id, ); // We need to reparent to ensure ids are unique this.reparentItemAndChildren(itemWithChildren); itemWithMods.push(...itemWithChildren); } // Get inventory size of item const size = this.itemHelper.getItemSize(itemWithMods, itemWithMods[0]._id); return { items: itemWithMods, width: size.width, height: size.height }; } /** * Replace the _id value for base item + all children items parentid value * @param itemWithChildren Item with mods to update * @param newId new id to add on chidren of base item */ protected reparentItemAndChildren(itemWithChildren: Item[], newId = this.objectId.generate()): void { // original id on base item const oldId = itemWithChildren[0]._id; // Update base item to use new id itemWithChildren[0]._id = newId; // Update all parentIds of items attached to base item to use new id for (const item of itemWithChildren) { if (item.parentId === oldId) { item.parentId = newId; } } } /** * Find an item in array by its _tpl, handle differently if chosenTpl is a weapon * @param items Items array to search * @param chosenTpl Tpl we want to get item with * @returns Item object */ protected getItemInArray(items: Item[], chosenTpl: string): Item { if (this.itemHelper.isOfBaseclass(chosenTpl, BaseClasses.WEAPON)) { return items.find((v) => v._tpl === chosenTpl && v.parentId === undefined); } return items.find((x) => x._tpl === chosenTpl); } // TODO: rewrite, BIG yikes protected createStaticLootItem( chosenTpl: string, staticAmmoDist: Record, parentId: string = undefined, ): IContainerItem { const itemTemplate = this.itemHelper.getItem(chosenTpl)[1]; let width = itemTemplate._props.Width; let height = itemTemplate._props.Height; let items: Item[] = [{ _id: this.objectId.generate(), _tpl: chosenTpl }]; // Use passed in parentId as override for new item if (parentId) { items[0].parentId = parentId; } if ( this.itemHelper.isOfBaseclass(chosenTpl, BaseClasses.MONEY) || this.itemHelper.isOfBaseclass(chosenTpl, BaseClasses.AMMO) ) { // Edge case - some ammos e.g. flares or M406 grenades shouldn't be stacked const stackCount = itemTemplate._props.StackMaxSize === 1 ? 1 : this.randomUtil.getInt(itemTemplate._props.StackMinRandom, itemTemplate._props.StackMaxRandom); items[0].upd = { StackObjectsCount: stackCount }; } // No spawn point, use default template else if (this.itemHelper.isOfBaseclass(chosenTpl, BaseClasses.WEAPON)) { let children: Item[] = []; const defaultPreset = this.jsonUtil.clone(this.presetHelper.getDefaultPreset(chosenTpl)); if (defaultPreset) { try { children = this.itemHelper.reparentItemAndChildren(defaultPreset._items[0], defaultPreset._items); } catch (error) { // this item already broke it once without being reproducible tpl = "5839a40f24597726f856b511"; AKS-74UB Default // 5ea03f7400685063ec28bfa8 // ppsh default // 5ba26383d4351e00334c93d9 //mp7_devgru this.logger.warning( this.localisationService.getText("location-preset_not_found", { tpl: chosenTpl, defaultId: defaultPreset._id, defaultName: defaultPreset._name, parentId: parentId, }), ); throw error; } } else { // RSP30 (62178be9d0050232da3485d9/624c0b3340357b5f566e8766/6217726288ed9f0845317459) doesnt have any default presets and kills this code below as it has no chidren to reparent this.logger.debug(`createItem() No preset found for weapon: ${chosenTpl}`); } const rootItem = items[0]; if (!rootItem) { this.logger.error( this.localisationService.getText("location-missing_root_item", { tpl: chosenTpl, parentId: parentId }), ); throw new Error(this.localisationService.getText("location-critical_error_see_log")); } try { if (children?.length > 0) { items = this.itemHelper.reparentItemAndChildren(rootItem, children); } } catch (error) { this.logger.error( this.localisationService.getText("location-unable_to_reparent_item", { tpl: chosenTpl, parentId: parentId, }), ); throw error; } // 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 const magazine = items.filter((x) => x.slotId === "mod_magazine")[0]; // some weapon presets come without magazine; only fill the mag if it exists if (magazine) { const magTemplate = this.itemHelper.getItem(magazine._tpl)[1]; const weaponTemplate = this.itemHelper.getItem(chosenTpl)[1]; // Create array with just magazine const magazineWithCartridges = [magazine]; this.itemHelper.fillMagazineWithRandomCartridge( magazineWithCartridges, magTemplate, staticAmmoDist, weaponTemplate._props.ammoCaliber, ); // Replace existing magazine with above array items.splice(items.indexOf(magazine), 1, ...magazineWithCartridges); } const size = this.itemHelper.getItemSize(items, rootItem._id); width = size.width; height = size.height; } // No spawnpoint to fall back on, generate manually else if (this.itemHelper.isOfBaseclass(chosenTpl, BaseClasses.AMMO_BOX)) { this.itemHelper.addCartridgesToAmmoBox(items, itemTemplate); } else if (this.itemHelper.isOfBaseclass(chosenTpl, BaseClasses.MAGAZINE)) { if (this.randomUtil.getChance100(this.locationConfig.magazineLootHasAmmoChancePercent)) { // Create array with just magazine const magazineWithCartridges = [items[0]]; this.itemHelper.fillMagazineWithRandomCartridge( magazineWithCartridges, itemTemplate, staticAmmoDist, null, this.locationConfig.minFillStaticMagazinePercent / 100, ); // Replace existing magazine with above array items.splice(items.indexOf(items[0]), 1, ...magazineWithCartridges); } } else if (this.itemHelper.armorItemCanHoldMods(chosenTpl)) { // We make base item above, at start of function, no need to do it here if (itemTemplate._props.Slots?.length > 0) { items = this.itemHelper.addChildSlotItems(items, itemTemplate, this.locationConfig.equipmentLootSettings.modSpawnChancePercent); } } return { items: items, width: width, height: height }; } }