529fe61f23
* Cleanup BotController.cs * More controller cleanup * More dialogue changes
412 lines
16 KiB
C#
412 lines
16 KiB
C#
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<BotController> 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<BotConfig>();
|
|
protected readonly PmcConfig pmcConfig = configServer.GetConfig<PmcConfig>();
|
|
|
|
/// <summary>
|
|
/// Return the number of bot load-out varieties to be generated
|
|
/// </summary>
|
|
/// <param name="type">bot Type we want the load-out gen count for</param>
|
|
/// <returns>number of bots to generate</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle singleplayer/settings/bot/difficulty
|
|
/// Get the core.json difficulty settings from database/bots
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
public Dictionary<string, object> GetBotCoreDifficulty()
|
|
{
|
|
return databaseService.GetBots().Core;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get bot difficulty settings
|
|
/// Adjust PMC settings to ensure they engage the correct bot types
|
|
/// </summary>
|
|
/// <param name="sessionId">Which user is requesting his bot settings</param>
|
|
/// <param name="type">what bot the server is requesting settings for</param>
|
|
/// <param name="diffLevel">difficulty level server requested settings for</param>
|
|
/// <param name="ignoreRaidSettings">OPTIONAL - should raid settings chosen pre-raid be ignored</param>
|
|
/// <returns>Difficulty object</returns>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Handle singleplayer/settings/bot/difficulties
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
public Dictionary<string, Dictionary<string, DifficultyCategories>> GetAllBotDifficulties()
|
|
{
|
|
var result = new Dictionary<string, Dictionary<string, DifficultyCategories>>();
|
|
|
|
var botTypesDb = databaseService.GetBots().Types;
|
|
if (botTypesDb is null)
|
|
{
|
|
return result;
|
|
}
|
|
//Get all bot types as sting array
|
|
var botTypes = Enum.GetValues<WildSpawnType>();
|
|
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<string, DifficultyCategories>());
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generate bots for a wave
|
|
/// </summary>
|
|
/// <param name="sessionId">Session/Player id</param>
|
|
/// <param name="request"></param>
|
|
/// <returns>List of bots</returns>
|
|
public async Task<IEnumerable<BotBase?>> Generate(MongoId sessionId, GenerateBotsRequestData request)
|
|
{
|
|
var pmcProfile = profileHelper.GetPmcProfile(sessionId);
|
|
|
|
return await GenerateBotWaves(sessionId, request, pmcProfile);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generate bots for passed in wave data
|
|
/// </summary>
|
|
/// <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 async Task<IEnumerable<BotBase?>> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Generate bots for a single wave request
|
|
/// </summary>
|
|
/// <param name="sessionId">Session/Player id</param>
|
|
/// <param name="generateRequest"></param>
|
|
/// <param name="botGenerationDetails"></param>
|
|
/// <returns>Result of generating bot wave</returns>
|
|
protected IEnumerable<BotBase?> 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;
|
|
}
|
|
|
|
/// <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>
|
|
/// Pull raid settings from Application context
|
|
/// </summary>
|
|
/// <returns>GetRaidConfigurationRequestData if it exists</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get min/max level range values for a specific map
|
|
/// </summary>
|
|
/// <param name="location">Map name e.g. factory4_day</param>
|
|
/// <returns>MinMax values</returns>
|
|
protected MinMax<int>? GetPmcLevelRangeForMap(string? location)
|
|
{
|
|
return pmcConfig.LocationSpecificPmcLevelOverride!.GetValueOrDefault(location?.ToLowerInvariant() ?? "", null);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Create a BotGenerationDetails for the bot generator to use
|
|
/// </summary>
|
|
/// <param name="condition">Data from client defining bot type and difficulty</param>
|
|
/// <param name="pmcProfile">Player who is generating bots</param>
|
|
/// <param name="allPmcsHaveSameNameAsPlayer">Should all PMCs have same name as player</param>
|
|
/// <param name="raidSettings">Settings chosen pre-raid by player in client</param>
|
|
/// <returns>BotGenerationDetails</returns>
|
|
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,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get the max number of bots allowed on a map
|
|
/// Looks up location player is entering when getting cap value
|
|
/// </summary>
|
|
/// <param name="location">The map location cap was requested for</param>
|
|
/// <returns>bot cap for map</returns>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Get weights for what each bot type should use as a brain - used by client
|
|
/// </summary>
|
|
/// <returns></returns>
|
|
public AiBotBrainTypes GetAiBotBrainTypes()
|
|
{
|
|
return new AiBotBrainTypes
|
|
{
|
|
PmcType = pmcConfig.PmcType,
|
|
Assault = botConfig.AssaultBrainType,
|
|
PlayerScav = botConfig.PlayerScavBrainType,
|
|
};
|
|
}
|
|
}
|
|
|
|
public record AiBotBrainTypes
|
|
{
|
|
[JsonPropertyName("pmc")]
|
|
public required Dictionary<string, Dictionary<string, Dictionary<string, double>>> PmcType { get; set; }
|
|
|
|
[JsonPropertyName("assault")]
|
|
public required Dictionary<string, Dictionary<string, int>> Assault { get; set; }
|
|
|
|
[JsonPropertyName("playerScav")]
|
|
public required Dictionary<string, Dictionary<string, int>> PlayerScav { get; set; }
|
|
}
|