using System.Diagnostics; using System.Text.Json.Serialization; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Constants; using SPTarkov.Server.Core.Extensions; 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, ServerLocalisationService serverLocalisationService, SeasonalEventService seasonalEventService, MatchBotDetailsCacheService matchBotDetailsCacheService, ProfileHelper profileHelper, ConfigServer configServer, ProfileActivityService profileActivityService, RandomUtil randomUtil, ICloner cloner ) { protected readonly BotConfig botConfig = configServer.GetConfig(); protected readonly PmcConfig pmcConfig = configServer.GetConfig(); /// /// 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(serverLocalisationService.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 - should raid settings chosen pre-raid be ignored /// Difficulty object public DifficultyCategories GetBotDifficulty(MongoId sessionId, string type, string diffLevel, bool ignoreRaidSettings = false) { var difficulty = diffLevel.ToLowerInvariant(); var raidConfig = profileActivityService.GetProfileActivityRaidData(sessionId).RaidConfiguration; if (!(raidConfig != null || ignoreRaidSettings)) { logger.Error(serverLocalisationService.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().ToLowerInvariant() ?? "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 = botType.IsPmc() ? (botType.GetPmcSideByRole() ?? "usec").ToLowerInvariant() : botType.ToString().ToLowerInvariant(); // 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 = botType.ToString().ToLowerInvariant(); 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].TryAdd(difficultyName, GetBotDifficulty(string.Empty, botNameKey, difficultyName, true)); } } return result; } /// /// Generate bots for a wave /// /// Session/Player id /// /// List of bots public async Task> Generate(MongoId sessionId, GenerateBotsRequestData request) { var pmcProfile = profileHelper.GetPmcProfile(sessionId); return await GenerateBotWaves(sessionId, request, pmcProfile); } /// /// Generate bots for passed in wave data /// /// Session/Player id /// Client bot generation request /// Player profile generating bots /// List of generated bots protected async Task> GenerateBotWaves(MongoId sessionId, GenerateBotsRequestData request, PmcData? pmcProfile) { if (request.Conditions is null || !request.Conditions.Any()) { return []; } var stopwatch = Stopwatch.StartNew(); // Get chosen raid settings from app context var raidSettings = GetMostRecentRaidSettings(sessionId); var allPmcsHaveSameNameAsPlayer = randomUtil.GetChance100(pmcConfig.AllPMCsHavePlayerNameWithRandomPrefixChance); // Split each bot wave into its own task var waveGenerationTasks = request.Conditions.Select(condition => Task.Run(() => { var botWaveGenerationDetails = GetBotGenerationDetailsForWave( condition, pmcProfile, allPmcsHaveSameNameAsPlayer, raidSettings ); // Add bot wave results directly to `botsInWave` return GenerateBotWave(sessionId, condition, botWaveGenerationDetails); }) ); // Wait for all above tasks to complete var results = await Task.WhenAll(waveGenerationTasks); stopwatch.Stop(); if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug($"Took {stopwatch.ElapsedMilliseconds}ms to GenerateMultipleBotsAndCache()"); } // Merge + flatten results of all wave generations return results.SelectMany(botList => botList); } /// /// Generate bots for a single wave request /// /// Session/Player id /// /// /// Result of generating bot wave protected IEnumerable GenerateBotWave( MongoId sessionId, GenerateCondition generateRequest, BotGenerationDetails botGenerationDetails ) { 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); } // Event role must take priority to generate correctly var role = botGenerationDetails.EventRole ?? botGenerationDetails.Role; if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( $"Generating wave of: {botGenerationDetails.BotCountToGenerate} bots of type: {role} {botGenerationDetails.BotDifficulty}" ); } var generatedBots = Enumerable .Range(0, botGenerationDetails.BotCountToGenerate) .AsParallel() // Parallelize above range of values so they can each generate a bot .Select(i => TryGenerateSingleBot(sessionId, botGenerationDetails, i)) .Where(bot => bot is not null ) // Skip failed bots ; // Materialise parallel query into data if (logger.IsLogEnabled(LogLevel.Debug)) { logger.Debug( $"Generated: {botGenerationDetails.BotCountToGenerate} {botGenerationDetails.Role}" + $"({botGenerationDetails.EventRole ?? botGenerationDetails.Role}) {botGenerationDetails.BotDifficulty} bots" ); } return generatedBots; } /// /// Try to generate and cache a single bot /// /// BotBase object or null. protected BotBase? TryGenerateSingleBot(MongoId sessionId, BotGenerationDetails generationDetails, int botIndex) { try { // Clone for thread safety TODO: confirm if clone is necessary (likely not) var bot = botGenerator.PrepareAndGenerateBot(sessionId, cloner.Clone(generationDetails)!); // Client expects Side for PMCs to be `Savage`, must be altered here before it's cached if (bot.Info?.Side is Sides.Bear or Sides.Usec) { bot.Info.Side = Sides.Savage; } // Store bot details in cache before returning. matchBotDetailsCacheService.CacheBot(bot); return bot; } catch (Exception e) { logger.Error($"Failed to generate bot #{botIndex + 1} ({generationDetails.Role}): {e.Message}"); return null; } } /// /// Pull raid settings from Application context /// /// GetRaidConfigurationRequestData if it exists protected GetRaidConfigurationRequestData? GetMostRecentRaidSettings(MongoId sessionId) { var raidConfiguration = profileActivityService.GetProfileActivityRaidData(sessionId).RaidConfiguration; if (raidConfiguration is null) { logger.Warning(serverLocalisationService.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?.ToLowerInvariant() ?? string.Empty, 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.BotRelativeLevelDelta.Max, BotRelativeLevelDeltaMin = pmcConfig.BotRelativeLevelDelta.Min, 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, Location = raidSettings?.Location, }; } /// /// 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.ToLowerInvariant(), out var maxCap)) { return botConfig.MaxBotCap["default"]; } if (location == "default") { logger.Warning(serverLocalisationService.GetText("bot-no_bot_cap_found_for_location", location.ToLowerInvariant())); } 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 required Dictionary>> PmcType { get; set; } [JsonPropertyName("assault")] public required Dictionary> Assault { get; set; } [JsonPropertyName("playerScav")] public required Dictionary> PlayerScav { get; set; } }