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;
}