Client LuaCsForBarotrauma
NPCConversation.cs
1 using System;
2 using System.Collections.Generic;
3 using Barotrauma.IO;
4 using System.Linq;
5 using System.Xml.Linq;
6 using System.Collections.Immutable;
7 
8 namespace Barotrauma
9 {
11  {
12  public static readonly Dictionary<LanguageIdentifier, PrefabCollection<NPCConversationCollection>> Collections = new Dictionary<LanguageIdentifier, PrefabCollection<NPCConversationCollection>>();
13 
14  public readonly LanguageIdentifier Language;
15 
16  public readonly List<NPCConversation> Conversations;
17 
18  public NPCConversationCollection(NPCConversationsFile file, ContentXElement element) : base(file, element.GetAttributeIdentifier("identifier", ""))
19  {
20  Language = element.GetAttributeIdentifier("language", "English").ToLanguageIdentifier();
21  Conversations = new List<NPCConversation>();
22  foreach (var subElement in element.Elements())
23  {
24  Identifier elemName = new Identifier(subElement.Name.LocalName);
25  if (elemName == "Conversation")
26  {
27  Conversations.Add(new NPCConversation(subElement));
28  }
29  }
30  }
31 
32  public override void Dispose() { }
33  }
34 
36  {
37  const int MaxPreviousConversations = 20;
38 
39  public readonly string Line;
40 
41  public readonly ImmutableHashSet<Identifier> AllowedJobs;
42 
43  public readonly ImmutableHashSet<Identifier> Flags;
44 
45  //The line can only be selected when eventmanager intensity is between these values
46  //null = no restriction
47  public readonly float? maxIntensity, minIntensity;
48 
49  public readonly ImmutableArray<NPCConversation> Responses;
50  private readonly int speakerIndex;
51  private readonly ImmutableHashSet<Identifier> allowedSpeakerTags;
52  private readonly bool requireNextLine;
53 
54  public NPCConversation(XElement element)
55  {
56  Line = element.GetAttributeString("line", "");
57 
58  speakerIndex = element.GetAttributeInt("speaker", 0);
59 
60  AllowedJobs = element.GetAttributeIdentifierArray("allowedjobs", Array.Empty<Identifier>()).ToImmutableHashSet();
61  Flags = element.GetAttributeIdentifierArray("flags", Array.Empty<Identifier>()).ToImmutableHashSet();
62  allowedSpeakerTags = element.GetAttributeIdentifierArray("speakertags", Array.Empty<Identifier>()).ToImmutableHashSet();
63 
64  if (element.Attribute("minintensity") != null) minIntensity = element.GetAttributeFloat("minintensity", 0.0f);
65  if (element.Attribute("maxintensity") != null) maxIntensity = element.GetAttributeFloat("maxintensity", 1.0f);
66 
67  Responses = element.Elements().Select(s => new NPCConversation(s)).ToImmutableArray();
68  requireNextLine = element.GetAttributeBool("requirenextline", false);
69  }
70 
71  private static List<Identifier> GetCurrentFlags(Character speaker)
72  {
73  var currentFlags = new List<Identifier>();
74  if (Submarine.MainSub != null && Submarine.MainSub.AtDamageDepth) { currentFlags.Add("SubmarineDeep".ToIdentifier()); }
75 
76  if (GameMain.GameSession != null && Level.Loaded != null)
77  {
78  if (Level.Loaded.Type == LevelData.LevelType.LocationConnection)
79  {
80  if (GameMain.GameSession.RoundDuration < 30.0f) { currentFlags.Add("Initial".ToIdentifier()); }
81  }
82  else if (Level.Loaded.Type == LevelData.LevelType.Outpost)
83  {
84  if (GameMain.GameSession.RoundDuration < 120.0f &&
85  speaker?.CurrentHull != null &&
86  GameMain.GameSession.Map?.CurrentLocation?.Reputation?.Value >= 0.0f &&
87  (speaker.TeamID == CharacterTeamType.FriendlyNPC || speaker.TeamID == CharacterTeamType.None) &&
88  Character.CharacterList.Any(c => c.TeamID != speaker.TeamID && c.CurrentHull == speaker.CurrentHull))
89  {
90  currentFlags.Add("EnterOutpost".ToIdentifier());
91  }
92  if (Level.Loaded.IsEndBiome)
93  {
94  currentFlags.Add("EndLevel".ToIdentifier());
95  }
96  }
97  if (GameMain.GameSession.EventManager.CurrentIntensity <= 0.2f)
98  {
99  currentFlags.Add("Casual".ToIdentifier());
100  }
101 
102  if (GameMain.GameSession.IsCurrentLocationRadiated())
103  {
104  currentFlags.Add("InRadiation".ToIdentifier());
105  }
106  }
107 
108  if (speaker != null)
109  {
110  if (speaker.AnimController.InWater) { currentFlags.Add("Underwater".ToIdentifier()); }
111  currentFlags.Add((speaker.CurrentHull == null ? "Outside" : "Inside").ToIdentifier());
112 
113  if (Character.Controlled != null)
114  {
115  if (Character.Controlled.CharacterHealth.GetAffliction("psychosis") != null)
116  {
117  currentFlags.Add((speaker != Character.Controlled ? "Psychosis" : "PsychosisSelf").ToIdentifier());
118  }
119  }
120 
121  var afflictions = speaker.CharacterHealth.GetAllAfflictions();
122  foreach (Affliction affliction in afflictions)
123  {
124  var currentEffect = affliction.GetActiveEffect();
125  if (currentEffect is { DialogFlag.IsEmpty: false } && !currentFlags.Contains(currentEffect.DialogFlag))
126  {
127  currentFlags.Add(currentEffect.DialogFlag);
128  }
129  }
130 
131  if (speaker.TeamID == CharacterTeamType.FriendlyNPC && speaker.Submarine != null && speaker.Submarine.Info.IsOutpost)
132  {
133  currentFlags.Add("OutpostNPC".ToIdentifier());
134  if (GameMain.GameSession?.Level?.StartLocation?.Faction is Faction faction)
135  {
136  currentFlags.Add($"OutpostNPC{faction.Prefab.Identifier}".ToIdentifier());
137  }
138  }
139  if (speaker.CampaignInteractionType != CampaignMode.InteractionType.None)
140  {
141  currentFlags.Add($"CampaignNPC.{speaker.CampaignInteractionType}".ToIdentifier());
142  }
143  if (GameMain.GameSession?.GameMode is CampaignMode campaignMode &&
144  (campaignMode.Map?.CurrentLocation?.Type?.Identifier == "abandoned"))
145  {
146  if (speaker.TeamID == CharacterTeamType.None)
147  {
148  currentFlags.Add("Bandit".ToIdentifier());
149  }
150  else if (speaker.TeamID == CharacterTeamType.FriendlyNPC)
151  {
152  currentFlags.Add("Hostage".ToIdentifier());
153  }
154  }
155  if (speaker.IsEscorted)
156  {
157  currentFlags.Add("escort".ToIdentifier());
158  }
159  }
160 
161  return currentFlags;
162  }
163 
164  private static readonly List<NPCConversation> previousConversations = new List<NPCConversation>();
165 
166  public static List<(Character speaker, string line)> CreateRandom(List<Character> availableSpeakers)
167  {
168  Dictionary<int, Character> assignedSpeakers = new Dictionary<int, Character>();
169  List<(Character speaker, string line)> lines = new List<(Character speaker, string line)>();
170 
171  var language = GameSettings.CurrentConfig.Language;
172  if (language != TextManager.DefaultLanguage && !NPCConversationCollection.Collections.ContainsKey(language))
173  {
174  DebugConsole.AddWarning($"Could not find NPC conversations for the language \"{language}\". Using \"{TextManager.DefaultLanguage}\" instead..");
175  language = TextManager.DefaultLanguage;
176  }
177 
178  CreateConversation(availableSpeakers, assignedSpeakers, null, lines,
179  availableConversations: NPCConversationCollection.Collections[language].SelectMany(cc => cc.Conversations).ToList());
180  return lines;
181  }
182 
183  public static List<(Character speaker, string line)> CreateRandom(List<Character> availableSpeakers, IEnumerable<Identifier> requiredFlags)
184  {
185  Dictionary<int, Character> assignedSpeakers = new Dictionary<int, Character>();
186  List<(Character speaker, string line)> lines = new List<(Character speaker, string line)>();
187 
188  var language = GameSettings.CurrentConfig.Language;
189  if (language != TextManager.DefaultLanguage && !NPCConversationCollection.Collections.ContainsKey(language))
190  {
191  DebugConsole.AddWarning($"Could not find NPC conversations for the language \"{language}\". Using \"{TextManager.DefaultLanguage}\" instead..");
192  language = TextManager.DefaultLanguage;
193  }
194 
195  var availableConversations = NPCConversationCollection.Collections[language]
196  .SelectMany(cc => cc.Conversations.Where(c => requiredFlags.All(f => c.Flags.Contains(f)))).ToList();
197  if (availableConversations.Count > 0)
198  {
199  CreateConversation(availableSpeakers, assignedSpeakers, null, lines, availableConversations: availableConversations, ignoreFlags: false);
200  }
201  return lines;
202  }
203 
204  private static void CreateConversation(
205  List<Character> availableSpeakers,
206  Dictionary<int, Character> assignedSpeakers,
207  NPCConversation baseConversation,
208  IList<(Character speaker, string line)> lineList,
209  IList<NPCConversation> availableConversations,
210  bool ignoreFlags = false)
211  {
212  IList<NPCConversation> conversations = baseConversation == null ? availableConversations : baseConversation.Responses;
213  if (conversations.Count == 0) { return; }
214 
215  int conversationIndex = Rand.Int(conversations.Count);
216  NPCConversation selectedConversation = conversations[conversationIndex];
217  if (string.IsNullOrEmpty(selectedConversation.Line)) { return; }
218 
219  Character speaker = null;
220  //speaker already assigned for this line
221  if (assignedSpeakers.ContainsKey(selectedConversation.speakerIndex))
222  {
223  //check if the character has all required flags to say the line
224  var characterFlags = GetCurrentFlags(assignedSpeakers[selectedConversation.speakerIndex]);
225  if (selectedConversation.Flags.All(flag => characterFlags.Contains(flag)))
226  {
227  speaker = assignedSpeakers[selectedConversation.speakerIndex];
228  }
229  }
230  if (speaker == null)
231  {
232  var allowedSpeakers = new List<Character>();
233 
234  List<NPCConversation> potentialLines = new List<NPCConversation>(conversations);
235 
236  //remove lines that are not appropriate for the intensity of the current situation
237  if (GameMain.GameSession?.EventManager != null)
238  {
239  potentialLines.RemoveAll(l =>
240  (l.minIntensity.HasValue && GameMain.GameSession.EventManager.CurrentIntensity < l.minIntensity) ||
241  (l.maxIntensity.HasValue && GameMain.GameSession.EventManager.CurrentIntensity > l.maxIntensity));
242  }
243 
244  while (potentialLines.Count > 0)
245  {
246  //select a random line and attempt to find a speaker for it
247  // and if no valid speaker is found, choose another random line
248  selectedConversation = GetRandomConversation(potentialLines, baseConversation == null);
249  if (selectedConversation == null || string.IsNullOrEmpty(selectedConversation.Line)) { return; }
250 
251  //speaker already assigned for this line
252  if (assignedSpeakers.ContainsKey(selectedConversation.speakerIndex))
253  {
254  speaker = assignedSpeakers[selectedConversation.speakerIndex];
255  break;
256  }
257 
258  foreach (Character potentialSpeaker in availableSpeakers)
259  {
260  if (CheckSpeakerViability(potentialSpeaker, selectedConversation, assignedSpeakers.Values.ToList(), ignoreFlags))
261  {
262  allowedSpeakers.Add(potentialSpeaker);
263  }
264  }
265 
266  if (allowedSpeakers.Count == 0 || NextLineFailure(selectedConversation, availableSpeakers, allowedSpeakers, ignoreFlags))
267  {
268  allowedSpeakers.Clear();
269  potentialLines.Remove(selectedConversation);
270  }
271  else
272  {
273  break;
274  }
275  }
276 
277  if (allowedSpeakers.Count == 0) { return; }
278  speaker = allowedSpeakers[Rand.Int(allowedSpeakers.Count)];
279  availableSpeakers.Remove(speaker);
280  assignedSpeakers.Add(selectedConversation.speakerIndex, speaker);
281  }
282 
283  if (baseConversation == null)
284  {
285  previousConversations.Insert(0, selectedConversation);
286  if (previousConversations.Count > MaxPreviousConversations) previousConversations.RemoveAt(MaxPreviousConversations);
287  }
288  lineList.Add((speaker, selectedConversation.Line));
289  CreateConversation(availableSpeakers, assignedSpeakers, selectedConversation, lineList, availableConversations);
290  }
291 
292  static bool NextLineFailure(NPCConversation selectedConversation, List<Character> availableSpeakers, List<Character> allowedSpeakers, bool ignoreFlags)
293  {
294  if (selectedConversation.requireNextLine)
295  {
296  foreach (NPCConversation nextConversation in selectedConversation.Responses)
297  {
298  foreach (Character potentialNextSpeaker in availableSpeakers)
299  {
300  if (CheckSpeakerViability(potentialNextSpeaker, nextConversation, allowedSpeakers, ignoreFlags))
301  {
302  return false;
303  }
304  }
305  }
306  return true;
307  }
308  return false;
309  }
310 
311  static bool CheckSpeakerViability(Character potentialSpeaker, NPCConversation selectedConversation, List<Character> checkedSpeakers, bool ignoreFlags)
312  {
313  //check if the character has an appropriate job to say the line
314  if ((potentialSpeaker.Info?.Job != null && potentialSpeaker.Info.Job.Prefab.OnlyJobSpecificDialog) || selectedConversation.AllowedJobs.Count > 0)
315  {
316  if (!(potentialSpeaker.Info?.Job?.Prefab is { } speakerJobPrefab)
317  || !selectedConversation.AllowedJobs.Contains(speakerJobPrefab.Identifier)) { return false; }
318  }
319 
320  //check if the character has all required flags to say the line
321  if (!ignoreFlags)
322  {
323  var characterFlags = GetCurrentFlags(potentialSpeaker);
324  if (!selectedConversation.Flags.All(flag => characterFlags.Contains(flag))) { return false; }
325  }
326 
327  //check if the character is close enough to hear the rest of the speakers
328  if (checkedSpeakers.Any(s => !potentialSpeaker.CanHearCharacter(s))) { return false; }
329 
330  //check if the character is close enough to see the rest of the speakers (this should be replaced with a more performant method)
331  if (checkedSpeakers.Any(s => !potentialSpeaker.CanSeeTarget(s))) { return false; }
332 
333  //check if the character has an appropriate personality
334  if (selectedConversation.allowedSpeakerTags.Count > 0)
335  {
336  if (potentialSpeaker.Info?.PersonalityTrait == null) { return false; }
337  if (!selectedConversation.allowedSpeakerTags.Any(t => potentialSpeaker.Info.PersonalityTrait.AllowedDialogTags.Any(t2 => t2 == t))) { return false; }
338  }
339  else
340  {
341  if (potentialSpeaker.Info?.PersonalityTrait != null &&
342  !potentialSpeaker.Info.PersonalityTrait.AllowedDialogTags.Contains("none"))
343  {
344  return false;
345  }
346  }
347  return true;
348  }
349  private static NPCConversation GetRandomConversation(List<NPCConversation> conversations, bool avoidPreviouslyUsed)
350  {
351  if (!avoidPreviouslyUsed)
352  {
353  return conversations.Count == 0 ? null : conversations[Rand.Int(conversations.Count)];
354  }
355 
356  List<float> probabilities = new List<float>();
357  foreach (NPCConversation conversation in conversations)
358  {
359  probabilities.Add(GetConversationProbability(conversation));
360  }
361  return ToolBox.SelectWeightedRandom(conversations, probabilities, Rand.RandSync.Unsynced);
362  }
363 
364  private static float GetConversationProbability(NPCConversation conversation)
365  {
366  //prefer choosing conversations with more flags (= for more specific situations) when possible
367  float baseProbability = MathF.Pow(conversation.Flags.Count + 1, 2);
368 
369  int index = previousConversations.IndexOf(conversation);
370  if (index < 0) { return baseProbability * 10.0f; }
371 
372  return baseProbability + 1.0f - 1.0f / (index + 1);
373  }
374 
375 #if DEBUG
376  public static void WriteToCSV()
377  {
378  System.Text.StringBuilder sb = new System.Text.StringBuilder();
379 
380  foreach (Identifier identifier in NPCConversationCollection.Collections[GameSettings.CurrentConfig.Language].Keys)
381  {
382  foreach (var current in NPCConversationCollection.Collections[GameSettings.CurrentConfig.Language][identifier].Conversations)
383  {
384  WriteConversation(sb, current, 0);
385  WriteSubConversations(sb, current.Responses, 1);
386  WriteEmptyRow(sb);
387  }
388  }
389 
390  File.WriteAllText("NPCConversations.csv", sb.ToString());
391  }
392 
393  private static void WriteConversation(System.Text.StringBuilder sb, NPCConversation conv, int depthIndex)
394  {
395  sb.Append(conv.speakerIndex); // Speaker index
396  sb.Append('*');
397  sb.Append(depthIndex); // Depth index
398  sb.Append('*');
399  sb.Append(conv.Line); // Original
400  sb.Append('*');
401  // Translated
402  sb.Append('*');
403  sb.Append(string.Join(",", conv.Flags)); // Flags
404  sb.Append('*');
405 
406  sb.Append(string.Join(',', conv.AllowedJobs));
407 
408  sb.Append('*');
409  sb.Append(string.Join(",", conv.allowedSpeakerTags)); // Traits
410  sb.Append('*');
411  sb.Append(conv.minIntensity); // Minimum intensity
412  sb.Append('*');
413  sb.Append(conv.maxIntensity); // Maximum intensity
414  sb.Append('*');
415  // Comments
416  sb.AppendLine();
417  }
418 
419  private static void WriteSubConversations(System.Text.StringBuilder sb, IList<NPCConversation> responses, int depthIndex)
420  {
421  for (int i = 0; i < responses.Count; i++)
422  {
423  WriteConversation(sb, responses[i], depthIndex);
424 
425  if (responses[i].Responses != null && responses[i].Responses.Length > 0)
426  {
427  WriteSubConversations(sb, responses[i].Responses, depthIndex + 1);
428  }
429  }
430  }
431 
432  private static void WriteEmptyRow(System.Text.StringBuilder sb)
433  {
434  for (int i = 0; i < 8; i++)
435  {
436  sb.Append('*');
437  }
438  sb.AppendLine();
439  }
440 #endif
441  }
442 
443 }
Identifier GetAttributeIdentifier(string key, string def)
Submarine Submarine
Definition: Entity.cs:53
NPCConversationCollection(NPCConversationsFile file, ContentXElement element)
readonly List< NPCConversation > Conversations
readonly LanguageIdentifier Language
static readonly Dictionary< LanguageIdentifier, PrefabCollection< NPCConversationCollection > > Collections
static List<(Character speaker, string line)> CreateRandom(List< Character > availableSpeakers)
NPCConversation(XElement element)
readonly? float maxIntensity
static List<(Character speaker, string line)> CreateRandom(List< Character > availableSpeakers, IEnumerable< Identifier > requiredFlags)
readonly ImmutableArray< NPCConversation > Responses
readonly ImmutableHashSet< Identifier > AllowedJobs
readonly ImmutableHashSet< Identifier > Flags
readonly Identifier Identifier
Definition: Prefab.cs:34
static Submarine MainSub
Note that this can be null in some situations, e.g. editors and missions that don't load a submarine.
@ Character
Characters only