From e7fd757dce38e54a5a5ff78f6e320b53d1bc5273 Mon Sep 17 00:00:00 2001 From: Chomp Date: Mon, 4 Aug 2025 13:25:19 +0100 Subject: [PATCH] Improved how bots are generated to be less blocking Updated `GenerateBotWaves` to return empty results when request data is empty Cleaned up `GenerateBotWave` to not need a manual lock Improved `botRelativeLevelDelta` value in pmc.config Updated `MatchBotDetailsCacheService` to store PMCs primary weapon tpl --- .../SPT_Data/configs/pmc.json | 6 +- .../Callbacks/BotCallbacks.cs | 4 +- .../Controllers/BotController.cs | 153 +++++++++--------- .../Extensions/ItemExtensions.cs | 1 + .../Generators/BotGenerator.cs | 10 +- .../Spt/Bots/BotDetailsForChatMessages.cs | 2 + .../Models/Spt/Config/PmcConfig.cs | 10 +- .../Services/MatchBotDetailsCacheService.cs | 19 ++- 8 files changed, 105 insertions(+), 100 deletions(-) diff --git a/Libraries/SPTarkov.Server.Assets/SPT_Data/configs/pmc.json b/Libraries/SPTarkov.Server.Assets/SPT_Data/configs/pmc.json index d9731cf3..1caba555 100644 --- a/Libraries/SPTarkov.Server.Assets/SPT_Data/configs/pmc.json +++ b/Libraries/SPTarkov.Server.Assets/SPT_Data/configs/pmc.json @@ -167,8 +167,10 @@ ], "useDifficultyOverride": false, "difficulty": "AsOnline", - "botRelativeLevelDeltaMax": 10, - "botRelativeLevelDeltaMin": 70, + "botRelativeLevelDelta": { + "min": 70, + "max": 10 + }, "isUsec": 60, "_pmcType": "Controls what bot brain can be chosen for each PMC bot type, the number is the weighting to be picked", "pmcType": { diff --git a/Libraries/SPTarkov.Server.Core/Callbacks/BotCallbacks.cs b/Libraries/SPTarkov.Server.Core/Callbacks/BotCallbacks.cs index a13ceb7b..a72fc9e7 100644 --- a/Libraries/SPTarkov.Server.Core/Callbacks/BotCallbacks.cs +++ b/Libraries/SPTarkov.Server.Core/Callbacks/BotCallbacks.cs @@ -52,9 +52,9 @@ public class BotCallbacks(BotController botController, HttpResponseUtil httpResp /// Handle client/game/bot/generate /// /// - public ValueTask GenerateBots(string url, GenerateBotsRequestData info, MongoId sessionID) + public async ValueTask GenerateBots(string url, GenerateBotsRequestData info, MongoId sessionID) { - return new ValueTask(httpResponseUtil.GetBody(botController.Generate(sessionID, info))); + return httpResponseUtil.GetBody(await botController.Generate(sessionID, info)); } /// diff --git a/Libraries/SPTarkov.Server.Core/Controllers/BotController.cs b/Libraries/SPTarkov.Server.Core/Controllers/BotController.cs index 85580647..4885d2f0 100644 --- a/Libraries/SPTarkov.Server.Core/Controllers/BotController.cs +++ b/Libraries/SPTarkov.Server.Core/Controllers/BotController.cs @@ -166,70 +166,73 @@ public class BotController( /// Session/Player id /// /// List of bots - public List Generate(MongoId sessionId, GenerateBotsRequestData request) + public async Task> Generate(MongoId sessionId, GenerateBotsRequestData request) { var pmcProfile = _profileHelper.GetPmcProfile(sessionId); - return GenerateBotWaves(request, pmcProfile, sessionId); + return await GenerateBotWaves(sessionId, request, pmcProfile); } /// /// Generate bots for passed in wave data /// - /// - /// Player generating bots /// Session/Player id + /// Client bot generation request + /// Player profile generating bots /// List of generated bots - protected List GenerateBotWaves(GenerateBotsRequestData request, PmcData? pmcProfile, MongoId sessionId) + protected async Task> GenerateBotWaves(MongoId sessionId, GenerateBotsRequestData request, PmcData? pmcProfile) { - var generatedBotList = new List(); + 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); - var stopwatch = Stopwatch.StartNew(); - // Map conditions to promises for bot generation + // Split each bot wave into its own task + var waveGenerationTasks = request.Conditions.Select(condition => + Task.Run(() => + { + var botWaveGenerationDetails = GetBotGenerationDetailsForWave( + condition, + pmcProfile, + allPmcsHaveSameNameAsPlayer, + raidSettings + ); - Task.WaitAll( - (request.Conditions ?? []) - .Select(condition => - Task.Factory.StartNew(() => - { - var botWaveGenerationDetails = GetBotGenerationDetailsForWave( - condition, - pmcProfile, - allPmcsHaveSameNameAsPlayer, - raidSettings - ); - - GenerateBotWave(condition, botWaveGenerationDetails, generatedBotList, sessionId); - }) - ) - .ToArray() + // Add bot wave results directly to `botsInWave` + return GenerateBotWave(sessionId, condition, botWaveGenerationDetails); + }) ); - stopwatch.Stop(); + // 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()"); } - return generatedBotList; + // Merge + flatten results of all wave generations + return results.SelectMany(botList => botList); } /// /// Generate bots for a single wave request /// + /// Session/Player id /// /// - /// List of bots to fill - /// Session/Player id - /// - protected void GenerateBotWave( + /// Result of generating bot wave + protected IEnumerable GenerateBotWave( + MongoId sessionId, GenerateCondition generateRequest, - BotGenerationDetails botGenerationDetails, - List botList, - MongoId sessionId + BotGenerationDetails botGenerationDetails ) { var isEventBot = generateRequest.Role?.Contains("event", StringComparison.OrdinalIgnoreCase); @@ -240,6 +243,7 @@ public class BotController( 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)) @@ -249,46 +253,14 @@ public class BotController( ); } - var maxThreads = botGenerationDetails.BotCountToGenerate; - -#if DEBUG - // Make debugging bot gen easier - maxThreads = 1; -#endif - - Parallel.For( - 0, - maxThreads, - (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); - } - ); + var generatedBots = Enumerable + .Range(0, botGenerationDetails.BotCountToGenerate) + .AsParallel() // Parallelise 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)) { @@ -297,6 +269,37 @@ public class BotController( + $"({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; + } } /// @@ -349,8 +352,8 @@ public class BotController( Role = condition.Role, PlayerLevel = pmcProfile?.Info?.Level ?? 1, PlayerName = pmcProfile?.Info?.Nickname, - BotRelativeLevelDeltaMax = _pmcConfig.BotRelativeLevelDeltaMax, - BotRelativeLevelDeltaMin = _pmcConfig.BotRelativeLevelDeltaMin, + 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 diff --git a/Libraries/SPTarkov.Server.Core/Extensions/ItemExtensions.cs b/Libraries/SPTarkov.Server.Core/Extensions/ItemExtensions.cs index 12d326c5..3a099cbb 100644 --- a/Libraries/SPTarkov.Server.Core/Extensions/ItemExtensions.cs +++ b/Libraries/SPTarkov.Server.Core/Extensions/ItemExtensions.cs @@ -230,6 +230,7 @@ public static class ItemExtensions } /// + /// TODO: return IEnumerable and update all calling code /// Get an item with its attachments (children) /// /// List of items (item + possible children) diff --git a/Libraries/SPTarkov.Server.Core/Generators/BotGenerator.cs b/Libraries/SPTarkov.Server.Core/Generators/BotGenerator.cs index 3fbec658..2d128b52 100644 --- a/Libraries/SPTarkov.Server.Core/Generators/BotGenerator.cs +++ b/Libraries/SPTarkov.Server.Core/Generators/BotGenerator.cs @@ -115,7 +115,7 @@ public class BotGenerator( /// constructed bot public BotBase PrepareAndGenerateBot(MongoId sessionId, BotGenerationDetails botGenerationDetails) { - var preparedBotBase = GetPreparedBotBase( + var botBaseClone = GetPreparedBotBaseClone( botGenerationDetails.EventRole ?? botGenerationDetails.Role, // Use eventRole if provided botGenerationDetails.Side, botGenerationDetails.BotDifficulty @@ -123,7 +123,7 @@ public class BotGenerator( // Get raw json data for bot (Cloned) var botRole = botGenerationDetails.IsPmc - ? preparedBotBase.Info.Side // Use side to get usec.json or bear.json when bot will be PMC + ? botBaseClone.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) @@ -131,7 +131,7 @@ public class BotGenerator( logger.Error($"Unable to retrieve: {botRole} bot template, cannot generate bot of this type"); } - return GenerateBot(sessionId, preparedBotBase, botJsonTemplateClone, botGenerationDetails); + return GenerateBot(sessionId, botBaseClone, botJsonTemplateClone, botGenerationDetails); } /// @@ -141,7 +141,7 @@ public class BotGenerator( /// Side bot should have /// Difficult bot should have /// Cloned bot base - protected BotBase GetPreparedBotBase(string botRole, string botSide, string difficulty) + protected BotBase GetPreparedBotBaseClone(string botRole, string botSide, string difficulty) { var botBaseClone = GetBotBaseClone(); botBaseClone.Info.Settings.Role = botRole; @@ -186,7 +186,7 @@ public class BotGenerator( _botConfig.BotRolesThatMustHaveUniqueName ); - // Only Pmcs should have a lower nickname + // Only PMCs need a lower nickname bot.Info.LowerNickname = botGenerationDetails.IsPmc ? bot.Info.Nickname.ToLowerInvariant() : string.Empty; // Only run when generating a 'fake' playerscav, not actual player scav diff --git a/Libraries/SPTarkov.Server.Core/Models/Spt/Bots/BotDetailsForChatMessages.cs b/Libraries/SPTarkov.Server.Core/Models/Spt/Bots/BotDetailsForChatMessages.cs index dfd7d19e..34237d38 100644 --- a/Libraries/SPTarkov.Server.Core/Models/Spt/Bots/BotDetailsForChatMessages.cs +++ b/Libraries/SPTarkov.Server.Core/Models/Spt/Bots/BotDetailsForChatMessages.cs @@ -1,4 +1,5 @@ using System.Text.Json.Serialization; +using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Enums; namespace SPTarkov.Server.Core.Models.Spt.Bots; @@ -17,4 +18,5 @@ public record BotDetailsForChatMessages public int? Level { get; set; } public MemberCategory? Type { get; set; } + public MongoId? PrimaryWeapon { get; set; } } diff --git a/Libraries/SPTarkov.Server.Core/Models/Spt/Config/PmcConfig.cs b/Libraries/SPTarkov.Server.Core/Models/Spt/Config/PmcConfig.cs index d86091e1..8a4c70ae 100644 --- a/Libraries/SPTarkov.Server.Core/Models/Spt/Config/PmcConfig.cs +++ b/Libraries/SPTarkov.Server.Core/Models/Spt/Config/PmcConfig.cs @@ -106,14 +106,8 @@ public record PmcConfig : BaseConfig /// /// How many levels above player level can a PMC be /// - [JsonPropertyName("botRelativeLevelDeltaMax")] - public required int BotRelativeLevelDeltaMax { get; set; } - - /// - /// How many levels below player level can a PMC be - /// - [JsonPropertyName("botRelativeLevelDeltaMin")] - public required int BotRelativeLevelDeltaMin { get; set; } + [JsonPropertyName("botRelativeLevelDelta")] + public required MinMax BotRelativeLevelDelta { get; set; } /// /// Force a number of healing items into PMCs secure container to ensure they can heal diff --git a/Libraries/SPTarkov.Server.Core/Services/MatchBotDetailsCacheService.cs b/Libraries/SPTarkov.Server.Core/Services/MatchBotDetailsCacheService.cs index 2b013f69..c87e91a5 100644 --- a/Libraries/SPTarkov.Server.Core/Services/MatchBotDetailsCacheService.cs +++ b/Libraries/SPTarkov.Server.Core/Services/MatchBotDetailsCacheService.cs @@ -2,6 +2,7 @@ using System.Collections.Concurrent; using System.Collections.Frozen; using SPTarkov.DI.Annotations; using SPTarkov.Server.Core.Constants; +using SPTarkov.Server.Core.Models.Common; using SPTarkov.Server.Core.Models.Eft.Common.Tables; using SPTarkov.Server.Core.Models.Enums; using SPTarkov.Server.Core.Models.Spt.Bots; @@ -15,9 +16,9 @@ namespace SPTarkov.Server.Core.Services; [Injectable(InjectionType.Singleton)] public class MatchBotDetailsCacheService(ISptLogger logger) { - private static readonly FrozenSet _sidesToCache = [Sides.PmcUsec, Sides.PmcBear]; + private static readonly FrozenSet _rolesToCache = [Sides.PmcUsec, Sides.PmcBear]; - protected readonly ConcurrentDictionary BotDetailsCache = new(); + protected readonly ConcurrentDictionary BotDetailsCache = new(); /// /// Store a bot in the cache, keyed by its ID. @@ -36,21 +37,22 @@ public class MatchBotDetailsCacheService(ISptLogger return; } - // If bot isn't a PMC, skip - if (botToCache.Info?.Settings?.Role is null || !_sidesToCache.Contains(botToCache.Info.Settings.Role)) + // ignore bot when not in role whitelist + if (botToCache.Info?.Settings?.Role is null || !_rolesToCache.Contains(botToCache.Info.Settings.Role)) { return; } BotDetailsCache.TryAdd( - botToCache.Id, - new BotDetailsForChatMessages() + botToCache.Id.Value, + new BotDetailsForChatMessages { Nickname = botToCache.Info.Nickname.Trim(), Side = botToCache.Info.Side == Sides.PmcUsec ? DogtagSide.Usec : DogtagSide.Bear, Aid = botToCache.Aid, Type = botToCache.Info.MemberCategory, Level = botToCache.Info.Level, + PrimaryWeapon = botToCache.Inventory.Items.FirstOrDefault(x => x.SlotId == "FirstPrimaryWeapon")?.Template, } ); } @@ -68,17 +70,18 @@ public class MatchBotDetailsCacheService(ISptLogger /// /// ID of bot to find /// - public BotDetailsForChatMessages? GetBotById(string? id) + public BotDetailsForChatMessages? GetBotById(MongoId? id) { if (id == null) { return null; } - var botInCache = BotDetailsCache.GetValueOrDefault(id, null); + var botInCache = BotDetailsCache.GetValueOrDefault(id.Value, null); if (botInCache is null) { logger.Warning($"Bot not found in match bot cache: {id}"); + return null; }