Refactored various collections (lists, dictionaries, queues, and caches) in AI, character, animation, and item component systems to use thread-safe patterns and concurrent data structures. This includes introducing copy-on-write wrappers, ConcurrentDictionary, ConcurrentQueue, and ThreadLocal where appropriate to ensure safe concurrent access and mutation, improving stability in multi-threaded scenarios.
461 lines
21 KiB
C#
461 lines
21 KiB
C#
using System;
|
|
using System.Collections.Concurrent;
|
|
using System.Collections.Generic;
|
|
using Barotrauma.IO;
|
|
using System.Linq;
|
|
using System.Xml.Linq;
|
|
using System.Collections.Immutable;
|
|
|
|
namespace Barotrauma
|
|
{
|
|
class NPCConversationCollection : Prefab
|
|
{
|
|
// Thread-safe dictionary for language-based collections
|
|
public static readonly ConcurrentDictionary<LanguageIdentifier, PrefabCollection<NPCConversationCollection>> Collections = new ConcurrentDictionary<LanguageIdentifier, PrefabCollection<NPCConversationCollection>>();
|
|
|
|
public readonly LanguageIdentifier Language;
|
|
|
|
public readonly List<NPCConversation> Conversations;
|
|
|
|
public NPCConversationCollection(NPCConversationsFile file, ContentXElement element) : base(file, element.GetAttributeIdentifier("identifier", ""))
|
|
{
|
|
Language = element.GetAttributeIdentifier("language", "English").ToLanguageIdentifier();
|
|
Conversations = new List<NPCConversation>();
|
|
foreach (var subElement in element.Elements())
|
|
{
|
|
Identifier elemName = new Identifier(subElement.Name.LocalName);
|
|
if (elemName == "Conversation")
|
|
{
|
|
Conversations.Add(new NPCConversation(subElement));
|
|
}
|
|
}
|
|
}
|
|
|
|
public override void Dispose() { }
|
|
}
|
|
|
|
class NPCConversation
|
|
{
|
|
const int MaxPreviousConversations = 20;
|
|
|
|
public readonly string Line;
|
|
|
|
public readonly ImmutableHashSet<Identifier> AllowedJobs;
|
|
|
|
public readonly ImmutableHashSet<Identifier> Flags;
|
|
|
|
//The line can only be selected when eventmanager intensity is between these values
|
|
//null = no restriction
|
|
public readonly float? maxIntensity, minIntensity;
|
|
|
|
public readonly ImmutableArray<NPCConversation> Responses;
|
|
private readonly int speakerIndex;
|
|
private readonly ImmutableHashSet<Identifier> allowedSpeakerTags;
|
|
private readonly bool requireNextLine;
|
|
|
|
public NPCConversation(XElement element)
|
|
{
|
|
Line = element.GetAttributeString("line", "");
|
|
|
|
speakerIndex = element.GetAttributeInt("speaker", 0);
|
|
|
|
AllowedJobs = element.GetAttributeIdentifierArray("allowedjobs", Array.Empty<Identifier>()).ToImmutableHashSet();
|
|
Flags = element.GetAttributeIdentifierArray("flags", Array.Empty<Identifier>()).ToImmutableHashSet();
|
|
allowedSpeakerTags = element.GetAttributeIdentifierArray("speakertags", Array.Empty<Identifier>()).ToImmutableHashSet();
|
|
|
|
if (element.Attribute("minintensity") != null) minIntensity = element.GetAttributeFloat("minintensity", 0.0f);
|
|
if (element.Attribute("maxintensity") != null) maxIntensity = element.GetAttributeFloat("maxintensity", 1.0f);
|
|
|
|
Responses = element.Elements().Select(s => new NPCConversation(s)).ToImmutableArray();
|
|
requireNextLine = element.GetAttributeBool("requirenextline", false);
|
|
}
|
|
|
|
private static List<Identifier> GetCurrentFlags(Character speaker)
|
|
{
|
|
var currentFlags = new List<Identifier>();
|
|
if (Submarine.MainSub != null && Submarine.MainSub.AtCosmeticDamageDepth) { currentFlags.Add("SubmarineDeep".ToIdentifier()); }
|
|
|
|
if (GameMain.GameSession != null && Level.Loaded != null)
|
|
{
|
|
if (Level.Loaded.Type == LevelData.LevelType.LocationConnection)
|
|
{
|
|
if (GameMain.GameSession.RoundDuration < 30.0f) { currentFlags.Add("Initial".ToIdentifier()); }
|
|
}
|
|
else if (Level.Loaded.Type == LevelData.LevelType.Outpost)
|
|
{
|
|
if (GameMain.GameSession.RoundDuration < 120.0f &&
|
|
speaker?.CurrentHull != null &&
|
|
GameMain.GameSession.Map?.CurrentLocation?.Reputation?.Value >= 0.0f &&
|
|
Character.CharacterList.Any(c => c.TeamID != speaker.TeamID && c.CurrentHull == speaker.CurrentHull))
|
|
{
|
|
currentFlags.Add("EnterOutpost".ToIdentifier());
|
|
}
|
|
if (Level.Loaded.IsEndBiome)
|
|
{
|
|
currentFlags.Add("EndLevel".ToIdentifier());
|
|
}
|
|
}
|
|
if (GameMain.GameSession.EventManager.CurrentIntensity <= 0.2f)
|
|
{
|
|
currentFlags.Add("Casual".ToIdentifier());
|
|
}
|
|
|
|
if (GameMain.GameSession.IsCurrentLocationRadiated())
|
|
{
|
|
currentFlags.Add("InRadiation".ToIdentifier());
|
|
}
|
|
}
|
|
|
|
if (speaker != null)
|
|
{
|
|
if (speaker.AnimController.InWater) { currentFlags.Add("Underwater".ToIdentifier()); }
|
|
currentFlags.Add((speaker.CurrentHull == null ? "Outside" : "Inside").ToIdentifier());
|
|
|
|
if (Character.Controlled != null)
|
|
{
|
|
if (Character.Controlled.CharacterHealth.GetAffliction("psychosis") != null)
|
|
{
|
|
currentFlags.Add((speaker != Character.Controlled ? "Psychosis" : "PsychosisSelf").ToIdentifier());
|
|
}
|
|
}
|
|
|
|
var afflictions = speaker.CharacterHealth.GetAllAfflictions();
|
|
foreach (Affliction affliction in afflictions)
|
|
{
|
|
var currentEffect = affliction.GetActiveEffect();
|
|
if (currentEffect is { DialogFlag.IsEmpty: false } && !currentFlags.Contains(currentEffect.DialogFlag))
|
|
{
|
|
currentFlags.Add(currentEffect.DialogFlag);
|
|
}
|
|
}
|
|
|
|
if (speaker.TeamID == CharacterTeamType.FriendlyNPC && speaker.Submarine != null && speaker.Submarine.Info.IsOutpost)
|
|
{
|
|
currentFlags.Add("OutpostNPC".ToIdentifier());
|
|
if (GameMain.GameSession?.Level?.StartLocation?.Faction is Faction faction)
|
|
{
|
|
currentFlags.Add($"OutpostNPC{faction.Prefab.Identifier}".ToIdentifier());
|
|
}
|
|
}
|
|
if (speaker.CampaignInteractionType != CampaignMode.InteractionType.None)
|
|
{
|
|
currentFlags.Add($"CampaignNPC.{speaker.CampaignInteractionType}".ToIdentifier());
|
|
}
|
|
if (GameMain.GameSession?.GameMode is CampaignMode campaignMode &&
|
|
(campaignMode.Map?.CurrentLocation?.Type?.Identifier == "abandoned"))
|
|
{
|
|
if (speaker.TeamID == CharacterTeamType.None)
|
|
{
|
|
currentFlags.Add("Bandit".ToIdentifier());
|
|
}
|
|
else if (speaker.TeamID == CharacterTeamType.FriendlyNPC)
|
|
{
|
|
currentFlags.Add("Hostage".ToIdentifier());
|
|
}
|
|
}
|
|
if (speaker.IsEscorted)
|
|
{
|
|
currentFlags.Add("escort".ToIdentifier());
|
|
}
|
|
}
|
|
|
|
return currentFlags;
|
|
}
|
|
|
|
// Thread-safe previous conversations tracking using copy-on-write pattern
|
|
private static volatile List<NPCConversation> _previousConversations = new List<NPCConversation>();
|
|
private static readonly object _previousConversationsLock = new object();
|
|
private static List<NPCConversation> previousConversations => _previousConversations;
|
|
|
|
private static void AddToPreviousConversations(NPCConversation conversation)
|
|
{
|
|
lock (_previousConversationsLock)
|
|
{
|
|
var newList = new List<NPCConversation>(_previousConversations);
|
|
newList.Insert(0, conversation);
|
|
if (newList.Count > MaxPreviousConversations)
|
|
{
|
|
newList.RemoveAt(MaxPreviousConversations);
|
|
}
|
|
_previousConversations = newList;
|
|
}
|
|
}
|
|
|
|
public static List<(Character speaker, string line)> CreateRandom(List<Character> availableSpeakers)
|
|
{
|
|
Dictionary<int, Character> assignedSpeakers = new Dictionary<int, Character>();
|
|
List<(Character speaker, string line)> lines = new List<(Character speaker, string line)>();
|
|
|
|
var language = GameSettings.CurrentConfig.Language;
|
|
if (language != TextManager.DefaultLanguage && !NPCConversationCollection.Collections.ContainsKey(language))
|
|
{
|
|
DebugConsole.AddWarning($"Could not find NPC conversations for the language \"{language}\". Using \"{TextManager.DefaultLanguage}\" instead..");
|
|
language = TextManager.DefaultLanguage;
|
|
}
|
|
|
|
CreateConversation(availableSpeakers, assignedSpeakers, null, lines,
|
|
availableConversations: NPCConversationCollection.Collections[language].SelectMany(cc => cc.Conversations).ToList());
|
|
return lines;
|
|
}
|
|
|
|
public static List<(Character speaker, string line)> CreateRandom(List<Character> availableSpeakers, IEnumerable<Identifier> requiredFlags)
|
|
{
|
|
Dictionary<int, Character> assignedSpeakers = new Dictionary<int, Character>();
|
|
List<(Character speaker, string line)> lines = new List<(Character speaker, string line)>();
|
|
|
|
var language = GameSettings.CurrentConfig.Language;
|
|
if (language != TextManager.DefaultLanguage && !NPCConversationCollection.Collections.ContainsKey(language))
|
|
{
|
|
DebugConsole.AddWarning($"Could not find NPC conversations for the language \"{language}\". Using \"{TextManager.DefaultLanguage}\" instead..");
|
|
language = TextManager.DefaultLanguage;
|
|
}
|
|
|
|
var availableConversations = NPCConversationCollection.Collections[language]
|
|
.SelectMany(cc => cc.Conversations.Where(c => requiredFlags.All(f => c.Flags.Contains(f)))).ToList();
|
|
if (availableConversations.Count > 0)
|
|
{
|
|
CreateConversation(availableSpeakers, assignedSpeakers, null, lines, availableConversations: availableConversations, ignoreFlags: false);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
private static void CreateConversation(
|
|
List<Character> availableSpeakers,
|
|
Dictionary<int, Character> assignedSpeakers,
|
|
NPCConversation baseConversation,
|
|
IList<(Character speaker, string line)> lineList,
|
|
IList<NPCConversation> availableConversations,
|
|
bool ignoreFlags = false)
|
|
{
|
|
IList<NPCConversation> conversations = baseConversation == null ? availableConversations : baseConversation.Responses;
|
|
if (conversations.Count == 0) { return; }
|
|
|
|
int conversationIndex = Rand.Int(conversations.Count);
|
|
NPCConversation selectedConversation = conversations[conversationIndex];
|
|
if (string.IsNullOrEmpty(selectedConversation.Line)) { return; }
|
|
|
|
Character speaker = null;
|
|
//speaker already assigned for this line
|
|
if (assignedSpeakers.ContainsKey(selectedConversation.speakerIndex))
|
|
{
|
|
//check if the character has all required flags to say the line
|
|
var characterFlags = GetCurrentFlags(assignedSpeakers[selectedConversation.speakerIndex]);
|
|
if (selectedConversation.Flags.All(flag => characterFlags.Contains(flag)))
|
|
{
|
|
speaker = assignedSpeakers[selectedConversation.speakerIndex];
|
|
}
|
|
}
|
|
if (speaker == null)
|
|
{
|
|
var allowedSpeakers = new List<Character>();
|
|
|
|
List<NPCConversation> potentialLines = new List<NPCConversation>(conversations);
|
|
|
|
//remove lines that are not appropriate for the intensity of the current situation
|
|
if (GameMain.GameSession?.EventManager != null)
|
|
{
|
|
potentialLines.RemoveAll(l =>
|
|
(l.minIntensity.HasValue && GameMain.GameSession.EventManager.CurrentIntensity < l.minIntensity) ||
|
|
(l.maxIntensity.HasValue && GameMain.GameSession.EventManager.CurrentIntensity > l.maxIntensity));
|
|
}
|
|
|
|
while (potentialLines.Count > 0)
|
|
{
|
|
//select a random line and attempt to find a speaker for it
|
|
// and if no valid speaker is found, choose another random line
|
|
selectedConversation = GetRandomConversation(potentialLines, baseConversation == null);
|
|
if (selectedConversation == null || string.IsNullOrEmpty(selectedConversation.Line)) { return; }
|
|
|
|
//speaker already assigned for this line
|
|
if (assignedSpeakers.ContainsKey(selectedConversation.speakerIndex))
|
|
{
|
|
speaker = assignedSpeakers[selectedConversation.speakerIndex];
|
|
break;
|
|
}
|
|
|
|
foreach (Character potentialSpeaker in availableSpeakers)
|
|
{
|
|
if (CheckSpeakerViability(potentialSpeaker, selectedConversation, assignedSpeakers.Values.ToList(), ignoreFlags))
|
|
{
|
|
allowedSpeakers.Add(potentialSpeaker);
|
|
}
|
|
}
|
|
|
|
if (allowedSpeakers.Count == 0 || NextLineFailure(selectedConversation, availableSpeakers, allowedSpeakers, ignoreFlags))
|
|
{
|
|
allowedSpeakers.Clear();
|
|
potentialLines.Remove(selectedConversation);
|
|
}
|
|
else
|
|
{
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (allowedSpeakers.Count == 0) { return; }
|
|
speaker = allowedSpeakers[Rand.Int(allowedSpeakers.Count)];
|
|
availableSpeakers.Remove(speaker);
|
|
assignedSpeakers.Add(selectedConversation.speakerIndex, speaker);
|
|
}
|
|
|
|
if (baseConversation == null)
|
|
{
|
|
AddToPreviousConversations(selectedConversation);
|
|
}
|
|
lineList.Add((speaker, selectedConversation.Line));
|
|
CreateConversation(availableSpeakers, assignedSpeakers, selectedConversation, lineList, availableConversations);
|
|
}
|
|
|
|
static bool NextLineFailure(NPCConversation selectedConversation, List<Character> availableSpeakers, List<Character> allowedSpeakers, bool ignoreFlags)
|
|
{
|
|
if (selectedConversation.requireNextLine)
|
|
{
|
|
foreach (NPCConversation nextConversation in selectedConversation.Responses)
|
|
{
|
|
foreach (Character potentialNextSpeaker in availableSpeakers)
|
|
{
|
|
if (CheckSpeakerViability(potentialNextSpeaker, nextConversation, allowedSpeakers, ignoreFlags))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool CheckSpeakerViability(Character potentialSpeaker, NPCConversation selectedConversation, List<Character> checkedSpeakers, bool ignoreFlags)
|
|
{
|
|
//check if the character has an appropriate job to say the line
|
|
if ((potentialSpeaker.Info?.Job != null && potentialSpeaker.Info.Job.Prefab.OnlyJobSpecificDialog) || selectedConversation.AllowedJobs.Count > 0)
|
|
{
|
|
if (!(potentialSpeaker.Info?.Job?.Prefab is { } speakerJobPrefab)
|
|
|| !selectedConversation.AllowedJobs.Contains(speakerJobPrefab.Identifier)) { return false; }
|
|
}
|
|
|
|
//check if the character has all required flags to say the line
|
|
if (!ignoreFlags)
|
|
{
|
|
var characterFlags = GetCurrentFlags(potentialSpeaker);
|
|
if (!selectedConversation.Flags.All(flag => characterFlags.Contains(flag))) { return false; }
|
|
}
|
|
|
|
//check if the character is close enough to hear the rest of the speakers
|
|
if (checkedSpeakers.Any(s => !potentialSpeaker.CanHearCharacter(s))) { return false; }
|
|
|
|
//check if the character is close enough to see the rest of the speakers (this should be replaced with a more performant method)
|
|
if (checkedSpeakers.Any(s => !potentialSpeaker.CanSeeTarget(s))) { return false; }
|
|
|
|
//check if the character has an appropriate personality
|
|
if (selectedConversation.allowedSpeakerTags.Count > 0)
|
|
{
|
|
if (potentialSpeaker.Info?.PersonalityTrait == null) { return false; }
|
|
if (!selectedConversation.allowedSpeakerTags.Any(t => potentialSpeaker.Info.PersonalityTrait.AllowedDialogTags.Any(t2 => t2 == t))) { return false; }
|
|
}
|
|
else
|
|
{
|
|
if (potentialSpeaker.Info?.PersonalityTrait != null &&
|
|
!potentialSpeaker.Info.PersonalityTrait.AllowedDialogTags.Contains("none"))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
private static NPCConversation GetRandomConversation(List<NPCConversation> conversations, bool avoidPreviouslyUsed)
|
|
{
|
|
if (!avoidPreviouslyUsed)
|
|
{
|
|
return conversations.Count == 0 ? null : conversations[Rand.Int(conversations.Count)];
|
|
}
|
|
|
|
List<float> probabilities = new List<float>();
|
|
foreach (NPCConversation conversation in conversations)
|
|
{
|
|
probabilities.Add(GetConversationProbability(conversation));
|
|
}
|
|
return ToolBox.SelectWeightedRandom(conversations, probabilities, Rand.RandSync.Unsynced);
|
|
}
|
|
|
|
private static float GetConversationProbability(NPCConversation conversation)
|
|
{
|
|
//prefer choosing conversations with more flags (= for more specific situations) when possible
|
|
float baseProbability = MathF.Pow(conversation.Flags.Count + 1, 2);
|
|
|
|
int index = previousConversations.IndexOf(conversation);
|
|
if (index < 0) { return baseProbability * 10.0f; }
|
|
|
|
return baseProbability + 1.0f - 1.0f / (index + 1);
|
|
}
|
|
|
|
#if DEBUG
|
|
public static void WriteToCSV()
|
|
{
|
|
System.Text.StringBuilder sb = new System.Text.StringBuilder();
|
|
|
|
foreach (Identifier identifier in NPCConversationCollection.Collections[GameSettings.CurrentConfig.Language].Keys)
|
|
{
|
|
foreach (var current in NPCConversationCollection.Collections[GameSettings.CurrentConfig.Language][identifier].Conversations)
|
|
{
|
|
WriteConversation(sb, current, 0);
|
|
WriteSubConversations(sb, current.Responses, 1);
|
|
WriteEmptyRow(sb);
|
|
}
|
|
}
|
|
|
|
File.WriteAllText("NPCConversations.csv", sb.ToString());
|
|
}
|
|
|
|
private static void WriteConversation(System.Text.StringBuilder sb, NPCConversation conv, int depthIndex)
|
|
{
|
|
sb.Append(conv.speakerIndex); // Speaker index
|
|
sb.Append('*');
|
|
sb.Append(depthIndex); // Depth index
|
|
sb.Append('*');
|
|
sb.Append(conv.Line); // Original
|
|
sb.Append('*');
|
|
// Translated
|
|
sb.Append('*');
|
|
sb.Append(string.Join(",", conv.Flags)); // Flags
|
|
sb.Append('*');
|
|
|
|
sb.Append(string.Join(',', conv.AllowedJobs));
|
|
|
|
sb.Append('*');
|
|
sb.Append(string.Join(",", conv.allowedSpeakerTags)); // Traits
|
|
sb.Append('*');
|
|
sb.Append(conv.minIntensity); // Minimum intensity
|
|
sb.Append('*');
|
|
sb.Append(conv.maxIntensity); // Maximum intensity
|
|
sb.Append('*');
|
|
// Comments
|
|
sb.AppendLine();
|
|
}
|
|
|
|
private static void WriteSubConversations(System.Text.StringBuilder sb, IList<NPCConversation> responses, int depthIndex)
|
|
{
|
|
for (int i = 0; i < responses.Count; i++)
|
|
{
|
|
WriteConversation(sb, responses[i], depthIndex);
|
|
|
|
if (responses[i].Responses != null && responses[i].Responses.Length > 0)
|
|
{
|
|
WriteSubConversations(sb, responses[i].Responses, depthIndex + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void WriteEmptyRow(System.Text.StringBuilder sb)
|
|
{
|
|
for (int i = 0; i < 8; i++)
|
|
{
|
|
sb.Append('*');
|
|
}
|
|
sb.AppendLine();
|
|
}
|
|
#endif
|
|
}
|
|
|
|
}
|