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