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
This commit is contained in:
Chomp
2025-08-04 13:25:19 +01:00
parent cf99d9d824
commit e7fd757dce
8 changed files with 105 additions and 100 deletions
@@ -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": {
@@ -52,9 +52,9 @@ public class BotCallbacks(BotController botController, HttpResponseUtil httpResp
/// Handle client/game/bot/generate
/// </summary>
/// <returns></returns>
public ValueTask<string> GenerateBots(string url, GenerateBotsRequestData info, MongoId sessionID)
public async ValueTask<string> GenerateBots(string url, GenerateBotsRequestData info, MongoId sessionID)
{
return new ValueTask<string>(httpResponseUtil.GetBody(botController.Generate(sessionID, info)));
return httpResponseUtil.GetBody(await botController.Generate(sessionID, info));
}
/// <summary>
@@ -166,70 +166,73 @@ public class BotController(
/// <param name="sessionId">Session/Player id</param>
/// <param name="request"></param>
/// <returns>List of bots</returns>
public List<BotBase> Generate(MongoId sessionId, GenerateBotsRequestData request)
public async Task<IEnumerable<BotBase>> Generate(MongoId sessionId, GenerateBotsRequestData request)
{
var pmcProfile = _profileHelper.GetPmcProfile(sessionId);
return GenerateBotWaves(request, pmcProfile, sessionId);
return await GenerateBotWaves(sessionId, request, pmcProfile);
}
/// <summary>
/// Generate bots for passed in wave data
/// </summary>
/// <param name="request"></param>
/// <param name="pmcProfile">Player generating bots</param>
/// <param name="sessionId">Session/Player id</param>
/// <param name="request">Client bot generation request</param>
/// <param name="pmcProfile">Player profile generating bots</param>
/// <returns>List of generated bots</returns>
protected List<BotBase> GenerateBotWaves(GenerateBotsRequestData request, PmcData? pmcProfile, MongoId sessionId)
protected async Task<IEnumerable<BotBase>> GenerateBotWaves(MongoId sessionId, GenerateBotsRequestData request, PmcData? pmcProfile)
{
var generatedBotList = new List<BotBase>();
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);
}
/// <summary>
/// Generate bots for a single wave request
/// </summary>
/// <param name="sessionId">Session/Player id</param>
/// <param name="generateRequest"></param>
/// <param name="botGenerationDetails"></param>
/// <param name="botList">List of bots to fill</param>
/// <param name="sessionId">Session/Player id</param>
/// <returns></returns>
protected void GenerateBotWave(
/// <returns>Result of generating bot wave</returns>
protected IEnumerable<BotBase> GenerateBotWave(
MongoId sessionId,
GenerateCondition generateRequest,
BotGenerationDetails botGenerationDetails,
List<BotBase> 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;
}
/// <summary>
/// Try to generate and cache a single bot
/// </summary>
/// <returns>BotBase object or null.</returns>
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;
}
}
/// <summary>
@@ -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
@@ -230,6 +230,7 @@ public static class ItemExtensions
}
/// <summary>
/// TODO: return IEnumerable and update all calling code
/// Get an item with its attachments (children)
/// </summary>
/// <param name="items">List of items (item + possible children)</param>
@@ -115,7 +115,7 @@ public class BotGenerator(
/// <returns>constructed bot</returns>
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);
}
/// <summary>
@@ -141,7 +141,7 @@ public class BotGenerator(
/// <param name="botSide">Side bot should have</param>
/// <param name="difficulty">Difficult bot should have</param>
/// <returns>Cloned bot base</returns>
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
@@ -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; }
}
@@ -106,14 +106,8 @@ public record PmcConfig : BaseConfig
/// <summary>
/// How many levels above player level can a PMC be
/// </summary>
[JsonPropertyName("botRelativeLevelDeltaMax")]
public required int BotRelativeLevelDeltaMax { get; set; }
/// <summary>
/// How many levels below player level can a PMC be
/// </summary>
[JsonPropertyName("botRelativeLevelDeltaMin")]
public required int BotRelativeLevelDeltaMin { get; set; }
[JsonPropertyName("botRelativeLevelDelta")]
public required MinMax<int> BotRelativeLevelDelta { get; set; }
/// <summary>
/// Force a number of healing items into PMCs secure container to ensure they can heal
@@ -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<MatchBotDetailsCacheService> logger)
{
private static readonly FrozenSet<string> _sidesToCache = [Sides.PmcUsec, Sides.PmcBear];
private static readonly FrozenSet<string> _rolesToCache = [Sides.PmcUsec, Sides.PmcBear];
protected readonly ConcurrentDictionary<string, BotDetailsForChatMessages> BotDetailsCache = new();
protected readonly ConcurrentDictionary<MongoId, BotDetailsForChatMessages> BotDetailsCache = new();
/// <summary>
/// Store a bot in the cache, keyed by its ID.
@@ -36,21 +37,22 @@ public class MatchBotDetailsCacheService(ISptLogger<MatchBotDetailsCacheService>
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<MatchBotDetailsCacheService>
/// </summary>
/// <param name="id"> ID of bot to find </param>
/// <returns></returns>
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;
}