using System.Linq; using System.Text; using Content.Server.GameTicking.Events; using Content.Shared.Language; using Content.Shared.Language.Events; using Content.Shared.Language.Systems; using Robust.Shared.Random; using UniversalLanguageSpeakerComponent = Content.Shared.Language.Components.UniversalLanguageSpeakerComponent; namespace Content.Server.Language; public sealed partial class LanguageSystem : SharedLanguageSystem { // Static and re-used event instances used to minimize memory allocations during language processing, which can happen many times per tick. // These are used in the method GetLanguages and returned from it. They should never be mutated outside of that method or returned outside this system. private readonly DetermineEntityLanguagesEvent _determineLanguagesEvent = new(string.Empty, new(), new()), _universalLanguagesEvent = new(UniversalPrototype, [UniversalPrototype], [UniversalPrototype]); // Returned for universal speakers only /// /// A random number added to each pseudo-random number's seed. Changes every round. /// public int RandomRoundSeed { get; private set; } public override void Initialize() { base.Initialize(); SubscribeNetworkEvent(OnClientSetLanguage); SubscribeLocalEvent(OnInitLanguageSpeaker); SubscribeLocalEvent(_ => RandomRoundSeed = _random.Next()); InitializeNet(); } #region public api /// /// Obfuscate a message using an entity's default language. /// public string ObfuscateSpeech(EntityUid source, string message) { var language = GetLanguage(source) ?? Universal; return ObfuscateSpeech(message, language); } /// /// Obfuscate a message using the given language. /// public string ObfuscateSpeech(string message, LanguagePrototype language) { var builder = new StringBuilder(); if (language.ObfuscateSyllables) ObfuscateSyllables(builder, message, language); else ObfuscatePhrases(builder, message, language); return builder.ToString(); } public bool CanUnderstand(EntityUid listener, LanguagePrototype language, LanguageSpeakerComponent? listenerLanguageComp = null) { if (language.ID == UniversalPrototype || HasComp(listener)) return true; var listenerLanguages = GetLanguages(listener, listenerLanguageComp)?.UnderstoodLanguages; return listenerLanguages?.Contains(language.ID, StringComparer.Ordinal) ?? false; } public bool CanSpeak(EntityUid speaker, string language, LanguageSpeakerComponent? speakerComp = null) { if (HasComp(speaker)) return true; var langs = GetLanguages(speaker, speakerComp)?.UnderstoodLanguages; return langs?.Contains(language, StringComparer.Ordinal) ?? false; } /// /// Returns the current language of the given entity. /// Assumes Universal if not specified. /// public LanguagePrototype GetLanguage(EntityUid speaker, LanguageSpeakerComponent? languageComp = null) { var id = GetLanguages(speaker, languageComp)?.CurrentLanguage; if (id == null) return Universal; // Fallback _prototype.TryIndex(id, out LanguagePrototype? proto); return proto ?? Universal; } public void SetLanguage(EntityUid speaker, string language, LanguageSpeakerComponent? languageComp = null) { if (!CanSpeak(speaker, language) || HasComp(speaker)) return; if (languageComp == null && !TryComp(speaker, out languageComp)) return; if (languageComp.CurrentLanguage == language) return; languageComp.CurrentLanguage = language; RaiseLocalEvent(speaker, new LanguagesUpdateEvent(), true); } /// /// Adds a new language to the lists of understood and/or spoken languages of the given component. /// public void AddLanguage(LanguageSpeakerComponent comp, string language, bool addSpoken = true, bool addUnderstood = true) { if (addSpoken && !comp.SpokenLanguages.Contains(language)) comp.SpokenLanguages.Add(language); if (addUnderstood && !comp.UnderstoodLanguages.Contains(language)) comp.UnderstoodLanguages.Add(language); RaiseLocalEvent(comp.Owner, new LanguagesUpdateEvent(), true); } public (List spoken, List understood) GetAllLanguages(EntityUid speaker) { var languages = GetLanguages(speaker); // The lists need to be copied because the internal ones are re-used for performance reasons. return (new List(languages.SpokenLanguages), new List(languages.UnderstoodLanguages)); } /// /// Ensures the given entity has a valid language as its current language. /// If not, sets it to the first entry of its SpokenLanguages list, or universal if it's empty. /// public void EnsureValidLanguage(EntityUid entity, LanguageSpeakerComponent? comp = null) { if (comp == null && !TryComp(entity, out comp)) return; var langs = GetLanguages(entity, comp); if (!langs.SpokenLanguages.Contains(comp!.CurrentLanguage, StringComparer.Ordinal)) { comp.CurrentLanguage = langs.SpokenLanguages.FirstOrDefault(UniversalPrototype); RaiseLocalEvent(comp.Owner, new LanguagesUpdateEvent(), true); } } #endregion #region event handling private void OnInitLanguageSpeaker(EntityUid uid, LanguageSpeakerComponent component, ComponentInit args) { if (string.IsNullOrEmpty(component.CurrentLanguage)) component.CurrentLanguage = component.SpokenLanguages.FirstOrDefault(UniversalPrototype); } #endregion #region internal api - obfuscation private void ObfuscateSyllables(StringBuilder builder, string message, LanguagePrototype language) { // Go through each word. Calculate its hash sum and count the number of letters. // Replicate it with pseudo-random syllables of pseudo-random (but similar) length. Use the hash code as the seed. // This means that identical words will be obfuscated identically. Simple words like "hello" or "yes" in different langs can be memorized. var wordBeginIndex = 0; var hashCode = 0; for (var i = 0; i < message.Length; i++) { var ch = char.ToLower(message[i]); // A word ends when one of the following is found: a space, a sentence end, or EOM if (char.IsWhiteSpace(ch) || IsSentenceEnd(ch) || i == message.Length - 1) { var wordLength = i - wordBeginIndex; if (wordLength > 0) { var newWordLength = PseudoRandomNumber(hashCode, 1, 4); for (var j = 0; j < newWordLength; j++) { var index = PseudoRandomNumber(hashCode + j, 0, language.Replacement.Count); builder.Append(language.Replacement[index]); } } builder.Append(ch); hashCode = 0; wordBeginIndex = i + 1; } else hashCode = hashCode * 31 + ch; } } private void ObfuscatePhrases(StringBuilder builder, string message, LanguagePrototype language) { // In a similar manner, each phrase is obfuscated with a random number of conjoined obfuscation phrases. // However, the number of phrases depends on the number of characters in the original phrase. var sentenceBeginIndex = 0; for (var i = 0; i < message.Length; i++) { var ch = char.ToLower(message[i]); if (IsSentenceEnd(ch) || i == message.Length - 1) { var length = i - sentenceBeginIndex; if (length > 0) { var newLength = (int) Math.Clamp(Math.Cbrt(length) - 1, 1, 4); // 27+ chars for 2 phrases, 64+ for 3, 125+ for 4. for (var j = 0; j < newLength; j++) { var phrase = _random.Pick(language.Replacement); builder.Append(phrase); } } sentenceBeginIndex = i + 1; if (IsSentenceEnd(ch)) builder.Append(ch).Append(" "); } } } private static bool IsSentenceEnd(char ch) { return ch is '.' or '!' or '?'; } #endregion #region internal api - misc /// /// Dynamically resolves the current language of the entity and the list of all languages it speaks. /// /// If the entity is not a language speaker, or is a universal language speaker, then it's assumed to speak Universal, /// aka all languages at once and none at the same time. /// /// /// The returned event is reused and thus must not be held as a reference anywhere but inside the caller function. /// private DetermineEntityLanguagesEvent GetLanguages(EntityUid speaker, LanguageSpeakerComponent? comp = null) { // This is a shortcut for ghosts and entities that should not speak normally (admemes) if (HasComp(speaker) || !TryComp(speaker, out comp)) return _universalLanguagesEvent; var ev = _determineLanguagesEvent; ev.SpokenLanguages.Clear(); ev.UnderstoodLanguages.Clear(); ev.CurrentLanguage = comp.CurrentLanguage; ev.SpokenLanguages.AddRange(comp.SpokenLanguages); ev.UnderstoodLanguages.AddRange(comp.UnderstoodLanguages); RaiseLocalEvent(speaker, ev, true); if (ev.CurrentLanguage.Length == 0) ev.CurrentLanguage = !string.IsNullOrEmpty(comp.CurrentLanguage) ? comp.CurrentLanguage : UniversalPrototype; // Fall back to account for admemes like admins possessing a bread return ev; } /// /// Generates a stable pseudo-random number in the range (min, max) for the given seed. /// Each input seed corresponds to exactly one random number. /// private int PseudoRandomNumber(int seed, int min, int max) { // This is not a uniform distribution, but it shouldn't matter given there's 2^31 possible random numbers, // the bias of this function should be so tiny it will never be noticed. seed += RandomRoundSeed; var random = ((seed * 1103515245) + 12345) & 0x7fffffff; // Source: http://cs.uccs.edu/~cs591/bufferOverflow/glibc-2.2.4/stdlib/random_r.c return random % (max - min) + min; } /// /// Set CurrentLanguage of the client, the client must be able to Understand the language requested. /// private void OnClientSetLanguage(LanguagesSetMessage message, EntitySessionEventArgs args) { if (args.SenderSession.AttachedEntity is not {Valid: true} speaker) return; var language = GetLanguagePrototype(message.CurrentLanguage); if (language == null || !CanSpeak(speaker, language.ID)) return; SetLanguage(speaker, language.ID); } #endregion }