diff --git a/Content.Server/_CorvaxNext/Speech/Components/ParrotSpeechComponent.cs b/Content.Server/_CorvaxNext/Speech/Components/ParrotSpeechComponent.cs index 1ad09fe7067..b41ef786719 100644 --- a/Content.Server/_CorvaxNext/Speech/Components/ParrotSpeechComponent.cs +++ b/Content.Server/_CorvaxNext/Speech/Components/ParrotSpeechComponent.cs @@ -1,40 +1,74 @@ -using Content.Server.Speech.EntitySystems; using Content.Shared.Whitelist; +using Content.Server.Speech.EntitySystems; namespace Content.Server.Speech.Components; +/// +/// This component stores the parrot's learned phrases (both single words and multi-word phrases), +/// and also controls time intervals and learning probabilities. +/// [RegisterComponent] [Access(typeof(ParrotSpeechSystem))] public sealed partial class ParrotSpeechComponent : Component { /// - /// The maximum number of words the parrot can learn per phrase. - /// Phrases are 1 to MaxPhraseLength words in length. + /// The maximum number of words in a generated phrase if the parrot decides to combine single words. /// [DataField] public int MaximumPhraseLength = 7; + /// + /// The maximum amount of single-word phrases the parrot can store. + /// [DataField] - public int MaximumPhraseCount = 20; + public int MaximumSingleWordCount = 60; + /// + /// The maximum amount of multi-word phrases the parrot can store. + /// [DataField] - public int MinimumWait = 60; // 1 minutes + public int MaximumMultiWordCount = 20; + /// + /// Minimum delay (in seconds) before the next utterance. + /// + [DataField] + public int MinimumWait = 60; // 1 minute + + /// + /// Maximum delay (in seconds) before the next utterance. + /// [DataField] public int MaximumWait = 120; // 2 minutes /// - /// The probability that a parrot will learn from something an overheard phrase. + /// Probability that the parrot learns an overheard phrase. /// [DataField] public float LearnChance = 0.2f; + /// + /// List of entities that are blacklisted from parrot listening. + /// If the entity is in the blacklist, the parrot won't learn from them. + /// [DataField] public EntityWhitelist Blacklist { get; private set; } = new(); - [DataField] - public TimeSpan? NextUtterance; + /// + /// Set of single-word phrases (unique words) the parrot has learned. + /// + [DataField(readOnly: true)] + public HashSet SingleWordPhrases = new(); + /// + /// Set of multi-word phrases (2 or more words) the parrot has learned. + /// [DataField(readOnly: true)] - public List LearnedPhrases = new(); + public HashSet MultiWordPhrases = new(); + + /// + /// The next time the parrot will speak (when the current time is beyond this value). + /// + [DataField] + public TimeSpan? NextUtterance; } diff --git a/Content.Server/_CorvaxNext/Speech/EntitySystems/ParrotSpeechSystem.cs b/Content.Server/_CorvaxNext/Speech/EntitySystems/ParrotSpeechSystem.cs index 940a9a72ed5..1516c6c0850 100644 --- a/Content.Server/_CorvaxNext/Speech/EntitySystems/ParrotSpeechSystem.cs +++ b/Content.Server/_CorvaxNext/Speech/EntitySystems/ParrotSpeechSystem.cs @@ -8,6 +8,10 @@ namespace Content.Server.Speech.EntitySystems; +/// +/// This system handles the learning (when the parrot hears a phrase) and +/// the random utterances (when the parrot speaks). +/// public sealed class ParrotSpeechSystem : EntitySystem { [Dependency] private readonly IGameTiming _timing = default!; @@ -20,7 +24,7 @@ public override void Initialize() base.Initialize(); SubscribeLocalEvent(OnListen); - SubscribeLocalEvent(CanListen); + SubscribeLocalEvent(OnListenAttempt); } public override void Update(float frameTime) @@ -28,58 +32,254 @@ public override void Update(float frameTime) var query = EntityQueryEnumerator(); while (query.MoveNext(out var uid, out var component)) { - if (component.LearnedPhrases.Count == 0) - // This parrot has not learned any phrases, so can't say anything interesting. + // If the parrot has not learned anything, skip + if (component.SingleWordPhrases.Count == 0 && component.MultiWordPhrases.Count == 0) continue; + + // If parrot is controlled by a player (has Mind), skip if (TryComp(uid, out var mind) && mind.HasMind) - // Pause parrot speech when someone is controlling the parrot. continue; + + // Check the time to speak if (_timing.CurTime < component.NextUtterance) continue; - if (component.NextUtterance is not null) + // Construct a phrase the parrot will say + var phrase = PickRandomPhrase(component); + if (string.IsNullOrWhiteSpace(phrase)) + continue; + + // Send the phrase to the chat system (hidden from chat/log to avoid spam) + _chat.TrySendInGameICMessage(uid, phrase, InGameICChatType.Speak, + hideChat: true, + hideLog: true, + checkRadioPrefix: false); + + // Reset next utterance time + component.NextUtterance = _timing.CurTime + + TimeSpan.FromSeconds(_random.Next(component.MinimumWait, component.MaximumWait)); + } + } + + /// + /// Picks a random phrase to utter. May be a single word, a multi-word phrase, or + /// a combination of single words (up to MaximumPhraseLength). + /// + private string PickRandomPhrase(ParrotSpeechComponent component) + { + var singleCount = component.SingleWordPhrases.Count; + var multiCount = component.MultiWordPhrases.Count; + if (singleCount == 0 && multiCount == 0) + return string.Empty; + + // 1) If we only have single words, use single approach + // 2) If we only have multi-word phrases, use multi approach + // 3) Otherwise, pick randomly among: + // a) Single word + // b) Full multi-word phrase + // c) Combined single words + + bool haveSingle = singleCount > 0; + bool haveMulti = multiCount > 0; + + if (haveSingle && !haveMulti) + { + // Only single words exist + return PickSingleWordOrCombine(component); + } + else if (!haveSingle && haveMulti) + { + // Only multi-word phrases exist + return PickRandomMultiWord(component); + } + else + { + // We have both single and multi, choose approach + var roll = _random.Next(3); // 0..2 + switch (roll) { - _chat.TrySendInGameICMessage( - uid, - _random.Pick(component.LearnedPhrases), - InGameICChatType.Speak, - hideChat: true, // Don't spam the chat with randomly generated messages - hideLog: true, // TODO: Don't spam admin logs either. - // If a parrot learns something inappropriate, admins can search for - // the player that said the inappropriate thing. - checkRadioPrefix: false); + case 0: + // single word + return PickSingleWord(component); + case 1: + // multi-word phrase + return PickRandomMultiWord(component); + default: + // combined single words + return CombineMultipleWords(component); } + } + } - component.NextUtterance = _timing.CurTime + TimeSpan.FromSeconds(_random.Next(component.MinimumWait, component.MaximumWait)); + /// + /// If we only have single words, we can either speak a single one or combine them. + /// + private string PickSingleWordOrCombine(ParrotSpeechComponent component) + { + // 50% chance single word, 50% chance combine + if (_random.Prob(0.5f)) + { + return PickSingleWord(component); } + else + { + return CombineMultipleWords(component); + } + } + + /// + /// Picks a random single word from SingleWordPhrases. + /// + private string PickSingleWord(ParrotSpeechComponent component) + { + var list = component.SingleWordPhrases.ToList(); + return _random.Pick(list); } + /// + /// Picks a random multi-word phrase from MultiWordPhrases. + /// + private string PickRandomMultiWord(ParrotSpeechComponent component) + { + var list = component.MultiWordPhrases.ToList(); + return _random.Pick(list); + } + + /// + /// Combines multiple single words (up to MaximumPhraseLength) into one phrase. + /// The length is random from 1 to max, but not exceeding the total single words we have. + /// + private string CombineMultipleWords(ParrotSpeechComponent component) + { + var countAvailable = component.SingleWordPhrases.Count; + if (countAvailable == 0) + return string.Empty; + + var maxCount = Math.Min(countAvailable, component.MaximumPhraseLength); + var wordsToUse = _random.Next(1, maxCount + 1); + + var list = component.SingleWordPhrases.ToList(); + _random.Shuffle(list); + + var shuffled = list.Take(wordsToUse); + var combined = string.Join(" ", shuffled); + return combined; + } + + /// + /// This event is triggered when the parrot hears someone speaking. If allowed, the parrot may learn it. + /// Now we remove punctuation from the message, split into words, pick a random sub-chunk, and save both: + /// - The whole sub-chunk as single or multi-word phrase + /// - Each word from that sub-chunk as a single word + /// private void OnListen(EntityUid uid, ParrotSpeechComponent component, ref ListenEvent args) { - if (_random.Prob(component.LearnChance)) - { - // Very approximate word splitting. But that's okay: parrots aren't smart enough to - // split words correctly. - var words = args.Message.Split(" ", StringSplitOptions.RemoveEmptyEntries); - // Prefer longer phrases - var phraseLength = 1 + (int) (Math.Sqrt(_random.NextDouble()) * component.MaximumPhraseLength); + // Random chance to learn + if (!_random.Prob(component.LearnChance)) + return; - var startIndex = _random.Next(0, Math.Max(0, words.Length - phraseLength + 1)); + // 1) Remove punctuation (replace it with spaces), convert to lower-case + var cleaned = RemovePunctuationAndToLower(args.Message); - var phrase = string.Join(" ", words.Skip(startIndex).Take(phraseLength)).ToLower(); + // 2) Split into words + var words = cleaned.Split(" ", StringSplitOptions.RemoveEmptyEntries); + if (words.Length == 0) + return; - while (component.LearnedPhrases.Count >= component.MaximumPhraseCount) - { - _random.PickAndTake(component.LearnedPhrases); - } + // 3) Decide how many words we pick from the overheard message + var phraseLength = 1 + (int)(Math.Sqrt(_random.NextDouble()) * component.MaximumPhraseLength); + if (phraseLength > words.Length) + phraseLength = words.Length; + + // 4) Pick a random start index + var startIndex = _random.Next(0, Math.Max(1, words.Length - phraseLength + 1)); + var chunk = words.Skip(startIndex).Take(phraseLength).ToArray(); - component.LearnedPhrases.Add(phrase); + // 5) If chunk has only 1 word, store it as single word + // otherwise store it as a multi-word phrase + if (chunk.Length == 1) + { + LearnSingleWord(chunk[0], component); + } + else + { + var phrase = string.Join(" ", chunk); + LearnMultiWord(phrase, component); + } + + // 6) Independently, store all words of that chunk as single words (no duplicates) + foreach (var w in chunk) + { + LearnSingleWord(w, component); } } - private void CanListen(EntityUid uid, ParrotSpeechComponent component, ref ListenAttemptEvent args) + /// + /// Checks if the source is blacklisted. If so, the parrot won't listen. + /// + private void OnListenAttempt(EntityUid uid, ParrotSpeechComponent component, ref ListenAttemptEvent args) { if (_whitelistSystem.IsBlacklistPass(component.Blacklist, args.Source)) args.Cancel(); } + + /// + /// Adds a single word into the SingleWordPhrases set, removing a random word if we exceed the limit. + /// + private void LearnSingleWord(string word, ParrotSpeechComponent component) + { + // If we already have it, skip + if (component.SingleWordPhrases.Contains(word)) + return; + + // If we exceed maximum, remove a random single word + if (component.SingleWordPhrases.Count >= component.MaximumSingleWordCount) + { + var list = component.SingleWordPhrases.ToList(); + var toRemove = _random.Pick(list); + component.SingleWordPhrases.Remove(toRemove); + } + + component.SingleWordPhrases.Add(word); + } + + /// + /// Adds a multi-word phrase into the MultiWordPhrases set, removing a random phrase if we exceed the limit. + /// + private void LearnMultiWord(string phrase, ParrotSpeechComponent component) + { + // If we already have it, skip + if (component.MultiWordPhrases.Contains(phrase)) + return; + + // If we exceed maximum, remove a random multi-word phrase + if (component.MultiWordPhrases.Count >= component.MaximumMultiWordCount) + { + var list = component.MultiWordPhrases.ToList(); + var toRemove = _random.Pick(list); + component.MultiWordPhrases.Remove(toRemove); + } + + component.MultiWordPhrases.Add(phrase); + } + + /// + /// Replaces all punctuation with spaces and returns a lower-cased string. + /// E.g. "Hello, world! I'm here." => "hello world i m here " + /// then trimmed/split => "hello", "world", "i", "m", "here" + /// + private string RemovePunctuationAndToLower(string text) + { + var chars = text.ToCharArray(); + for (var i = 0; i < chars.Length; i++) + { + if (char.IsPunctuation(chars[i])) + { + chars[i] = ' '; + } + } + + // Convert to lower case + return new string(chars).ToLowerInvariant(); + } } diff --git a/Resources/Prototypes/_CorvaxNext/Entities/Mobs/NPCs/animals.yml b/Resources/Prototypes/_CorvaxNext/Entities/Mobs/NPCs/animals.yml index 8ef42e980e0..12bcb79c2c7 100644 --- a/Resources/Prototypes/_CorvaxNext/Entities/Mobs/NPCs/animals.yml +++ b/Resources/Prototypes/_CorvaxNext/Entities/Mobs/NPCs/animals.yml @@ -14,13 +14,13 @@ - map: ["enum.DamageStateVisualLayers.Base", "movement"] state: parrot-moving # until upstream sprite: _CorvaxNext/Mobs/Animals/parrot.rsi - - type: SpriteMovement - movementLayers: - movement: - state: parrot-moving - noMovementLayers: - movement: - state: parrot-moving # until upstream + # - type: SpriteMovement # until upstream + # movementLayers: + # movement: + # state: parrot-moving + # noMovementLayers: + # movement: + # state: parrot-moving - type: Item sprite: _CorvaxNext/Mobs/Animals/parrot.rsi size: Normal @@ -34,6 +34,7 @@ sprite: _CorvaxNext/Mobs/Animals/parrot.rsi slots: - HEAD + - NECK - type: Fixtures fixtures: fix1: diff --git a/Resources/Textures/_CorvaxNext/Mobs/Animals/parrot.rsi/equipped-NECK.png b/Resources/Textures/_CorvaxNext/Mobs/Animals/parrot.rsi/equipped-NECK.png new file mode 100644 index 00000000000..668decbb7e6 Binary files /dev/null and b/Resources/Textures/_CorvaxNext/Mobs/Animals/parrot.rsi/equipped-NECK.png differ diff --git a/Resources/Textures/_CorvaxNext/Mobs/Animals/parrot.rsi/meta.json b/Resources/Textures/_CorvaxNext/Mobs/Animals/parrot.rsi/meta.json index e0d80415b48..976e23f348b 100644 --- a/Resources/Textures/_CorvaxNext/Mobs/Animals/parrot.rsi/meta.json +++ b/Resources/Textures/_CorvaxNext/Mobs/Animals/parrot.rsi/meta.json @@ -31,6 +31,10 @@ "name": "equipped-HELMET", "directions": 4 }, + { + "name": "equipped-NECK", + "directions": 4 + }, { "name": "inhand-left", "directions": 4