import { inject, injectable } from "tsyringe"; import { ApplicationContext } from "@spt-aki/context/ApplicationContext"; import { ContextVariableType } from "@spt-aki/context/ContextVariableType"; import { ContainerHelper } from "@spt-aki/helpers/ContainerHelper"; import { DurabilityLimitsHelper } from "@spt-aki/helpers/DurabilityLimitsHelper"; import { InventoryHelper } from "@spt-aki/helpers/InventoryHelper"; import { ItemHelper } from "@spt-aki/helpers/ItemHelper"; import { Inventory } from "@spt-aki/models/eft/common/tables/IBotBase"; import { Item, Repairable, Upd } from "@spt-aki/models/eft/common/tables/IItem"; import { Grid, ITemplateItem } from "@spt-aki/models/eft/common/tables/ITemplateItem"; import { IGetRaidConfigurationRequestData } from "@spt-aki/models/eft/match/IGetRaidConfigurationRequestData"; import { BaseClasses } from "@spt-aki/models/enums/BaseClasses"; import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes"; import { ItemAddedResult } from "@spt-aki/models/enums/ItemAddedResult"; import { IChooseRandomCompatibleModResult } from "@spt-aki/models/spt/bots/IChooseRandomCompatibleModResult"; import { EquipmentFilters, IBotConfig, IRandomisedResourceValues } from "@spt-aki/models/spt/config/IBotConfig"; import { IPmcConfig } from "@spt-aki/models/spt/config/IPmcConfig"; 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 { RandomUtil } from "@spt-aki/utils/RandomUtil"; @injectable() export class BotGeneratorHelper { protected botConfig: IBotConfig; protected pmcConfig: IPmcConfig; constructor( @inject("WinstonLogger") protected logger: ILogger, @inject("RandomUtil") protected randomUtil: RandomUtil, @inject("DatabaseServer") protected databaseServer: DatabaseServer, @inject("DurabilityLimitsHelper") protected durabilityLimitsHelper: DurabilityLimitsHelper, @inject("ItemHelper") protected itemHelper: ItemHelper, @inject("InventoryHelper") protected inventoryHelper: InventoryHelper, @inject("ContainerHelper") protected containerHelper: ContainerHelper, @inject("ApplicationContext") protected applicationContext: ApplicationContext, @inject("LocalisationService") protected localisationService: LocalisationService, @inject("ConfigServer") protected configServer: ConfigServer, ) { this.botConfig = this.configServer.getConfig(ConfigTypes.BOT); this.pmcConfig = this.configServer.getConfig(ConfigTypes.PMC); } /** * Adds properties to an item * e.g. Repairable / HasHinge / Foldable / MaxDurability * @param itemTemplate Item extra properties are being generated for * @param botRole Used by weapons to randomize the durability values. Null for non-equipped items * @returns Item Upd object with extra properties */ public generateExtraPropertiesForItem(itemTemplate: ITemplateItem, botRole?: string): { upd?: Upd; } { // Get raid settings, if no raid, default to day const raidSettings = this.applicationContext.getLatestValue(ContextVariableType.RAID_CONFIGURATION)?.getValue< IGetRaidConfigurationRequestData >(); const raidIsNight = raidSettings?.timeVariant === "PAST"; const itemProperties: Upd = {}; if (itemTemplate._props.MaxDurability) { if (itemTemplate._props.weapClass) { // Is weapon itemProperties.Repairable = this.generateWeaponRepairableProperties(itemTemplate, botRole); } else if (itemTemplate._props.armorClass) { // Is armor itemProperties.Repairable = this.generateArmorRepairableProperties(itemTemplate, botRole); } } if (itemTemplate._props.HasHinge) { itemProperties.Togglable = { On: true }; } if (itemTemplate._props.Foldable) { itemProperties.Foldable = { Folded: false }; } if (itemTemplate._props.weapFireType?.length) { if (itemTemplate._props.weapFireType.includes("fullauto")) { itemProperties.FireMode = { FireMode: "fullauto" }; } else { itemProperties.FireMode = { FireMode: this.randomUtil.getArrayValue(itemTemplate._props.weapFireType) }; } } if (itemTemplate._props.MaxHpResource) { itemProperties.MedKit = { HpResource: this.getRandomizedResourceValue( itemTemplate._props.MaxHpResource, this.botConfig.lootItemResourceRandomization[botRole]?.meds, ), }; } if (itemTemplate._props.MaxResource && itemTemplate._props.foodUseTime) { itemProperties.FoodDrink = { HpPercent: this.getRandomizedResourceValue( itemTemplate._props.MaxResource, this.botConfig.lootItemResourceRandomization[botRole]?.food, ), }; } if (itemTemplate._parent === BaseClasses.FLASHLIGHT) { // Get chance from botconfig for bot type const lightLaserActiveChance = raidIsNight ? this.getBotEquipmentSettingFromConfig(botRole, "lightIsActiveNightChancePercent", 50) : this.getBotEquipmentSettingFromConfig(botRole, "lightIsActiveDayChancePercent", 25); itemProperties.Light = { IsActive: (this.randomUtil.getChance100(lightLaserActiveChance)), SelectedMode: 0, }; } else if (itemTemplate._parent === BaseClasses.TACTICAL_COMBO) { // Get chance from botconfig for bot type, use 50% if no value found const lightLaserActiveChance = this.getBotEquipmentSettingFromConfig( botRole, "laserIsActiveChancePercent", 50, ); itemProperties.Light = { IsActive: (this.randomUtil.getChance100(lightLaserActiveChance)), SelectedMode: 0, }; } if (itemTemplate._parent === BaseClasses.NIGHTVISION) { // Get chance from botconfig for bot type const nvgActiveChance = raidIsNight ? this.getBotEquipmentSettingFromConfig(botRole, "nvgIsActiveChanceNightPercent", 90) : this.getBotEquipmentSettingFromConfig(botRole, "nvgIsActiveChanceDayPercent", 15); itemProperties.Togglable = { On: (this.randomUtil.getChance100(nvgActiveChance)) }; } // Togglable face shield if (itemTemplate._props.HasHinge && itemTemplate._props.FaceShieldComponent) { // Get chance from botconfig for bot type, use 75% if no value found const faceShieldActiveChance = this.getBotEquipmentSettingFromConfig( botRole, "faceShieldIsActiveChancePercent", 75, ); itemProperties.Togglable = { On: (this.randomUtil.getChance100(faceShieldActiveChance)) }; } return Object.keys(itemProperties).length ? { upd: itemProperties } : {}; } /** * Randomize the HpResource for bots e.g (245/400 resources) * @param maxResource Max resource value of medical items * @param randomizationValues Value provided from config * @returns Randomized value from maxHpResource */ protected getRandomizedResourceValue(maxResource: number, randomizationValues: IRandomisedResourceValues): number { if (!randomizationValues) { return maxResource; } if (this.randomUtil.getChance100(randomizationValues.chanceMaxResourcePercent)) { return maxResource; } return this.randomUtil.getInt( this.randomUtil.getPercentOfValue(randomizationValues.resourcePercent, maxResource, 0), maxResource, ); } /** * Get the chance for the weapon attachment or helmet equipment to be set as activated * @param botRole role of bot with weapon/helmet * @param setting the setting of the weapon attachment/helmet equipment to be activated * @param defaultValue default value for the chance of activation if the botrole or bot equipment role is null * @returns Percent chance to be active */ protected getBotEquipmentSettingFromConfig( botRole: string, setting: keyof EquipmentFilters, defaultValue: number, ): number { if (!botRole) { return defaultValue; } const botEquipmentSettings = this.botConfig.equipment[this.getBotEquipmentRole(botRole)]; if (!botEquipmentSettings) { this.logger.warning( this.localisationService.getText("bot-missing_equipment_settings", { botRole: botRole, setting: setting, defaultValue: defaultValue, }), ); return defaultValue; } if (botEquipmentSettings[setting] === undefined || typeof botEquipmentSettings[setting] !== "number") { this.logger.warning( this.localisationService.getText("bot-missing_equipment_settings_property", { botRole: botRole, setting: setting, defaultValue: defaultValue, }), ); return defaultValue; } return botEquipmentSettings[setting]; } /** * Create a repairable object for a weapon that containers durability + max durability properties * @param itemTemplate weapon object being generated for * @param botRole type of bot being generated for * @returns Repairable object */ protected generateWeaponRepairableProperties(itemTemplate: ITemplateItem, botRole: string): Repairable { const maxDurability = this.durabilityLimitsHelper.getRandomizedMaxWeaponDurability(itemTemplate, botRole); const currentDurability = this.durabilityLimitsHelper.getRandomizedWeaponDurability( itemTemplate, botRole, maxDurability, ); return { Durability: currentDurability, MaxDurability: maxDurability }; } /** * Create a repairable object for an armor that containers durability + max durability properties * @param itemTemplate weapon object being generated for * @param botRole type of bot being generated for * @returns Repairable object */ protected generateArmorRepairableProperties(itemTemplate: ITemplateItem, botRole: string): Repairable { let maxDurability: number; let currentDurability: number; if (parseInt(`${itemTemplate._props.armorClass}`) === 0) { maxDurability = itemTemplate._props.MaxDurability; currentDurability = itemTemplate._props.MaxDurability; } else { maxDurability = this.durabilityLimitsHelper.getRandomizedMaxArmorDurability(itemTemplate, botRole); currentDurability = this.durabilityLimitsHelper.getRandomizedArmorDurability( itemTemplate, botRole, maxDurability, ); } return { Durability: currentDurability, MaxDurability: maxDurability }; } public isWeaponModIncompatibleWithCurrentMods( itemsEquipped: Item[], tplToCheck: string, modSlot: string, ): IChooseRandomCompatibleModResult { // TODO: Can probably be optimized to cache itemTemplates as items are added to inventory const equippedItemsDb = itemsEquipped.map((item) => this.databaseServer.getTables().templates.items[item._tpl]); const itemToEquipDb = this.itemHelper.getItem(tplToCheck); const itemToEquip = itemToEquipDb[1]; if (!itemToEquipDb[0]) { this.logger.warning( this.localisationService.getText("bot-invalid_item_compatibility_check", { itemTpl: tplToCheck, slot: modSlot, }), ); return { incompatible: true, found: false, reason: `item: ${tplToCheck} does not exist in the database` }; } // No props property if (!itemToEquip._props) { this.logger.warning( this.localisationService.getText("bot-compatibility_check_missing_props", { id: itemToEquip._id, name: itemToEquip._name, slot: modSlot, }), ); return { incompatible: true, found: false, reason: `item: ${tplToCheck} does not have a _props field` }; } // Check if any of the current weapon mod templates have the incoming item defined as incompatible const blockingItem = equippedItemsDb.find((x) => x._props.ConflictingItems?.includes(tplToCheck)); if (blockingItem) { return { incompatible: true, found: false, reason: `Cannot add: ${tplToCheck} ${itemToEquip._name} to slot: ${modSlot}. Blocked by: ${blockingItem._id} ${blockingItem._name}`, slotBlocked: true, }; } // Check inverse to above, if the incoming item has any existing mods in its conflicting items array const blockingModItem = itemsEquipped.find((item) => itemToEquip._props.ConflictingItems?.includes(item._tpl)); if (blockingModItem) { return { incompatible: true, found: false, reason: ` Cannot add: ${tplToCheck} to slot: ${modSlot}. Would block existing item: ${blockingModItem._tpl} in slot: ${blockingModItem.slotId}`, }; } return { incompatible: false, reason: "" }; } /** * Can item be added to another item without conflict * @param itemsEquipped Items to check compatibilities with * @param tplToCheck Tpl of the item to check for incompatibilities * @param equipmentSlot Slot the item will be placed into * @returns false if no incompatibilities, also has incompatibility reason */ public isItemIncompatibleWithCurrentItems( itemsEquipped: Item[], tplToCheck: string, equipmentSlot: string, ): IChooseRandomCompatibleModResult { // Skip slots that have no incompatibilities if (["Scabbard", "Backpack", "SecureContainer", "Holster", "ArmBand"].includes(equipmentSlot)) { return { incompatible: false, found: false, reason: "" }; } // TODO: Can probably be optimized to cache itemTemplates as items are added to inventory const equippedItemsDb = itemsEquipped.map((i) => this.databaseServer.getTables().templates.items[i._tpl]); const itemToEquipDb = this.itemHelper.getItem(tplToCheck); const itemToEquip = itemToEquipDb[1]; if (!itemToEquipDb[0]) { this.logger.warning( this.localisationService.getText("bot-invalid_item_compatibility_check", { itemTpl: tplToCheck, slot: equipmentSlot, }), ); return { incompatible: true, found: false, reason: `item: ${tplToCheck} does not exist in the database` }; } if (!itemToEquip._props) { this.logger.warning( this.localisationService.getText("bot-compatibility_check_missing_props", { id: itemToEquip._id, name: itemToEquip._name, slot: equipmentSlot, }), ); return { incompatible: true, found: false, reason: `item: ${tplToCheck} does not have a _props field` }; } // Does an equipped item have a property that blocks the desired item - check for prop "BlocksX" .e.g BlocksEarpiece / BlocksFaceCover let blockingItem = equippedItemsDb.find((x) => x._props[`Blocks${equipmentSlot}`]); if (blockingItem) { // this.logger.warning(`1 incompatibility found between - ${itemToEquip[1]._name} and ${blockingItem._name} - ${equipmentSlot}`); return { incompatible: true, found: false, reason: `${tplToCheck} ${itemToEquip._name} in slot: ${equipmentSlot} blocked by: ${blockingItem._id} ${blockingItem._name}`, slotBlocked: true, }; } // Check if any of the current inventory templates have the incoming item defined as incompatible blockingItem = equippedItemsDb.find((x) => x._props.ConflictingItems?.includes(tplToCheck)); if (blockingItem) { // this.logger.warning(`2 incompatibility found between - ${itemToEquip[1]._name} and ${blockingItem._props.Name} - ${equipmentSlot}`); return { incompatible: true, found: false, reason: `${tplToCheck} ${itemToEquip._name} in slot: ${equipmentSlot} blocked by: ${blockingItem._id} ${blockingItem._name}`, slotBlocked: true, }; } // Does item being checked get blocked/block existing item if (itemToEquip._props.BlocksHeadwear) { const existingHeadwear = itemsEquipped.find((x) => x.slotId === "Headwear"); if (existingHeadwear) { return { incompatible: true, found: false, reason: `${tplToCheck} ${itemToEquip._name} is blocked by: ${existingHeadwear._tpl} in slot: ${existingHeadwear.slotId}`, slotBlocked: true, }; } } // Does item being checked get blocked/block existing item if (itemToEquip._props.BlocksFaceCover) { const existingFaceCover = itemsEquipped.find((item) => item.slotId === "FaceCover"); if (existingFaceCover) { return { incompatible: true, found: false, reason: `${tplToCheck} ${itemToEquip._name} is blocked by: ${existingFaceCover._tpl} in slot: ${existingFaceCover.slotId}`, slotBlocked: true, }; } } // Does item being checked get blocked/block existing item if (itemToEquip._props.BlocksEarpiece) { const existingEarpiece = itemsEquipped.find((item) => item.slotId === "Earpiece"); if (existingEarpiece) { return { incompatible: true, found: false, reason: `${tplToCheck} ${itemToEquip._name} is blocked by: ${existingEarpiece._tpl} in slot: ${existingEarpiece.slotId}`, slotBlocked: true, }; } } // Does item being checked get blocked/block existing item if (itemToEquip._props.BlocksArmorVest) { const existingArmorVest = itemsEquipped.find((item) => item.slotId === "ArmorVest"); if (existingArmorVest) { return { incompatible: true, found: false, reason: `${tplToCheck} ${itemToEquip._name} is blocked by: ${existingArmorVest._tpl} in slot: ${existingArmorVest.slotId}`, slotBlocked: true, }; } } // Check if the incoming item has any inventory items defined as incompatible const blockingInventoryItem = itemsEquipped.find((x) => itemToEquip._props.ConflictingItems?.includes(x._tpl)); if (blockingInventoryItem) { // this.logger.warning(`3 incompatibility found between - ${itemToEquip[1]._name} and ${blockingInventoryItem._tpl} - ${equipmentSlot}`) return { incompatible: true, found: false, reason: `${tplToCheck} blocks existing item ${blockingInventoryItem._tpl} in slot ${blockingInventoryItem.slotId}`, }; } return { incompatible: false, reason: "" }; } /** * Convert a bots role to the equipment role used in config/bot.json * @param botRole Role to convert * @returns Equipment role (e.g. pmc / assault / bossTagilla) */ public getBotEquipmentRole(botRole: string): string { return ([this.pmcConfig.usecType.toLowerCase(), this.pmcConfig.bearType.toLowerCase()].includes( botRole.toLowerCase(), )) ? "pmc" : botRole; } /** * Adds an item with all its children into specified equipmentSlots, wherever it fits. * @param equipmentSlots Slot to add item+children into * @param rootItemId Root item id to use as mod items parentid * @param rootItemTplId Root itms tpl id * @param itemWithChildren Item to add * @param inventory Inventory to add item+children into * @returns ItemAddedResult result object */ public addItemWithChildrenToEquipmentSlot( equipmentSlots: string[], rootItemId: string, rootItemTplId: string, itemWithChildren: Item[], inventory: Inventory, ): ItemAddedResult { /** Track how many containers are unable to be found */ let missingContainerCount = 0; for (const equipmentSlotId of equipmentSlots) { // Get container to put item into const container = inventory.items.find((item) => item.slotId === equipmentSlotId); if (!container) { missingContainerCount++; if (missingContainerCount === equipmentSlots.length) { // Bot doesnt have any containers we want to add item to this.logger.debug( `Unable to add item: ${itemWithChildren[0]._tpl} to bot as it lacks the following containers: ${ equipmentSlots.join(",") }`, ); return ItemAddedResult.NO_CONTAINERS; } // No container of desired type found, skip to next container type continue; } // Get container details from db const containerTemplate = this.itemHelper.getItem(container._tpl); if (!containerTemplate[0]) { this.logger.warning(this.localisationService.getText("bot-missing_container_with_tpl", container._tpl)); // Bad item, skip continue; } if (!containerTemplate[1]._props.Grids?.length) { // Container has no slots to hold items continue; } // Get x/y grid size of item const itemSize = this.inventoryHelper.getItemSize(rootItemTplId, rootItemId, itemWithChildren); // Iterate over each grid in the container and look for a big enough space for the item to be placed in let currentGridCount = 1; const totalSlotGridCount = containerTemplate[1]._props.Grids.length; for (const slotGrid of containerTemplate[1]._props.Grids) { // Grid is empty, skip if (slotGrid._props.cellsH === 0 || slotGrid._props.cellsV === 0) { continue; } // Can't put item type in grid, skip all grids as we're assuming they have the same rules if (!this.itemAllowedInContainer(slotGrid, rootItemTplId)) { // Only one possible slot and item is incompatible, exit function and inform caller if (equipmentSlots.length === 1) { return ItemAddedResult.INCOMPATIBLE_ITEM; } // Multiple containers, maybe next one allows item, only break out of loop for this containers grids break; } // Get all root items in found container const existingContainerItems = inventory.items.filter((item) => item.parentId === container._id && item.slotId === slotGrid._name ); // Get root items in container we can iterate over to find out what space is free const containerItemsToCheck = existingContainerItems.filter((x) => x.slotId === slotGrid._name); for (const item of containerItemsToCheck) { // Look for children on items, insert into array if found // (used later when figuring out how much space weapon takes up) const itemWithChildren = this.itemHelper.findAndReturnChildrenAsItems(inventory.items, item._id); if (itemWithChildren.length > 1) { existingContainerItems.splice(existingContainerItems.indexOf(item), 1, ...itemWithChildren); } } // Get rid of items free/used spots in current grid const slotGridMap = this.inventoryHelper.getContainerMap( slotGrid._props.cellsH, slotGrid._props.cellsV, existingContainerItems, container._id, ); // Try to fit item into grid const findSlotResult = this.containerHelper.findSlotForItem(slotGridMap, itemSize[0], itemSize[1]); // Open slot found, add item to inventory if (findSlotResult.success) { const parentItem = itemWithChildren.find((i) => i._id === rootItemId); // Set items parent to container id parentItem.parentId = container._id; parentItem.slotId = slotGrid._name; parentItem.location = { x: findSlotResult.x, y: findSlotResult.y, r: findSlotResult.rotation ? 1 : 0, }; inventory.items.push(...itemWithChildren); return ItemAddedResult.SUCCESS; } // If we've checked all grids in container and reached this point, there's no space for item if (currentGridCount >= totalSlotGridCount) { return ItemAddedResult.NO_SPACE; } currentGridCount++; // No space in this grid, move to next container grid and try again } } return ItemAddedResult.UNKNOWN; } /** * Is the provided item allowed inside a container * @param slotGrid Items sub-grid we want to place item inside * @param itemTpl Item tpl being placed * @returns True if allowed */ protected itemAllowedInContainer(slotGrid: Grid, itemTpl: string): boolean { const propFilters = slotGrid._props.filters; const excludedFilter = propFilters[0]?.ExcludedFilter; const filter = propFilters[0]?.Filter; if (propFilters.length === 0) { // no filters, item is fine to add return true; } // Check if item base type is excluded if (excludedFilter || filter) { const itemDetails = this.itemHelper.getItem(itemTpl)[1]; // if item to add is found in exclude filter, not allowed if (excludedFilter.includes(itemDetails._parent)) { return false; } // If Filter array only contains 1 filter and its for basetype 'item', allow it if (filter.length === 1 && filter.includes(BaseClasses.ITEM)) { return true; } // If allowed filter has something in it + filter doesnt have basetype 'item', not allowed if (filter.length > 0 && !filter.includes(itemDetails._parent)) { return false; } } return true; } }