using Core.Helpers; using Core.Models.Common; using Core.Models.Eft.Common; using Core.Models.Eft.Common.Tables; using Core.Models.Enums; using Core.Models.Spt.Bots; using Core.Models.Spt.Config; using Core.Models.Utils; using Core.Servers; using Core.Services; using Core.Utils; using Core.Utils.Cloners; using SptCommon.Annotations; using BodyPart = Core.Models.Eft.Common.Tables.BodyPart; using LogLevel = Core.Models.Spt.Logging.LogLevel; namespace Core.Generators; [Injectable] public class BotGenerator( ISptLogger _logger, HashUtil _hashUtil, RandomUtil _randomUtil, TimeUtil _timeUtil, ProfileHelper _profileHelper, DatabaseService _databaseService, BotInventoryGenerator _botInventoryGenerator, BotLevelGenerator _botLevelGenerator, BotEquipmentFilterService _botEquipmentFilterService, WeightedRandomHelper _weightedRandomHelper, BotHelper _botHelper, BotGeneratorHelper _botGeneratorHelper, SeasonalEventService _seasonalEventService, ItemFilterService _itemFilterService, BotNameService _botNameService, ConfigServer _configServer, ICloner _cloner ) { protected BotConfig _botConfig = _configServer.GetConfig(); protected PmcConfig _pmcConfig = _configServer.GetConfig(); /// /// Generate a player scav bot object /// /// Session id /// e.g. assault / pmcbot /// easy/normal/hard/impossible /// base bot template to use (e.g. assault/pmcbot) /// profile of player generating pscav /// BotBase public PmcData GeneratePlayerScav(string sessionId, string role, string difficulty, BotType botTemplate, PmcData profile) { var bot = GetCloneOfBotBase(); bot.Info.Settings.BotDifficulty = difficulty; bot.Info.Settings.Role = role; bot.Info.Side = "Savage"; var botGenDetails = new BotGenerationDetails { IsPmc = false, Side = "Savage", Role = role, BotRelativeLevelDeltaMax = 0, BotRelativeLevelDeltaMin = 0, BotCountToGenerate = 1, BotDifficulty = difficulty, IsPlayerScav = true }; bot = GenerateBot(sessionId, bot, botTemplate, botGenDetails); // Sets the name after scav name shown in parentheses bot.Info.MainProfileNickname = profile.Info.Nickname; return new PmcData { Id = bot.Id, Aid = bot.Aid, SessionId = bot.SessionId, Savage = null, KarmaValue = bot.KarmaValue, Info = bot.Info, Customization = bot.Customization, Health = bot.Health, Inventory = bot.Inventory, Skills = bot.Skills, Stats = bot.Stats, Encyclopedia = bot.Encyclopedia, TaskConditionCounters = bot.TaskConditionCounters, InsuredItems = bot.InsuredItems, Hideout = bot.Hideout, Quests = bot.Quests, TradersInfo = bot.TradersInfo, UnlockedInfo = bot.UnlockedInfo, RagfairInfo = bot.RagfairInfo, Achievements = bot.Achievements, RepeatableQuests = bot.RepeatableQuests, Bonuses = bot.Bonuses, Notes = bot.Notes, CarExtractCounts = bot.CarExtractCounts, CoopExtractCounts = bot.CoopExtractCounts, SurvivorClass = bot.SurvivorClass, WishList = bot.WishList, MoneyTransferLimitData = bot.MoneyTransferLimitData, IsPmc = bot.IsPmc, Prestige = new Dictionary() }; } /// /// Create 1 bot of the type/side/difficulty defined in botGenerationDetails /// /// Session id /// details on how to generate bots /// constructed bot public BotBase PrepareAndGenerateBot(string sessionId, BotGenerationDetails? botGenerationDetails) { var preparedBotBase = GetPreparedBotBase( botGenerationDetails.EventRole ?? botGenerationDetails.Role, // Use eventRole if provided, botGenerationDetails.Side, botGenerationDetails.BotDifficulty ); // Get raw json data for bot (Cloned) var botRole = botGenerationDetails.IsPmc ?? false ? preparedBotBase.Info.Side // Use side to get usec.json or bear.json when bot will be PMC : botGenerationDetails.Role; var botJsonTemplateClone = _cloner.Clone(_botHelper.GetBotTemplate(botRole)); if (botJsonTemplateClone is null) { _logger.Error($"Unable to retrieve: {botRole} bot template, cannot generate bot of this type"); } return GenerateBot(sessionId, preparedBotBase, botJsonTemplateClone, botGenerationDetails); } /// /// Get a clone of the default bot base object and adjust its role/side/difficulty values /// /// Role bot should have /// Side bot should have /// Difficult bot should have /// Cloned bot base public BotBase GetPreparedBotBase(string botRole, string botSide, string difficulty) { var botBaseClone = GetCloneOfBotBase(); botBaseClone.Info.Settings.Role = botRole; botBaseClone.Info.Side = botSide; botBaseClone.Info.Settings.BotDifficulty = difficulty; return botBaseClone; } /// /// Get a clone of the database\bots\base.json file /// /// BotBase object public BotBase GetCloneOfBotBase() { return _cloner.Clone(_databaseService.GetBots().Base); } /// /// Create a IBotBase object with equipment/loot/exp etc /// /// Session id /// Bots base file /// Bot template from db/bots/x.json /// details on how to generate the bot /// BotBase object public BotBase GenerateBot( string sessionId, BotBase bot, BotType botJsonTemplate, BotGenerationDetails botGenerationDetails) { var botRoleLowercase = botGenerationDetails.Role.ToLower(); var botLevel = _botLevelGenerator.GenerateBotLevel( botJsonTemplate.BotExperience.Level, botGenerationDetails, bot ); // Only filter bot equipment, never players if (!botGenerationDetails.IsPlayerScav.GetValueOrDefault(false)) { _botEquipmentFilterService.FilterBotEquipment( sessionId, botJsonTemplate, botLevel.Level.Value, botGenerationDetails ); } bot.Info.Nickname = _botNameService.GenerateUniqueBotNickname( botJsonTemplate, botGenerationDetails, botRoleLowercase, _botConfig.BotRolesThatMustHaveUniqueName ); // Only Pmcs should have a lower nickname bot.Info.LowerNickname = botGenerationDetails.IsPmc.GetValueOrDefault(false) ? bot.Info.Nickname.ToLower() : string.Empty; // Only run when generating a 'fake' playerscav, not actual player scav if (!botGenerationDetails.IsPlayerScav.GetValueOrDefault(false) && ShouldSimulatePlayerScav(botRoleLowercase)) { _botNameService.AddRandomPmcNameToBotMainProfileNicknameProperty(bot); SetRandomisedGameVersionAndCategory(bot.Info); } if (!_seasonalEventService.ChristmasEventEnabled()) // Process all bots EXCEPT gifter, he needs christmas items { if (botGenerationDetails.Role != "gifter") { _seasonalEventService.RemoveChristmasItemsFromBotInventory( botJsonTemplate.BotInventory, botGenerationDetails.Role ); } } RemoveBlacklistedLootFromBotTemplate(botJsonTemplate.BotInventory); // Remove hideout data if bot is not a PMC or pscav - match what live sends if (!(botGenerationDetails.IsPmc.GetValueOrDefault(false) || botGenerationDetails.IsPlayerScav.GetValueOrDefault(false))) { bot.Hideout = null; } bot.Info.Experience = botLevel.Exp; bot.Info.Level = botLevel.Level; bot.Info.Settings.Experience = GetExperienceRewardForKillByDifficulty( botJsonTemplate.BotExperience.Reward, botGenerationDetails.BotDifficulty, botGenerationDetails.Role ); bot.Info.Settings.StandingForKill = GetStandingChangeForKillByDifficulty( botJsonTemplate.BotExperience.StandingForKill, botGenerationDetails.BotDifficulty, botGenerationDetails.Role ); bot.Info.Settings.AggressorBonus = GetAgressorBonusByDifficulty( botJsonTemplate.BotExperience.StandingForKill, botGenerationDetails.BotDifficulty, botGenerationDetails.Role ); bot.Info.Settings.UseSimpleAnimator = botJsonTemplate.BotExperience.UseSimpleAnimator ?? false; bot.Info.Voice = _weightedRandomHelper.GetWeightedValue(botJsonTemplate.BotAppearance.Voice); bot.Health = GenerateHealth(botJsonTemplate.BotHealth, botGenerationDetails.IsPlayerScav.GetValueOrDefault(false)); bot.Skills = GenerateSkills(botJsonTemplate.BotSkills); bot.Info.PrestigeLevel = 0; if (botGenerationDetails.IsPmc.GetValueOrDefault(false)) { bot.Info.IsStreamerModeAvailable = true; // Set to true so client patches can pick it up later - client sometimes alters botrole to assaultGroup SetRandomisedGameVersionAndCategory(bot.Info); if (bot.Info.GameVersion == GameEditions.UNHEARD) { AddAdditionalPocketLootWeightsForUnheardBot(botJsonTemplate); } } // Add drip SetBotAppearance(bot, botJsonTemplate.BotAppearance, botGenerationDetails); // Filter out blacklisted gear from the base template FilterBlacklistedGear(botJsonTemplate, botGenerationDetails); bot.Inventory = _botInventoryGenerator.GenerateInventory( sessionId, botJsonTemplate, botRoleLowercase, botGenerationDetails.IsPmc.GetValueOrDefault(false), bot.Info.Level.Value, bot.Info.GameVersion ); if (_botConfig.BotRolesWithDogTags.Contains(botRoleLowercase)) { AddDogtagToBot(bot); } // Generate new bot ID AddIdsToBot(bot, botGenerationDetails); // Generate new inventory ID GenerateInventoryId(bot); // Set role back to originally requested now its been generated if (botGenerationDetails.EventRole is not null) { bot.Info.Settings.Role = botGenerationDetails.EventRole; } return bot; } /// /// Should this bot have a name like "name (Pmc Name)" and be altered by client patch to be hostile to player /// /// Role bot has /// True if name should be simulated pscav public bool ShouldSimulatePlayerScav(string botRole) { return botRole == "assault" && _randomUtil.GetChance100(_botConfig.ChanceAssaultScavHasPlayerScavName); } /// /// Get exp for kill by bot difficulty /// /// Dict of difficulties and experience /// the killed bots difficulty /// Role of bot (optional, used for error logging) /// Experience for kill public int GetExperienceRewardForKillByDifficulty(Dictionary> experiences, string botDifficulty, string role) { if (!experiences.TryGetValue(botDifficulty.ToLower(), out var result)) { if (_logger.IsLogEnabled(LogLevel.Debug)) { _logger.Debug($"Unable to find experience: {botDifficulty} for {role} bot, falling back to `normal`"); } return _randomUtil.GetInt(experiences["normal"].Min, experiences["normal"].Max); } return _randomUtil.GetInt(result.Min, result.Max); } /// /// Get the standing value change when player kills a bot /// /// Dictionary of standing values keyed by bot difficulty /// Difficulty of bot to look up /// Role of bot (optional, used for error logging) /// Standing change value public double GetStandingChangeForKillByDifficulty(Dictionary standingsForKill, string botDifficulty, string role) { if (!standingsForKill.TryGetValue(botDifficulty.ToLower(), out var result)) { _logger.Warning($"Unable to find standing for kill value for: {role} {botDifficulty}, falling back to `normal`"); return standingsForKill["normal"]; } return result; } /// /// Get the agressor bonus value when player kills a bot /// /// Dictionary of standing values keyed by bot difficulty /// Difficulty of bot to look up /// Role of bot (optional, used for error logging) /// Standing change value public double GetAgressorBonusByDifficulty(Dictionary aggressorBonuses, string botDifficulty, string role) { if (!aggressorBonuses.TryGetValue(botDifficulty.ToLower(), out var result)) { _logger.Warning($"Unable to find aggressor bonus for kill value for: {role} {botDifficulty}, falling back to `normal`"); return aggressorBonuses["normal"]; } return result; } /// /// Set weighting of flagged equipment to 0 /// /// Bot data to adjust /// Generation details of bot public void FilterBlacklistedGear(BotType botJsonTemplate, BotGenerationDetails botGenerationDetails) { var blacklist = _botEquipmentFilterService.GetBotEquipmentBlacklist( _botGeneratorHelper.GetBotEquipmentRole(botGenerationDetails.Role), botGenerationDetails.PlayerLevel.GetValueOrDefault(1) ); if (blacklist?.Gear is null) // Nothing to filter by { return; } foreach (var (equipmentSlot, blacklistedTpls) in blacklist.Gear) { var equipmentDict = botJsonTemplate.BotInventory.Equipment[equipmentSlot]; foreach (var blacklistedTpl in blacklistedTpls) // Set weighting to 0, will never be picked { equipmentDict[blacklistedTpl] = 0; } } } /// /// TODO: Complete Summary /// /// Bot data to adjust public void AddAdditionalPocketLootWeightsForUnheardBot(BotType botJsonTemplate) { // Adjust pocket loot weights to allow for 5 or 6 items var pocketWeights = botJsonTemplate.BotGeneration.Items.PocketLoot.Weights; pocketWeights[5] = 1; pocketWeights[6] = 1; } /// /// Remove items from item.json/lootableItemBlacklist from bots inventory /// /// Bot to filter public void RemoveBlacklistedLootFromBotTemplate(BotTypeInventory botInventory) { List lootContainersToFilter = ["Backpack", "Pockets", "TacticalVest"]; var props = botInventory.Items.GetType().GetProperties(); // Remove blacklisted loot from loot containers foreach (var lootContainerKey in lootContainersToFilter) { var prop = props.FirstOrDefault(x => string.Equals(x.Name, lootContainerKey, StringComparison.CurrentCultureIgnoreCase)); var propValue = (Dictionary) prop.GetValue(botInventory.Items); // No container, skip if (propValue?.Count == 0) { continue; } List tplsToRemove = []; foreach (var (key, _) in propValue) { if (_itemFilterService.IsLootableItemBlacklisted(key)) { tplsToRemove.Add(key); } } foreach (var blacklistedTplToRemove in tplsToRemove) { propValue.Remove(blacklistedTplToRemove); } prop.SetValue(botInventory.Items, propValue); } } /// /// Choose various appearance settings for a bot using weights: head/body/feet/hands /// /// Bot to adjust /// Appearance settings to choose from /// Generation details public void SetBotAppearance(BotBase bot, Appearance appearance, BotGenerationDetails botGenerationDetails) { // Choose random values by weight bot.Customization.Head = _weightedRandomHelper.GetWeightedValue(appearance.Head); bot.Customization.Feet = _weightedRandomHelper.GetWeightedValue(appearance.Feet); bot.Customization.Body = _weightedRandomHelper.GetWeightedValue(appearance.Body); var bodyGlobalDictDb = _databaseService.GetGlobals().Configuration.Customization.Body; var chosenBodyTemplate = _databaseService.GetCustomization()[bot.Customization.Body]; // Some bodies have matching hands, look up body to see if this is the case var chosenBody = bodyGlobalDictDb.FirstOrDefault(c => c.Key == chosenBodyTemplate?.Name.Trim()); bot.Customization.Hands = chosenBody.Value?.IsNotRandom ?? false ? chosenBody.Value.Hands // Has fixed hands for chosen body, update to match : _weightedRandomHelper.GetWeightedValue(appearance.Hands); // Hands can be random, choose any from weighted dict } /// /// Log the number of PMCs generated to the debug console /// /// Generated bot array, ready to send to client public void LogPmcGeneratedCount(List output) { var pmcCount = output.Aggregate( 0, (acc, cur) => { return cur.Info.Side is "Bear" or "Usec" ? acc + 1 : acc; } ); if (_logger.IsLogEnabled(LogLevel.Debug)) { _logger.Debug($"Generated {output.Count} total bots. Replaced {pmcCount} with PMCs"); } } /// /// Converts health object to the required format /// /// health object from bot json /// Is a pscav bot being generated /// Health object public BotBaseHealth GenerateHealth(BotTypeHealth healthObj, bool playerScav = false) { var bodyParts = playerScav ? GetLowestHpBody(healthObj.BodyParts) : _randomUtil.GetArrayValue(healthObj.BodyParts); BotBaseHealth health = new() { Hydration = new CurrentMinMax { Current = _randomUtil.GetDouble(healthObj.Hydration.Min, healthObj.Hydration.Max), Maximum = healthObj.Hydration.Max }, Energy = new CurrentMinMax { Current = _randomUtil.GetDouble(healthObj.Energy.Min, healthObj.Energy.Max), Maximum = healthObj.Energy.Max }, Temperature = new CurrentMinMax { Current = _randomUtil.GetDouble(healthObj.Temperature.Min, healthObj.Temperature.Max), Maximum = healthObj.Temperature.Max }, BodyParts = new Dictionary { { "Head", new BodyPartHealth { Health = new CurrentMinMax { Current = _randomUtil.GetDouble(bodyParts.Head.Min, bodyParts.Head.Max), Maximum = (double)Math.Round(bodyParts.Head.Max) } } }, { "Chest", new BodyPartHealth { Health = new CurrentMinMax { Current = _randomUtil.GetDouble(bodyParts.Chest.Min, bodyParts.Chest.Max), Maximum = (double)Math.Round(bodyParts.Chest.Max) } } }, { "Stomach", new BodyPartHealth { Health = new CurrentMinMax { Current = _randomUtil.GetDouble(bodyParts.Stomach.Min, bodyParts.Stomach.Max), Maximum = Math.Round(bodyParts.Stomach.Max) } } }, { "LeftArm", new BodyPartHealth { Health = new CurrentMinMax { Current = _randomUtil.GetDouble(bodyParts.LeftArm.Min, bodyParts.LeftArm.Max), Maximum = Math.Round(bodyParts.LeftArm.Max) } } }, { "RightArm", new BodyPartHealth { Health = new CurrentMinMax { Current = _randomUtil.GetDouble(bodyParts.RightArm.Min, bodyParts.RightArm.Max), Maximum = Math.Round(bodyParts.RightArm.Max) } } }, { "LeftLeg", new BodyPartHealth { Health = new CurrentMinMax { Current = _randomUtil.GetDouble(bodyParts.LeftLeg.Min, bodyParts.LeftLeg.Max), Maximum = Math.Round(bodyParts.LeftLeg.Max) } } }, { "RightLeg", new BodyPartHealth { Health = new CurrentMinMax { Current = _randomUtil.GetDouble(bodyParts.RightLeg.Min, bodyParts.RightLeg.Max), Maximum = Math.Round(bodyParts.RightLeg.Max) } } } }, UpdateTime = 0, // 0 for player-scav too Immortal = false }; return health; } /// /// Sum up body parts max hp values, return the bodyPart collection with lowest value /// /// Body parts to sum up /// Lowest hp collection public BodyPart? GetLowestHpBody(List bodies) { if (bodies.Count == 0) { return null; } BodyPart result = new(); var props = result.GetType().GetProperties(); double? currentHighest = double.MaxValue; foreach (var bodyPart in bodies) { double? hpTotal = 0; foreach (var prop in props) { var value = (MinMax) prop.GetValue(bodyPart); hpTotal += value.Max; } if (hpTotal < currentHighest) { // Found collection with lower value that previous, use it currentHighest = hpTotal; result = bodyPart; } } return result; } /// /// Get a bots skills with randomsied progress value between the min and max values /// /// Skills that should have their progress value randomised /// Skills public Skills GenerateSkills(BotDbSkills botSkills) { var skillsToReturn = new Skills { Common = GetSkillsWithRandomisedProgressValue(botSkills.Common, true), Mastering = GetSkillsWithRandomisedProgressValue(botSkills.Mastering, false), Points = 0 }; return skillsToReturn; } /// /// Randomise the progress value of passed in skills based on the min/max value /// /// Skills to randomise /// Are the skills 'common' skills /// Skills with randomised progress values as an array public List GetSkillsWithRandomisedProgressValue(Dictionary>? skills, bool isCommonSkills) { if (skills is null) { return []; } return skills.Select( kvp => { // Get skill from dict, skip if not found var skill = kvp.Value; if (skill == null) { return null; } // All skills have id and progress props var skillToAdd = new BaseSkill { Id = kvp.Key, Progress = _randomUtil.GetDouble(skill.Min, skill.Max) }; // Common skills have additional props if (isCommonSkills) { skillToAdd.PointsEarnedDuringSession = 0; skillToAdd.LastAccess = 0; } return skillToAdd; } ) .Where(baseSkill => baseSkill != null) .ToList(); } /// /// Generate an id+aid for a bot and apply /// /// bot to update /// /// public void AddIdsToBot(BotBase bot, BotGenerationDetails botGenerationDetails) { var botId = _hashUtil.Generate(); bot.Id = botId; bot.Aid = botGenerationDetails.IsPmc.GetValueOrDefault(false) ? _hashUtil.GenerateAccountId() : 0; } /// /// Update a profiles profile.Inventory.equipment value with a freshly generated one. /// Update all inventory items that make use of this value too. /// /// Profile to update public void GenerateInventoryId(BotBase profile) { var newInventoryItemId = _hashUtil.Generate(); foreach (var item in profile.Inventory.Items) { // Root item found, update its _id value to newly generated id if (item.Template == ItemTpl.INVENTORY_DEFAULT) { item.Id = newInventoryItemId; continue; } // Optimisation - skip items without a parentId // They are never linked to root inventory item + we already handled root item above if (item.ParentId is null) { continue; } // Item is a child of root inventory item, update its parentId value to newly generated id if (item.ParentId == profile.Inventory.Equipment) { item.ParentId = newInventoryItemId; } } // Update inventory equipment id to new one we generated profile.Inventory.Equipment = newInventoryItemId; } /// /// Randomise a bots game version and account category. /// Chooses from all the game versions (standard, eod etc). /// Chooses account type (default, Sherpa, etc). /// /// bot info object to update /// Chosen game version public string SetRandomisedGameVersionAndCategory(Info botInfo) { // Special case if (string.Equals(botInfo.Nickname, "nikita", StringComparison.OrdinalIgnoreCase)) { botInfo.GameVersion = GameEditions.UNHEARD; botInfo.MemberCategory = MemberCategory.Developer; return botInfo.GameVersion; } // Choose random weighted game version for bot botInfo.GameVersion = _weightedRandomHelper.GetWeightedValue(_pmcConfig.GameVersionWeight); // Choose appropriate member category value switch (botInfo.GameVersion) { case GameEditions.EDGE_OF_DARKNESS: botInfo.MemberCategory = MemberCategory.UniqueId; break; case GameEditions.UNHEARD: botInfo.MemberCategory = MemberCategory.Unheard; break; default: // Everyone else gets a weighted randomised category botInfo.MemberCategory = _weightedRandomHelper.GetWeightedValue(_pmcConfig.AccountTypeWeight); break; } // Ensure selected category matches botInfo.SelectedMemberCategory = botInfo.MemberCategory; return botInfo.GameVersion; } /// /// Add a side-specific (usec/bear) dogtag item to a bots inventory /// /// bot to add dogtag to /// public void AddDogtagToBot(BotBase bot) { Item inventoryItem = new() { Id = _hashUtil.Generate(), Template = GetDogtagTplByGameVersionAndSide(bot.Info.Side, bot.Info.GameVersion), ParentId = bot.Inventory.Equipment, SlotId = "Dogtag", Upd = new Upd { SpawnedInSession = true } }; bot.Inventory.Items.Add(inventoryItem); } /// /// Get a dogtag tpl that matches the bots game version and side /// /// Usec/Bear /// edge_of_darkness / standard /// item tpl public string GetDogtagTplByGameVersionAndSide(string side, string gameVersion) { if (string.Equals(side, "usec", StringComparison.OrdinalIgnoreCase)) { switch (gameVersion) { case GameEditions.EDGE_OF_DARKNESS: return ItemTpl.BARTER_DOGTAG_USEC_EOD; case GameEditions.UNHEARD: return ItemTpl.BARTER_DOGTAG_USEC_TUE; default: return ItemTpl.BARTER_DOGTAG_USEC; } } switch (gameVersion) { case GameEditions.EDGE_OF_DARKNESS: return ItemTpl.BARTER_DOGTAG_BEAR_EOD; case GameEditions.UNHEARD: return ItemTpl.BARTER_DOGTAG_BEAR_TUE; default: return ItemTpl.BARTER_DOGTAG_BEAR; } } /// /// Adjust a PMCs pocket tpl to UHD if necessary, otherwise do nothing /// /// Pmc object to adjust public void SetPmcPocketsByGameVersion(BotBase bot) { if (bot.Info.GameVersion == GameEditions.UNHEARD) { var pockets = bot.Inventory.Items.FirstOrDefault(item => item.SlotId == "Pockets"); pockets.Template = ItemTpl.POCKETS_1X4_TUE; } } }