using System.Diagnostics; using System.Text.Json.Serialization; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Constants; using SPTarkov.Server.Core.Generators; using SPTarkov.Server.Core.Helpers; using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Eft.Bot; using SPTarkov.Server.Core.Models.Eft.Common; using SPTarkov.Server.Core.Models.Eft.Common.Tables; using SPTarkov.Server.Core.Models.Eft.Match; using SPTarkov.Server.Core.Models.Spt.Bots; 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 LogLevel = SPTarkov.Server.Core.Models.Spt.Logging.LogLevel; namespace SPTarkov.Server.Core.Controllers; [Injectable] public class BotController( ISptLogger _logger, DatabaseService _databaseService, BotGenerator _botGenerator, BotHelper _botHelper, BotDifficultyHelper _botDifficultyHelper, LocalisationService _localisationService, SeasonalEventService _seasonalEventService, MatchBotDetailsCacheService _matchBotDetailsCacheService, ProfileHelper _profileHelper, ConfigServer _configServer, ProfileActivityService _profileActivityService, RandomUtil _randomUtil, ICloner _cloner ) { private readonly BotConfig _botConfig = _configServer.GetConfig(); private readonly PmcConfig _pmcConfig = _configServer.GetConfig(); private static readonly Lock _botListLock = new(); /// /// Return the number of bot load-out varieties to be generated /// /// bot Type we want the load-out gen count for /// number of bots to generate public int GetBotPresetGenerationLimit(string type) { if (!_botConfig.PresetBatch.TryGetValue(type, out var limit)) { _logger.Warning( _localisationService.GetText("bot-bot_preset_count_value_missing", type) ); return 10; } return limit; } /// /// Handle singleplayer/settings/bot/difficulty /// Get the core.json difficulty settings from database/bots /// /// public Dictionary GetBotCoreDifficulty() { return _databaseService.GetBots().Core!; } /// /// Get bot difficulty settings /// Adjust PMC settings to ensure they engage the correct bot types /// /// Which user is requesting his bot settings /// what bot the server is requesting settings for /// difficulty level server requested settings for /// OPTIONAL - applicationContext Data stored at start of raid /// OPTIONAL - should raid settings chosen pre-raid be ignored /// Difficulty object public DifficultyCategories GetBotDifficulty( string sessionId, string type, string diffLevel, bool ignoreRaidSettings = false ) { var difficulty = diffLevel.ToLower(); var raidConfig = _profileActivityService .GetProfileActivityRaidData(sessionId) ?.RaidConfiguration; if (!(raidConfig != null || ignoreRaidSettings)) { _logger.Error( _localisationService.GetText( "bot-missing_application_context", "RAID_CONFIGURATION" ) ); } // Check value chosen in pre-raid difficulty dropdown // If value is not 'asonline', change requested difficulty to be what was chosen in dropdown var botDifficultyDropDownValue = raidConfig?.WavesSettings?.BotDifficulty?.ToString().ToLower() ?? "asonline"; if (botDifficultyDropDownValue != "asonline") { difficulty = _botDifficultyHelper.ConvertBotDifficultyDropdownToBotDifficulty( botDifficultyDropDownValue ); } var botDb = _databaseService.GetBots(); return _botDifficultyHelper.GetBotDifficultySettings(type, difficulty, botDb); } /// /// Handle singleplayer/settings/bot/difficulties /// /// public Dictionary> GetAllBotDifficulties() { var result = new Dictionary>(); var botTypesDb = _databaseService.GetBots().Types; if (botTypesDb is null) { return result; } //Get all bot types as sting array var botTypes = Enum.GetValues(); foreach (var botType in botTypes) { // If bot is usec/bear, swap to different name var botTypeLower = _botHelper.IsBotPmc(botType) ? _botHelper.GetPmcSideByRole(botType).ToLower() : nameof(botType).ToLower(); // Get details from db if (!botTypesDb.TryGetValue(botTypeLower, out var botDetails)) { // No bot of this type found, copy details from assault result[botTypeLower] = result[Roles.Assault]; if (_logger.IsLogEnabled(LogLevel.Debug)) { _logger.Debug( $"Unable to find bot: {botTypeLower} in db, copying: '{Roles.Assault}'" ); } continue; } if (botDetails?.BotDifficulty is null) { // Bot has no difficulty values, skip _logger.Warning( $"Unable to find bot: {botTypeLower} difficulty values in db, skipping" ); continue; } var botNameKey = nameof(botType).ToLower(); foreach (var (difficultyName, _) in botDetails.BotDifficulty) { // Bot doesn't exist in result, add if (!result.ContainsKey(botNameKey)) { result.TryAdd(botNameKey, new Dictionary()); } // Store all difficulty values in dict keyed by difficulty type e.g. easy/normal/hard/impossible result[botNameKey] .Add( difficultyName, GetBotDifficulty(string.Empty, botNameKey, difficultyName, true) ); } } return result; } /// /// Generate bots for a wave /// /// Session/Player id /// /// List of bots public List Generate(string sessionId, GenerateBotsRequestData request) { var pmcProfile = _profileHelper.GetPmcProfile(sessionId); return GenerateBotWaves(request, pmcProfile, sessionId); } /// /// Generate bots for passed in wave data /// /// /// Player generating bots /// Session/Player id /// List of generated bots protected List GenerateBotWaves( GenerateBotsRequestData request, PmcData? pmcProfile, string sessionId ) { var generatedBotList = new List(); var raidSettings = GetMostRecentRaidSettings(sessionId); var allPmcsHaveSameNameAsPlayer = _randomUtil.GetChance100( _pmcConfig.AllPMCsHavePlayerNameWithRandomPrefixChance ); var stopwatch = Stopwatch.StartNew(); // Map conditions to promises for bot generation Task.WaitAll( (request.Conditions ?? []) .Select(condition => Task.Factory.StartNew(() => { var botWaveGenerationDetails = GetBotGenerationDetailsForWave( condition, pmcProfile, allPmcsHaveSameNameAsPlayer, raidSettings ); GenerateBotWave( condition, botWaveGenerationDetails, generatedBotList, sessionId ); }) ) .ToArray() ); stopwatch.Stop(); if (_logger.IsLogEnabled(LogLevel.Debug)) { _logger.Debug( $"Took {stopwatch.ElapsedMilliseconds}ms to GenerateMultipleBotsAndCache()" ); } return generatedBotList; } /// /// Generate bots for a single wave request /// /// /// /// List of bots to fill /// Session/Player id /// protected void GenerateBotWave( GenerateCondition generateRequest, BotGenerationDetails botGenerationDetails, List botList, string sessionId ) { var isEventBot = generateRequest.Role?.Contains( "event", StringComparison.OrdinalIgnoreCase ); if (isEventBot.GetValueOrDefault(false)) { // Add eventRole data + reassign role property to be base type botGenerationDetails.EventRole = generateRequest.Role; botGenerationDetails.Role = _seasonalEventService.GetBaseRoleForEventBot( botGenerationDetails.EventRole ); } var role = botGenerationDetails.EventRole ?? botGenerationDetails.Role; if (_logger.IsLogEnabled(LogLevel.Debug)) { _logger.Debug( $"Generating wave of: {botGenerationDetails.BotCountToGenerate} bots of type: {role} {botGenerationDetails.BotDifficulty}" ); } Parallel.For( 0, botGenerationDetails.BotCountToGenerate.Value, (i) => { BotBase bot = null; try { bot = _botGenerator.PrepareAndGenerateBot( sessionId, _cloner.Clone(botGenerationDetails) ); } catch (Exception e) { _logger.Error( $"Failed to generate bot: {botGenerationDetails.Role} #{i + 1}: {e.Message} {e.StackTrace}" ); return; } // The client expects the Side for PMCs to be `Savage` // We do this here so it's after we cache the bot in the match details lookup, as when you die, they will have the right side if (bot.Info.Side is Sides.Bear or Sides.Usec) { bot.Info.Side = Sides.Savage; } lock (_botListLock) { botList.Add(bot); } // Store bot details in cache so post-raid PMC messages can use data _matchBotDetailsCacheService.CacheBot(bot); } ); if (_logger.IsLogEnabled(LogLevel.Debug)) { _logger.Debug( $"Generated: {botGenerationDetails.BotCountToGenerate} {botGenerationDetails.Role}" + $"({botGenerationDetails.EventRole ?? botGenerationDetails.Role ?? ""}) {botGenerationDetails.BotDifficulty} bots" ); } } /// /// Pull raid settings from Application context /// /// GetRaidConfigurationRequestData if it exists protected GetRaidConfigurationRequestData? GetMostRecentRaidSettings(string sessionId) { var raidConfiguration = _profileActivityService .GetProfileActivityRaidData(sessionId) ?.RaidConfiguration; if (raidConfiguration is null) { _logger.Warning( _localisationService.GetText("bot-unable_to_load_raid_settings_from_appcontext") ); } return raidConfiguration; } /// /// Get min/max level range values for a specific map /// /// Map name e.g. factory4_day /// MinMax values protected MinMax GetPmcLevelRangeForMap(string? location) { return _pmcConfig.LocationSpecificPmcLevelOverride!.GetValueOrDefault( location?.ToLower() ?? "", null ); } /// /// Create a BotGenerationDetails for the bot generator to use /// /// Data from client defining bot type and difficulty /// Player who is generating bots /// Should all PMCs have same name as player /// Settings chosen pre-raid by player in client /// BotGenerationDetails protected BotGenerationDetails GetBotGenerationDetailsForWave( GenerateCondition condition, PmcData? pmcProfile, bool allPmcsHaveSameNameAsPlayer, GetRaidConfigurationRequestData? raidSettings ) { var generateAsPmc = _botHelper.IsBotPmc(condition.Role); return new BotGenerationDetails { IsPmc = generateAsPmc, Side = generateAsPmc ? _botHelper.GetPmcSideByRole(condition.Role ?? string.Empty) : "Savage", Role = condition.Role, PlayerLevel = pmcProfile?.Info?.Level ?? 1, PlayerName = pmcProfile?.Info?.Nickname, BotRelativeLevelDeltaMax = _pmcConfig.BotRelativeLevelDeltaMax, BotRelativeLevelDeltaMin = _pmcConfig.BotRelativeLevelDeltaMin, BotCountToGenerate = Math.Max( GetBotPresetGenerationLimit(condition.Role), condition.Limit ), // Choose largest between value passed in from request vs what's in bot.config BotDifficulty = condition.Difficulty, LocationSpecificPmcLevelOverride = GetPmcLevelRangeForMap(raidSettings?.Location), // Min/max levels for PMCs to generate within IsPlayerScav = false, AllPmcsHaveSameNameAsPlayer = allPmcsHaveSameNameAsPlayer, }; } /// /// Get the max number of bots allowed on a map /// Looks up location player is entering when getting cap value /// /// The map location cap was requested for /// bot cap for map public int GetBotCap(string location) { if (!_botConfig.MaxBotCap.TryGetValue(location.ToLower(), out var maxCap)) { return _botConfig.MaxBotCap["default"]; } if (location == "default") { _logger.Warning( _localisationService.GetText( "bot-no_bot_cap_found_for_location", location.ToLower() ) ); } return maxCap; } /// /// Get weights for what each bot type should use as a brain - used by client /// /// public AiBotBrainTypes GetAiBotBrainTypes() { return new AiBotBrainTypes { PmcType = _pmcConfig.PmcType, Assault = _botConfig.AssaultBrainType, PlayerScav = _botConfig.PlayerScavBrainType, }; } } public record AiBotBrainTypes { [JsonPropertyName("pmc")] public Dictionary>> PmcType { get; set; } [JsonPropertyName("assault")] public Dictionary> Assault { get; set; } [JsonPropertyName("playerScav")] public Dictionary> PlayerScav { get; set; } }