using System; using System.Collections.Generic; using Barotrauma.IO; using System.Linq; using System.Xml.Linq; namespace Barotrauma { class NPCConversation { const int MaxPreviousConversations = 20; private class ConversationCollection { public readonly string Identifier; public readonly Dictionary> Conversations; public ConversationCollection(string identifier) { Identifier = identifier; Conversations = new Dictionary>(); } public void Add(string language, string filePath, XElement subElement) { if (!Conversations.ContainsKey(language)) { Conversations.Add(language, new List()); } Conversations[language].Add(new NPCConversation(subElement, filePath)); } public void RemoveByFile(string filePath) { List keysToRemove = new List(); foreach (var kpv in Conversations) { kpv.Value.RemoveAll(c => c.FilePath == filePath); if (kpv.Value.Count == 0) { keysToRemove.Add(kpv.Key); } } foreach (var key in keysToRemove) { Conversations.Remove(key); } } } private static Dictionary allConversations = new Dictionary(); public readonly string FilePath; public readonly string Line; public readonly List AllowedJobs; public readonly List Flags; //The line can only be selected when eventmanager intensity is between these values //null = no restriction public float? maxIntensity, minIntensity; public readonly List Responses; private readonly int speakerIndex; private readonly List allowedSpeakerTags; private readonly bool requireNextLine; // used primarily for team1 characters interacting with escorted personnel (TODO: not used anywhere) private readonly bool requireSight; public static void LoadAll(IEnumerable files) { foreach (var file in files) { if (Path.GetExtension(file.Path) == ".csv") continue; // .csv files are not supported LoadFromFile(file); } } public static void LoadFromFile(ContentFile file) { XDocument doc = XMLExtensions.TryLoadXml(file.Path); if (doc == null) { return; } string language = doc.Root.GetAttributeString("Language", "English"); string identifier = doc.Root.GetAttributeString("identifier", null); if (string.IsNullOrWhiteSpace(identifier)) { DebugConsole.ThrowError($"Conversations file '{file.Path}' has no identifier!"); return; } foreach (XElement subElement in doc.Root.Elements()) { switch (subElement.Name.ToString().ToLowerInvariant()) { case "conversation": if (!allConversations.ContainsKey(identifier)) { allConversations.Add(identifier, new ConversationCollection(identifier)); } allConversations[identifier].Add(language, file.Path, subElement); break; case "personalitytrait": new NPCPersonalityTrait(subElement, file.Path); break; } } } public static void RemoveByFile(string filePath) { List keysToRemove = new List(); foreach (var kpv in allConversations) { kpv.Value.RemoveByFile(filePath); if (!kpv.Value.Conversations.Any()) { keysToRemove.Add(kpv.Key); } } foreach (string key in keysToRemove) { allConversations.Remove(key); } NPCPersonalityTrait.List.RemoveAll(npt => npt.FilePath == filePath); } public NPCConversation(XElement element, string filePath) { FilePath = filePath; Line = element.GetAttributeString("line", ""); speakerIndex = element.GetAttributeInt("speaker", 0); AllowedJobs = new List(); string allowedJobsStr = element.GetAttributeString("allowedjobs", ""); foreach (string allowedJobIdentifier in allowedJobsStr.Split(',')) { string key = allowedJobIdentifier.ToLowerInvariant(); if (JobPrefab.Prefabs.ContainsKey(key)) { AllowedJobs.Add(JobPrefab.Prefabs[key]); } } Flags = new List(element.GetAttributeStringArray("flags", new string[0])); allowedSpeakerTags = new List(); string allowedSpeakerTagsStr = element.GetAttributeString("speakertags", ""); foreach (string tag in allowedSpeakerTagsStr.Split(',')) { if (string.IsNullOrEmpty(tag)) continue; allowedSpeakerTags.Add(tag.Trim().ToLowerInvariant()); } if (element.Attribute("minintensity") != null) minIntensity = element.GetAttributeFloat("minintensity", 0.0f); if (element.Attribute("maxintensity") != null) maxIntensity = element.GetAttributeFloat("maxintensity", 1.0f); Responses = new List(); foreach (XElement subElement in element.Elements()) { Responses.Add(new NPCConversation(subElement, filePath)); } requireNextLine = element.GetAttributeBool("requirenextline", false); requireSight = element.GetAttributeBool("requiresight", false); } private static List GetCurrentFlags(Character speaker) { var currentFlags = new List(); if (Submarine.MainSub != null && Submarine.MainSub.AtDamageDepth) { currentFlags.Add("SubmarineDeep"); } if (GameMain.GameSession != null && Level.Loaded != null) { if (Level.Loaded.Type == LevelData.LevelType.LocationConnection) { if (Timing.TotalTime < GameMain.GameSession.RoundStartTime + 30.0f) { currentFlags.Add("Initial"); } } else if (Level.Loaded.Type == LevelData.LevelType.Outpost) { if (Timing.TotalTime < GameMain.GameSession.RoundStartTime + 120.0f && speaker?.CurrentHull != null && (speaker.TeamID == CharacterTeamType.FriendlyNPC || speaker.TeamID == CharacterTeamType.None) && Character.CharacterList.Any(c => c.TeamID != speaker.TeamID && c.CurrentHull == speaker.CurrentHull)) { currentFlags.Add("EnterOutpost"); } } if (GameMain.GameSession.EventManager.CurrentIntensity <= 0.2f) { currentFlags.Add("Casual"); } if (GameMain.GameSession.IsCurrentLocationRadiated()) { currentFlags.Add("InRadiation"); } } if (speaker != null) { if (speaker.AnimController.InWater) { currentFlags.Add("Underwater"); } currentFlags.Add(speaker.CurrentHull == null ? "Outside" : "Inside"); if (Character.Controlled != null) { if (Character.Controlled.CharacterHealth.GetAffliction("psychosis") != null) { currentFlags.Add(speaker != Character.Controlled ? "Psychosis" : "PsychosisSelf"); } } var afflictions = speaker.CharacterHealth.GetAllAfflictions(); foreach (Affliction affliction in afflictions) { var currentEffect = affliction.GetActiveEffect(); if (currentEffect != null && !string.IsNullOrEmpty(currentEffect.DialogFlag) && !currentFlags.Contains(currentEffect.DialogFlag)) { currentFlags.Add(currentEffect.DialogFlag); } } if (speaker.TeamID == CharacterTeamType.FriendlyNPC && speaker.Submarine != null && speaker.Submarine.Info.IsOutpost) { currentFlags.Add("OutpostNPC"); } if (speaker.CampaignInteractionType != CampaignMode.InteractionType.None) { currentFlags.Add("CampaignNPC." + speaker.CampaignInteractionType); } if (GameMain.GameSession?.GameMode is CampaignMode campaignMode && (campaignMode.Map?.CurrentLocation?.Type?.Identifier.Equals("abandoned", StringComparison.OrdinalIgnoreCase) ?? false)) { if (speaker.TeamID == CharacterTeamType.None) { currentFlags.Add("Bandit"); } else if (speaker.TeamID == CharacterTeamType.FriendlyNPC) { currentFlags.Add("Hostage"); } } if (speaker.IsEscorted) { currentFlags.Add("escort"); } } return currentFlags; } private static List previousConversations = new List(); public static List> CreateRandom(List availableSpeakers) { Dictionary assignedSpeakers = new Dictionary(); List> lines = new List>(); CreateConversation(availableSpeakers, assignedSpeakers, null, lines, availableConversations: allConversations.Values.SelectMany(cc => cc.Conversations.Where(kpv => kpv.Key == TextManager.Language).SelectMany(kpv => kpv.Value)).ToList()); return lines; } public static List> CreateRandom(List availableSpeakers, IEnumerable requiredFlags) { Dictionary assignedSpeakers = new Dictionary(); List> lines = new List>(); var availableConversations = allConversations.Values.SelectMany(cc => cc.Conversations.SelectMany( kpv => kpv.Value.Where(conversation => kpv.Key == TextManager.Language && requiredFlags.All(f => conversation.Flags.Contains(f))))).ToList(); if (availableConversations.Count > 0) { CreateConversation(availableSpeakers, assignedSpeakers, null, lines, availableConversations: availableConversations, ignoreFlags: false); } return lines; } private static void CreateConversation( List availableSpeakers, Dictionary assignedSpeakers, NPCConversation baseConversation, List> lineList, List availableConversations, bool ignoreFlags = false) { List 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(); List potentialLines = new List(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) { previousConversations.Insert(0, selectedConversation); if (previousConversations.Count > MaxPreviousConversations) previousConversations.RemoveAt(MaxPreviousConversations); } lineList.Add(new Pair(speaker, selectedConversation.Line)); CreateConversation(availableSpeakers, assignedSpeakers, selectedConversation, lineList, availableConversations); } static bool NextLineFailure(NPCConversation selectedConversation, List availableSpeakers, List 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 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 (!selectedConversation.AllowedJobs.Contains(potentialSpeaker.Info?.Job.Prefab)) { 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.CanSeeCharacter(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 conversations, bool avoidPreviouslyUsed) { if (!avoidPreviouslyUsed) { return conversations.Count == 0 ? null : conversations[Rand.Int(conversations.Count)]; } List probabilities = new List(); foreach (NPCConversation conversation in conversations) { probabilities.Add(GetConversationProbability(conversation)); } return ToolBox.SelectWeightedRandom(conversations, probabilities, Rand.RandSync.Unsynced); } private static float GetConversationProbability(NPCConversation conversation) { int index = previousConversations.IndexOf(conversation); if (index < 0) return 10.0f; return 1.0f - 1.0f / (index + 1); } #if DEBUG public static void WriteToCSV() { System.Text.StringBuilder sb = new System.Text.StringBuilder(); foreach (string key in allConversations.Keys) { foreach (string lang in allConversations[key].Conversations.Keys) { if (lang != TextManager.Language) { continue; } foreach (var current in allConversations[key].Conversations[lang]) { 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('*'); for (int i = 0; i < conv.AllowedJobs.Count; i++) // Jobs { sb.Append(conv.AllowedJobs[i].Identifier); if (i < conv.AllowedJobs.Count - 1) { sb.Append(","); } } 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, List responses, int depthIndex) { for (int i = 0; i < responses.Count; i++) { WriteConversation(sb, responses[i], depthIndex); if (responses[i].Responses != null && responses[i].Responses.Count > 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 } }