using System; using System.Collections.Generic; using Barotrauma.IO; using System.Linq; using System.Xml.Linq; using System.Collections.Immutable; namespace Barotrauma { class NPCConversationCollection : Prefab { public static readonly Dictionary> Collections = new Dictionary>(); public readonly LanguageIdentifier Language; public readonly List Conversations; public NPCConversationCollection(NPCConversationsFile file, ContentXElement element) : base(file, element.GetAttributeIdentifier("identifier", "")) { Language = element.GetAttributeIdentifier("language", "English").ToLanguageIdentifier(); Conversations = new List(); 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 AllowedJobs; public readonly ImmutableHashSet 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 Responses; private readonly int speakerIndex; private readonly ImmutableHashSet allowedSpeakerTags; private readonly bool requireNextLine; public NPCConversation(XElement element) { Line = element.GetAttributeString("line", ""); speakerIndex = element.GetAttributeInt("speaker", 0); AllowedJobs = element.GetAttributeIdentifierArray("allowedjobs", Array.Empty()).ToImmutableHashSet(); Flags = element.GetAttributeIdentifierArray("flags", Array.Empty()).ToImmutableHashSet(); allowedSpeakerTags = element.GetAttributeIdentifierArray("speakertags", Array.Empty()).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 GetCurrentFlags(Character speaker) { var currentFlags = new List(); if (Submarine.MainSub != null && Submarine.MainSub.AtDamageDepth) { 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 && (speaker.TeamID == CharacterTeamType.FriendlyNPC || speaker.TeamID == CharacterTeamType.None) && 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; } private static readonly List previousConversations = new List(); public static List<(Character speaker, string line)> CreateRandom(List availableSpeakers) { Dictionary assignedSpeakers = new Dictionary(); 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 availableSpeakers, IEnumerable requiredFlags) { Dictionary assignedSpeakers = new Dictionary(); 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 availableSpeakers, Dictionary assignedSpeakers, NPCConversation baseConversation, IList<(Character speaker, string line)> lineList, IList availableConversations, bool ignoreFlags = false) { IList 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((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 (!(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 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) { //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 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 } }