]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Parroting Parrots part 1: Help maints! SQUAWK! Maints! (#38243)
authorCrude Oil <124208219+CroilBird@users.noreply.github.com>
Wed, 9 Jul 2025 19:04:57 +0000 (21:04 +0200)
committerGitHub <noreply@github.com>
Wed, 9 Jul 2025 19:04:57 +0000 (12:04 -0700)
* parrots have ears. add poly

* high tech parrot functionality

* adjust times

* add accent to radio message

* don't spam everything all at once probably

* learn about the existence of prob(float)

* actually use Prob(float) correctly

* newline

* add pet spawner for poly

* move chance to talk on radio to component

* missing comment

* minor edits and doc additions

* the reviewerrrrrrr

* parrot can't learn when crit or dead

* increase default memory

* rename poly to polly

* crude way to ignore whispers. chatcode please

* This is Polly. It is set to broadcast over the engineering frequency

* add missing initialize

* add displacement map for parrot ears

* review comments - Errant

* minor things

* large rework

* fix attempting to talk when entity has no channels

* use list of active radios again to track channels

* fix bad return, some comments

* fix long learn cooldown

* minor adjustments

* use FromMinutes

* the voices told me to make these changes

* remove default reassignment

* Review changes

* remove polly's accent

* decouple radio stuff from parrotsystem

* minor stuff

* split vocalization and parroting

* minor review work

* re-add missing check

* add admin verb for clearing parrot messages

* minor action icon update

* oops

* increase icon number text size

* Admin erase parrot messages associated with players

* part 1 beck review

* add whitelist and blacklist for parrots

* Downgrade missing component error to warning

* Add comment

* add some missing comments

* Remove active radio entity tracking, use all inventory slots

* Minor changes

* small review stuff

* review radio stuff

* swap ears displacement to invisible death displacement

* remove syncsprite

* vscode why do yo have to hurt my feelings

* review changes

* use checkboth

27 files changed:
Content.Server/Administration/Systems/AdminSystem.cs
Content.Server/Animals/Components/ParrotListenerComponent.cs [new file with mode: 0644]
Content.Server/Animals/Components/ParrotMemoryComponent.cs [new file with mode: 0644]
Content.Server/Animals/Systems/ParrotMemorySystem.cs [new file with mode: 0644]
Content.Server/Radio/EntitySystems/HeadsetSystem.cs
Content.Server/Radio/RadioEvent.cs
Content.Server/Vocalization/Components/RadioVocalizerComponent.cs [new file with mode: 0644]
Content.Server/Vocalization/Components/VocalizerComponent.cs [new file with mode: 0644]
Content.Server/Vocalization/Systems/RadioVocalizationSystem.cs [new file with mode: 0644]
Content.Server/Vocalization/Systems/VocalizationSystem.cs [new file with mode: 0644]
Resources/Locale/en-US/animals/parrot/parrot.ftl [new file with mode: 0644]
Resources/Prototypes/Catalog/Cargo/cargo_livestock.yml
Resources/Prototypes/Entities/Markers/Spawners/Mobs/animals.yml
Resources/Prototypes/Entities/Markers/Spawners/Mobs/pets.yml
Resources/Prototypes/Entities/Mobs/NPCs/animals.yml
Resources/Prototypes/Entities/Mobs/NPCs/pets.yml
Resources/Prototypes/InventoryTemplates/parrot_inventory_template.yml [new file with mode: 0644]
Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml
Resources/Textures/Interface/AdminActions/clear-parrot.png [new file with mode: 0644]
Resources/Textures/Mobs/Animals/parrot.rsi/parrot.png [deleted file]
Resources/Textures/Mobs/Animals/parrot/displacement.rsi/ears.png [new file with mode: 0644]
Resources/Textures/Mobs/Animals/parrot/displacement.rsi/meta.json [new file with mode: 0644]
Resources/Textures/Mobs/Animals/parrot/parrot.rsi/dead.png [moved from Resources/Textures/Mobs/Animals/parrot.rsi/dead.png with 100% similarity]
Resources/Textures/Mobs/Animals/parrot/parrot.rsi/icon.png [moved from Resources/Textures/Mobs/Animals/parrot.rsi/icon.png with 100% similarity]
Resources/Textures/Mobs/Animals/parrot/parrot.rsi/meta.json [moved from Resources/Textures/Mobs/Animals/parrot.rsi/meta.json with 100% similarity]
Resources/Textures/Mobs/Animals/parrot/parrot.rsi/parrot.png [new file with mode: 0644]
Resources/Textures/Mobs/Animals/parrot/parrot.rsi/sit.png [moved from Resources/Textures/Mobs/Animals/parrot.rsi/sit.png with 100% similarity]

index 56bbc225cc2e1cb3f8925fa4a84029e77c421062..78433db12945b45bf51856a52ec0ab8fe5ea8e28 100644 (file)
@@ -383,8 +383,13 @@ public sealed class AdminSystem : EntitySystem
         {
             _chat.DeleteMessagesBy(uid);
 
+            var eraseEvent = new EraseEvent(uid);
+
             if (!_minds.TryGetMind(uid, out var mindId, out var mind) || mind.OwnedEntity == null || TerminatingOrDeleted(mind.OwnedEntity.Value))
+            {
+                RaiseLocalEvent(ref eraseEvent);
                 return;
+            }
 
             var entity = mind.OwnedEntity.Value;
 
@@ -444,6 +449,8 @@ public sealed class AdminSystem : EntitySystem
 
             if (_playerManager.TryGetSessionById(uid, out var session))
                 _gameTicker.SpawnObserver(session);
+
+            RaiseLocalEvent(ref eraseEvent);
         }
 
     private void OnSessionPlayTimeUpdated(ICommonSession session)
@@ -451,3 +458,10 @@ public sealed class AdminSystem : EntitySystem
         UpdatePlayerList(session);
     }
 }
+
+/// <summary>
+/// Event fired after a player is erased by an admin
+/// </summary>
+/// <param name="PlayerNetUserId">NetUserId of the player that was the target of the Erase</param>
+[ByRefEvent]
+public record struct EraseEvent(NetUserId PlayerNetUserId);
diff --git a/Content.Server/Animals/Components/ParrotListenerComponent.cs b/Content.Server/Animals/Components/ParrotListenerComponent.cs
new file mode 100644 (file)
index 0000000..9d0c8a2
--- /dev/null
@@ -0,0 +1,32 @@
+using Content.Shared.Whitelist;
+
+namespace Content.Server.Animals.Components;
+
+/// <summary>
+/// Makes an entity able to listen to messages from IC chat and attempt to commit them to memory
+/// </summary>
+[RegisterComponent]
+public sealed partial class ParrotListenerComponent : Component
+{
+    /// <summary>
+    /// Whitelist for purposes of limiting which entities a parrot will listen to
+    ///
+    /// This is here because parrots can learn via local chat or radio from other parrots. this can quickly devolve from
+    /// SQUAWK! Polly wants a cracker! BRAAWK
+    /// to
+    /// BRAAWK! SQUAWK! RAWWK! Polly wants a cracker! AAWK! AWWK! Cracker! SQUAWK! BRAWWK! SQUAWK!
+    /// This is limited somewhat by the message length limit on ParrotMemoryComponent, but can be prevented entirely here
+    /// </summary>
+    [DataField]
+    public EntityWhitelist? Whitelist;
+
+    /// <summary>
+    /// Blacklist for purposes of ignoring entities
+    /// As above, this is here to force parrots to ignore certain entities.
+    /// For example, polly will be consistently mapped around EngiDrobes, which will consistently say stuff like
+    /// "Guaranteed to protect your feet from industrial accidents!"
+    /// If polly ends up constantly advertising engineering drip, this can be used to prevent it.
+    /// </summary>
+    [DataField]
+    public EntityWhitelist? Blacklist;
+}
diff --git a/Content.Server/Animals/Components/ParrotMemoryComponent.cs b/Content.Server/Animals/Components/ParrotMemoryComponent.cs
new file mode 100644 (file)
index 0000000..2c3771c
--- /dev/null
@@ -0,0 +1,57 @@
+using Robust.Shared.Network;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Server.Animals.Components;
+
+/// <summary>
+/// Makes an entity able to memorize chat/radio messages
+/// </summary>
+[RegisterComponent]
+[AutoGenerateComponentPause]
+public sealed partial class ParrotMemoryComponent : Component
+{
+    /// <summary>
+    /// List of SpeechMemory records this entity has learned
+    /// </summary>
+    [DataField]
+    public List<SpeechMemory> SpeechMemories = [];
+
+    /// <summary>
+    /// The % chance an entity with this component learns a phrase when learning is off cooldown
+    /// </summary>
+    [DataField]
+    public float LearnChance = 0.4f;
+
+    /// <summary>
+    /// Time after which another attempt can be made at learning a phrase
+    /// </summary>
+    [DataField]
+    public TimeSpan LearnCooldown = TimeSpan.FromMinutes(1);
+
+    /// <summary>
+    /// Next time at which the parrot can attempt to learn something
+    /// </summary>
+    [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
+    [AutoPausedField]
+    public TimeSpan NextLearnInterval = TimeSpan.Zero;
+
+    /// <summary>
+    /// The number of speech entries that are remembered
+    /// </summary>
+    [DataField]
+    public int MaxSpeechMemory = 50;
+
+    /// <summary>
+    /// Minimum length of a speech entry
+    /// </summary>
+    [DataField]
+    public int MinEntryLength = 4;
+
+    /// <summary>
+    /// Maximum length of a speech entry
+    /// </summary>
+    [DataField]
+    public int MaxEntryLength = 50;
+}
+
+public record struct SpeechMemory(NetUserId? NetUserId, string Message);
diff --git a/Content.Server/Animals/Systems/ParrotMemorySystem.cs b/Content.Server/Animals/Systems/ParrotMemorySystem.cs
new file mode 100644 (file)
index 0000000..eb34298
--- /dev/null
@@ -0,0 +1,247 @@
+using Content.Server.Administration.Logs;
+using Content.Server.Administration.Managers;
+using Content.Server.Administration.Systems;
+using Content.Server.Animals.Components;
+using Content.Server.Mind;
+using Content.Server.Popups;
+using Content.Server.Radio;
+using Content.Server.Speech;
+using Content.Server.Speech.Components;
+using Content.Server.Vocalization.Systems;
+using Content.Shared.Database;
+using Content.Shared.Mobs.Systems;
+using Content.Shared.Popups;
+using Content.Shared.Verbs;
+using Content.Shared.Whitelist;
+using Robust.Shared.Network;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Server.Animals.Systems;
+
+/// <summary>
+/// The ParrotMemorySystem handles remembering messages received through local chat (activelistener) or a radio
+/// (radiovocalizer) and stores them in a list. When an entity with a VocalizerComponent attempts to vocalize, this will
+/// try to set the message from memory.
+/// </summary>
+public sealed partial class ParrotMemorySystem : EntitySystem
+{
+    [Dependency] private readonly EntityWhitelistSystem _whitelist = default!;
+    [Dependency] private readonly IAdminLogManager _adminLogger = default!;
+    [Dependency] private readonly IAdminManager _admin = default!;
+    [Dependency] private readonly IGameTiming _gameTiming = default!;
+    [Dependency] private readonly IRobustRandom _random = default!;
+    [Dependency] private readonly MindSystem _mind = default!;
+    [Dependency] private readonly MobStateSystem _mobState = default!;
+    [Dependency] private readonly PopupSystem _popup = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<EraseEvent>(OnErase);
+
+        SubscribeLocalEvent<ParrotMemoryComponent, GetVerbsEvent<Verb>>(OnGetVerbs);
+
+        SubscribeLocalEvent<ParrotListenerComponent, MapInitEvent>(ListenerOnMapInit);
+
+        SubscribeLocalEvent<ParrotListenerComponent, ListenEvent>(OnListen);
+        SubscribeLocalEvent<ParrotListenerComponent, HeadsetRadioReceiveRelayEvent>(OnHeadsetReceive);
+
+        SubscribeLocalEvent<ParrotMemoryComponent, TryVocalizeEvent>(OnTryVocalize);
+    }
+
+    private void OnErase(ref EraseEvent args)
+    {
+        DeletePlayerMessages(args.PlayerNetUserId);
+    }
+
+    private void OnGetVerbs(Entity<ParrotMemoryComponent> entity, ref GetVerbsEvent<Verb> args)
+    {
+        var user = args.User;
+
+        // limit this to admins
+        if (!_admin.IsAdmin(user))
+            return;
+
+        // simple verb that just clears the memory list
+        var clearMemoryVerb = new Verb()
+        {
+            Text = Loc.GetString("parrot-verb-clear-memory"),
+            Category = VerbCategory.Admin,
+            Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/AdminActions/clear-parrot.png")),
+            Act = () =>
+            {
+                entity.Comp.SpeechMemories.Clear();
+                _popup.PopupEntity(Loc.GetString("parrot-popup-memory-cleared"), entity, user, PopupType.Medium);
+            },
+        };
+
+        args.Verbs.Add(clearMemoryVerb);
+    }
+
+    private void ListenerOnMapInit(Entity<ParrotListenerComponent> entity, ref MapInitEvent args)
+    {
+        // If an entity has a ParrotListenerComponent it really ought to have an ActiveListenerComponent
+        if (!HasComp<ActiveListenerComponent>(entity))
+            Log.Warning($"Entity {ToPrettyString(entity)} has a ParrotListenerComponent but was not given an ActiveListenerComponent");
+    }
+
+    private void OnListen(Entity<ParrotListenerComponent> entity, ref ListenEvent args)
+    {
+
+        TryLearn(entity.Owner, args.Message, args.Source);
+    }
+
+    private void OnHeadsetReceive(Entity<ParrotListenerComponent> entity, ref HeadsetRadioReceiveRelayEvent args)
+    {
+        var message = args.RelayedEvent.Message;
+        var source = args.RelayedEvent.MessageSource;
+
+        TryLearn(entity.Owner, message, source);
+    }
+
+    /// <summary>
+    /// Called when an entity with a ParrotMemoryComponent tries to vocalize.
+    /// This function picks a message from memory and sets the event to handled
+    /// </summary>
+    private void OnTryVocalize(Entity<ParrotMemoryComponent> entity, ref TryVocalizeEvent args)
+    {
+        // return if this was already handled
+        if (args.Handled)
+            return;
+
+        // if there are no memories, return
+        if (entity.Comp.SpeechMemories.Count == 0)
+            return;
+
+        // get a random memory from the memory list
+        var memory = _random.Pick(entity.Comp.SpeechMemories);
+
+        args.Message = memory.Message;
+        args.Handled = true;
+    }
+
+    /// <summary>
+    /// Try to learn a new message, returning early if this entity cannot learn a new message,
+    /// the message doesn't pass certain checks, or the chance for learning a new message fails
+    /// </summary>
+    /// <param name="entity">Entity learning a new word</param>
+    /// <param name="incomingMessage">Message to learn</param>
+    /// <param name="source">Source EntityUid of the message</param>
+    public void TryLearn(Entity<ParrotMemoryComponent?, ParrotListenerComponent?> entity, string incomingMessage, EntityUid source)
+    {
+        if (!Resolve(entity, ref entity.Comp1, ref entity.Comp2))
+            return;
+
+        if (!_whitelist.CheckBoth(source, entity.Comp2.Blacklist, entity.Comp2.Whitelist))
+            return;
+
+        if (source.Equals(entity) || _mobState.IsIncapacitated(entity))
+            return;
+
+        // can't learn too soon after having already learnt something else
+        if (_gameTiming.CurTime < entity.Comp1.NextLearnInterval)
+            return;
+
+        // remove whitespace around message, if any
+        var message = incomingMessage.Trim();
+
+        // ignore messages containing tildes. This is a crude way to ignore whispers that are too far away
+        // TODO: this isn't great. This should be replaced with a const or we should have a better way to check faraway messages
+        if (message.Contains('~'))
+            return;
+
+        // ignore empty messages. These probably aren't sent anyway but just in case
+        if (string.IsNullOrWhiteSpace(message))
+            return;
+
+        // ignore messages that are too short or too long
+        if (message.Length < entity.Comp1.MinEntryLength || message.Length > entity.Comp1.MaxEntryLength)
+            return;
+
+        // only from this point this message has a chance of being learned
+        // set new time for learn interval, regardless of whether the learning succeeds
+        entity.Comp1.NextLearnInterval = _gameTiming.CurTime + entity.Comp1.LearnCooldown;
+
+        // decide if this message passes the learning chance
+        if (!_random.Prob(entity.Comp1.LearnChance))
+            return;
+
+        // actually commit this message to memory
+        Learn((entity, entity.Comp1), message, source);
+    }
+
+    /// <summary>
+    /// Actually learn a message and commit it to memory
+    /// </summary>
+    /// <param name="entity">Entity learning a new word</param>
+    /// <param name="message">Message to learn</param>
+    /// <param name="source">Source EntityUid of the message</param>
+    private void Learn(Entity<ParrotMemoryComponent> entity, string message, EntityUid source)
+    {
+        // log a low-priority chat type log to the admin logger
+        // specifies what message was learnt by what entity, and who taught the message to that entity
+        _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Parroting entity {ToPrettyString(entity):entity} learned the phrase \"{message}\" from {ToPrettyString(source):speaker}");
+
+        NetUserId? sourceNetUserId = null;
+        if (_mind.TryGetMind(source, out _, out var mind))
+        {
+            sourceNetUserId = mind.UserId;
+        }
+
+        var newMemory = new SpeechMemory(sourceNetUserId, message);
+
+        // add a new message if there is space in the memory
+        if (entity.Comp.SpeechMemories.Count < entity.Comp.MaxSpeechMemory)
+        {
+            entity.Comp.SpeechMemories.Add(newMemory);
+            return;
+        }
+
+        // if there's no space in memory, replace something at random
+        var replaceIdx = _random.Next(entity.Comp.SpeechMemories.Count);
+        entity.Comp.SpeechMemories[replaceIdx] = newMemory;
+    }
+
+    /// <summary>
+    /// Delete all messages from a specified player on all ParrotMemoryComponents
+    /// </summary>
+    /// <param name="playerNetUserId">The player of whom to delete messages</param>
+    private void DeletePlayerMessages(NetUserId playerNetUserId)
+    {
+        // query to enumerate all entities with a memorycomponent
+        var query = EntityQueryEnumerator<ParrotMemoryComponent>();
+        while (query.MoveNext(out _, out var memory))
+        {
+            DeletePlayerMessages(memory, playerNetUserId);
+        }
+    }
+
+    /// <summary>
+    /// Delete all messages from a specified player on a given ParrotMemoryComponent
+    /// </summary>
+    /// <param name="memoryComponent">The ParrotMemoryComponent on which to delete messages</param>
+    /// <param name="playerNetUserId">The player of whom to delete messages</param>
+    private void DeletePlayerMessages(ParrotMemoryComponent memoryComponent, NetUserId playerNetUserId)
+    {
+        // this is a sort of expensive operation that is hopefully rare and performed on just a few parrots
+        // with limited memory
+        for (var i = 0; i < memoryComponent.SpeechMemories.Count; i++)
+        {
+            var memory = memoryComponent.SpeechMemories[i];
+
+            // netuserid may be null if the message was learnt from a non-player entity
+            if (memory.NetUserId is null)
+                continue;
+
+            // skip if this memory was not learnt from the target user
+            if (!memory.NetUserId.Equals(playerNetUserId))
+                continue;
+
+            // order isn't important in this list so we can use the faster means of removing
+            memoryComponent.SpeechMemories.RemoveSwap(i);
+        }
+    }
+}
index d18b044205c0e0385d444c76fa53381c1f39ebc8..e3f80703110b03995afdc6e7d2a19cbbcd4c14b9 100644 (file)
@@ -99,7 +99,19 @@ public sealed class HeadsetSystem : SharedHeadsetSystem
 
     private void OnHeadsetReceive(EntityUid uid, HeadsetComponent component, ref RadioReceiveEvent args)
     {
-        if (TryComp(Transform(uid).ParentUid, out ActorComponent? actor))
+        // TODO: change this when a code refactor is done
+        // this is currently done this way because receiving radio messages on an entity otherwise requires that entity
+        // to have an ActiveRadioComponent
+
+        var parent = Transform(uid).ParentUid;
+
+        if (parent.IsValid())
+        {
+            var relayEvent = new HeadsetRadioReceiveRelayEvent(args);
+            RaiseLocalEvent(parent, ref relayEvent);
+        }
+
+        if (TryComp(parent, out ActorComponent? actor))
             _netMan.ServerSendMessage(args.ChatMsg, actor.PlayerSession.Channel);
     }
 
index fafa66674e3d5e2d73a3eaec9604e8d28fb809eb..49ff63f824ee7d3d31c0280e69f01648a3d8e890 100644 (file)
@@ -6,6 +6,12 @@ namespace Content.Server.Radio;
 [ByRefEvent]
 public readonly record struct RadioReceiveEvent(string Message, EntityUid MessageSource, RadioChannelPrototype Channel, EntityUid RadioSource, MsgChatMessage ChatMsg);
 
+/// <summary>
+/// Event raised on the parent entity of a headset radio when a radio message is received
+/// </summary>
+[ByRefEvent]
+public readonly record struct HeadsetRadioReceiveRelayEvent(RadioReceiveEvent RelayedEvent);
+
 /// <summary>
 /// Use this event to cancel sending message per receiver
 /// </summary>
diff --git a/Content.Server/Vocalization/Components/RadioVocalizerComponent.cs b/Content.Server/Vocalization/Components/RadioVocalizerComponent.cs
new file mode 100644 (file)
index 0000000..b2414f3
--- /dev/null
@@ -0,0 +1,14 @@
+namespace Content.Server.Vocalization.Components;
+
+/// <summary>
+/// Makes an entity able to vocalize through an equipped radio
+/// </summary>
+[RegisterComponent]
+public sealed partial class RadioVocalizerComponent : Component
+{
+    /// <summary>
+    /// chance the vocalizing entity speaks on the radio.
+    /// </summary>
+    [DataField]
+    public float RadioAttemptChance = 0.3f;
+}
diff --git a/Content.Server/Vocalization/Components/VocalizerComponent.cs b/Content.Server/Vocalization/Components/VocalizerComponent.cs
new file mode 100644 (file)
index 0000000..0dfde97
--- /dev/null
@@ -0,0 +1,30 @@
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Server.Vocalization.Components;
+
+/// <summary>
+/// Makes an entity vocalize at set intervals
+/// </summary>
+[RegisterComponent]
+[AutoGenerateComponentPause]
+public sealed partial class VocalizerComponent : Component
+{
+    /// <summary>
+    /// Minimum time to wait after speaking to vocalize again
+    /// </summary>
+    [DataField]
+    public TimeSpan MinVocalizeInterval = TimeSpan.FromMinutes(2);
+
+    /// <summary>
+    /// Maximum time to wait after speaking to vocalize again
+    /// </summary>
+    [DataField]
+    public TimeSpan MaxVocalizeInterval = TimeSpan.FromMinutes(6);
+
+    /// <summary>
+    /// Next time at which to vocalize
+    /// </summary>
+    [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
+    [AutoPausedField]
+    public TimeSpan NextVocalizeInterval = TimeSpan.Zero;
+}
diff --git a/Content.Server/Vocalization/Systems/RadioVocalizationSystem.cs b/Content.Server/Vocalization/Systems/RadioVocalizationSystem.cs
new file mode 100644 (file)
index 0000000..bdb1416
--- /dev/null
@@ -0,0 +1,98 @@
+using Content.Server.Chat.Systems;
+using Content.Server.Radio.Components;
+using Content.Server.Vocalization.Components;
+using Content.Shared.Chat;
+using Content.Shared.Inventory;
+using Content.Shared.Radio;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Server.Vocalization.Systems;
+
+/// <summary>
+/// RadioVocalizationSystem handles vocalizing things via equipped radios when a VocalizeEvent is fired
+/// </summary>
+public sealed partial class RadioVocalizationSystem : EntitySystem
+{
+    [Dependency] private readonly ChatSystem _chat = default!;
+    [Dependency] private readonly IPrototypeManager _proto = default!;
+    [Dependency] private readonly IRobustRandom _random = default!;
+    [Dependency] private readonly InventorySystem _inventory = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<RadioVocalizerComponent, VocalizeEvent>(OnVocalize);
+    }
+
+    /// <summary>
+    /// Called whenever an entity with a VocalizerComponent tries to speak
+    /// </summary>
+    private void OnVocalize(Entity<RadioVocalizerComponent> entity, ref VocalizeEvent args)
+    {
+        if (args.Handled)
+            return;
+
+        // set to handled if we succeed in speaking on the radio
+        args.Handled = TrySpeakRadio(entity.Owner, args.Message);
+    }
+
+    /// <summary>
+    /// Selects a random radio channel from all ActiveRadio entities in a given entity's inventory
+    /// If no channels are found, this returns false and sets channel to an empty string
+    /// </summary>
+    private bool TryPickRandomRadioChannel(EntityUid entity, out string channel)
+    {
+        HashSet<string> potentialChannels = [];
+
+        // we don't have to check if this entity has an inventory. GetHandOrInventoryEntities will not yield anything
+        // if an entity has no inventory or inventory slots
+        foreach (var item in _inventory.GetHandOrInventoryEntities(entity))
+        {
+            if (!TryComp<ActiveRadioComponent>(item, out var radio))
+                continue;
+
+            potentialChannels.UnionWith(radio.Channels);
+        }
+
+        if (potentialChannels.Count == 0)
+        {
+            channel = string.Empty;
+            return false;
+        }
+
+        channel = _random.Pick(potentialChannels);
+
+        return true;
+    }
+
+    /// <summary>
+    /// Attempts to speak on the radio. Returns false if there is no radio or talking on radio fails somehow
+    /// </summary>
+    /// <param name="entity">Entity to try and make speak on the radio</param>
+    /// <param name="message">Message to speak</param>
+    private bool TrySpeakRadio(Entity<RadioVocalizerComponent?> entity, string message)
+    {
+        if (!Resolve(entity, ref entity.Comp))
+            return false;
+
+        if (!_random.Prob(entity.Comp.RadioAttemptChance))
+            return false;
+
+        if (!TryPickRandomRadioChannel(entity, out var channel))
+            return false;
+
+        var channelPrefix = _proto.Index<RadioChannelPrototype>(channel).KeyCode;
+
+        // send a whisper using the radio channel prefix and whatever relevant radio channel character
+        // along with the message. This is analogous to how radio messages are sent by players
+        _chat.TrySendInGameICMessage(
+            entity,
+            $"{SharedChatSystem.RadioChannelPrefix}{channelPrefix} {message}",
+            InGameICChatType.Whisper,
+            ChatTransmitRange.Normal);
+
+        return true;
+    }
+}
diff --git a/Content.Server/Vocalization/Systems/VocalizationSystem.cs b/Content.Server/Vocalization/Systems/VocalizationSystem.cs
new file mode 100644 (file)
index 0000000..b0a2e23
--- /dev/null
@@ -0,0 +1,111 @@
+using Content.Server.Chat.Systems;
+using Content.Server.Vocalization.Components;
+using Content.Shared.ActionBlocker;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Vocalization.Systems;
+
+/// <summary>
+/// VocalizationSystem raises VocalizeEvents to make entities speak at certain intervals
+/// This is used in combination with systems like ParrotMemorySystem to randomly say messages from memory,
+/// or can be used by other systems to speak pre-set messages
+/// </summary>
+public sealed partial class VocalizationSystem : EntitySystem
+{
+    [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!;
+    [Dependency] private readonly ChatSystem _chat = default!;
+    [Dependency] private readonly IGameTiming _gameTiming = default!;
+    [Dependency] private readonly IRobustRandom _random = default!;
+
+    /// <summary>
+    /// Try speaking by raising a TryVocalizeEvent
+    /// This event is passed to systems adding a message to it and setting it to handled
+    /// </summary>
+    private void TrySpeak(Entity<VocalizerComponent> entity)
+    {
+        var tryVocalizeEvent = new TryVocalizeEvent();
+        RaiseLocalEvent(entity.Owner, ref tryVocalizeEvent);
+
+        // if the event was never handled, return
+        // this happens if there are no components that trigger systems to add a message to this event
+        if (!tryVocalizeEvent.Handled)
+            return;
+
+        // if the event's message is null for whatever reason, return.
+        // this would mean a system didn't set the message properly but did set the event to handled
+        if (tryVocalizeEvent.Message is not { } message)
+            return;
+
+        Speak(entity, message);
+    }
+
+    /// <summary>
+    /// Actually say something.
+    /// </summary>
+    private void Speak(Entity<VocalizerComponent> entity, string message)
+    {
+        // raise a VocalizeEvent
+        // this can be handled by other systems to speak using a method other than local chat
+        var vocalizeEvent = new VocalizeEvent(message);
+        RaiseLocalEvent(entity.Owner, ref vocalizeEvent);
+
+        // if the event is handled, don't try speaking
+        if (vocalizeEvent.Handled)
+            return;
+
+        // default to local chat if no other system handles the event
+        // first check if the entity can speak
+        if (!_actionBlocker.CanSpeak(entity))
+            return;
+
+        // send the message
+        _chat.TrySendInGameICMessage(entity, message, InGameICChatType.Speak, ChatTransmitRange.Normal);
+    }
+
+    public override void Update(float frameTime)
+    {
+        base.Update(frameTime);
+
+        // get current game time for delay
+        var currentGameTime = _gameTiming.CurTime;
+
+        // query to get all entities with a VocalizeComponent
+        var query = EntityQueryEnumerator<VocalizerComponent>();
+        while (query.MoveNext(out var uid, out var vocalizer))
+        {
+            // go to next entity if it is too early for this one to speak
+            if (currentGameTime < vocalizer.NextVocalizeInterval)
+                continue;
+
+            // set a new time for the speak interval, regardless of whether speaking works
+            var randomSpeakInterval = _random.Next(vocalizer.MinVocalizeInterval, vocalizer.MaxVocalizeInterval);
+            vocalizer.NextVocalizeInterval += randomSpeakInterval;
+
+            // if an admin updates the speak interval to be immediate, this loop will spam messages until the
+            // nextspeakinterval catches up with the current game time. Prevent this from happening
+            if (vocalizer.NextVocalizeInterval < _gameTiming.CurTime)
+                vocalizer.NextVocalizeInterval = _gameTiming.CurTime + randomSpeakInterval;
+
+            // try to speak
+            TrySpeak((uid, vocalizer));
+        }
+    }
+}
+
+/// <summary>
+/// Fired when the entity wants to try vocalizing, but doesn't have a message yet
+/// </summary>
+/// <param name="Message">Message to send, this is null when the event is just fired and should be set by a system</param>
+/// <param name="Handled">Whether the message was handled by a system</param>
+[ByRefEvent]
+public record struct TryVocalizeEvent(string? Message = null, bool Handled = false);
+
+/// <summary>
+/// Fired when the entity wants to vocalize and has a message. Allows for interception by other systems if the
+/// vocalization needs to be done some other way
+/// </summary>
+/// <param name="Message">Message to send</param>
+/// <param name="Handled">Whether the message was handled by a system</param>
+[ByRefEvent]
+public record struct VocalizeEvent(string Message, bool Handled = false);
diff --git a/Resources/Locale/en-US/animals/parrot/parrot.ftl b/Resources/Locale/en-US/animals/parrot/parrot.ftl
new file mode 100644 (file)
index 0000000..8262324
--- /dev/null
@@ -0,0 +1,2 @@
+parrot-verb-clear-memory = Clear parrot memory
+parrot-popup-memory-cleared = Parrot memory cleared
index 667b83ec3fdf3fe3c9cb40e3cf536f4d4b72475d..68b4b2dc585b17cf7e7ab21a4235dbd176fe5489 100644 (file)
 - type: cargoProduct
   id: LivestockParrot
   icon:
-    sprite: Mobs/Animals/parrot.rsi
+    sprite: Mobs/Animals/parrot/parrot.rsi
     state: parrot
   product: CrateNPCParrot
   cost: 3000
index 5691d984b768489a62d22ca3ec9e3304f83a94bf..ea39785032815919656251384e8938734585ed51 100644 (file)
     layers:
     - state: green
     - state: parrot
-      sprite: Mobs/Animals/parrot.rsi
+      sprite: Mobs/Animals/parrot/parrot.rsi
   - type: ConditionalSpawner
     prototypes:
     - MobParrot
index 0708f9a108cf855fb23a7df254046967f0d25faf..f590bcb508d9fcf04f63793654e2304a111ba1ee 100644 (file)
     prototypes:
     - MobMonkeyPunpun
 
+- type: entity
+  parent: MarkerBase
+  id: SpawnMobPollyParrot
+  name: Polly the parrot Spawner
+  suffix: CE Pet
+  components:
+  - type: Sprite
+    layers:
+    - state: green
+    - state: parrot
+      sprite: Mobs/Animals/parrot/parrot.rsi
+  - type: ConditionalSpawner
+    prototypes:
+    - MobPollyParrot
+
 - type: entity
   parent: MarkerBase
   id: SpawnMobPossumMorty
index 2a599d70bea8ab95ff06b41293eb8817071539a5..2b837b768e4a8e999ac5d723fb4b0f3dc13222de 100644 (file)
 
 # Would be cool to have some functionality for the parrot to be able to sit on stuff
 - type: entity
-  name: parrot
   parent: [ SimpleMobBase, FlyingMobBase ]
-  id: MobParrot
+  id: MobParrotBase
+  abstract: true
   description: Infiltrates your domain, spies on you, and somehow still a cool pet.
   components:
   - type: MovementSpeedModifier
     layers:
     - map: ["enum.DamageStateVisualLayers.Base"]
       state: parrot
-      sprite: Mobs/Animals/parrot.rsi
+      sprite: Mobs/Animals/parrot/parrot.rsi
   - type: Fixtures
     fixtures:
       fix1:
   - type: Vocal
     sounds:
       Unsexed: Parrot
-  - type: ParrotAccent
+  - type: ActiveListener
+  - type: Vocalizer
+  - type: RadioVocalizer
+  - type: ParrotListener
+  - type: ParrotMemory
+  - type: Inventory
+    templateId: parrot
+    speciesId: parrot
+    # transparent displacement map to hide the headset. this should be an animated displacement map to follow the parrot
+    # as it bobs up and down, but this in turn needs some way to change the displacement map on inventory entities when
+    # the parrot dies, otherwise the visuals will be broken
+    displacements:
+      ears:
+        sizeMaps:
+          32:
+            sprite: Mobs/Animals/parrot/displacement.rsi
+            state: ears
+  - type: InventorySlots
+  - type: Strippable
+  - type: UserInterface
+    interfaces:
+      enum.StrippingUiKey.Key:
+        type: StrippableBoundUserInterface
   - type: InteractionPopup
     successChance: 0.6
     interactSuccessString: petting-success-bird
   - type: Bloodstream
     bloodMaxVolume: 50
 
+- type: entity
+  name: parrot
+  parent: MobParrotBase
+  id: MobParrot
+  components:
+  - type: ParrotAccent # regular parrots have a parrotaccent. Polly is special and does not
+
 - type: entity
   name: penguin
   parent: SimpleMobBase
index 738b1ac1299fdb4eaab01be800d9e04c55afc348..86be44dd05c87e0178523ce8676f0e150ae9568e 100644 (file)
 #  - type: AlwaysRevolutionaryConvertible
   - type: StealTarget
     stealGroup: AnimalTropico
+
+- type: entity
+  name: Polly the parrot
+  parent: MobParrotBase
+  id: MobPollyParrot
+  description: An expert in quantum cracker theory
+  components:
+  - type: ParrotMemory
+    learnChance: 0.5 # polly is smarter
+  - type: Vocalizer
+    maxVocalizeInterval: 240 # polly is chattier
+  - type: Grammar
+    attributes:
+      proper: true
+      gender: male
+  - type: Loadout
+    prototypes: [ MobPollyGear ]
+
diff --git a/Resources/Prototypes/InventoryTemplates/parrot_inventory_template.yml b/Resources/Prototypes/InventoryTemplates/parrot_inventory_template.yml
new file mode 100644 (file)
index 0000000..5460d73
--- /dev/null
@@ -0,0 +1,10 @@
+- type: inventoryTemplate
+  id: parrot
+  slots:
+  - name: ears
+    slotTexture: ears
+    slotFlags: EARS
+    stripTime: 3
+    uiWindowPos: 0,2
+    strippingWindowPos: 0,0
+    displayName: Ears
index 1448a07cba4f241f603af5b0c84f14b73969578d..13455651a25ed6d5bf148dc44a3dd6fcd73058d9 100644 (file)
     jumpsuit: ClothingUniformJumpsuitJacketMonkey
     id: PunPunIDCard
 
+- type: startingGear
+  id: MobPollyGear
+  equipment:
+    ears: ClothingHeadsetEngineering
+
 # Emotional Support Scurret
 
 - type: startingGear
diff --git a/Resources/Textures/Interface/AdminActions/clear-parrot.png b/Resources/Textures/Interface/AdminActions/clear-parrot.png
new file mode 100644 (file)
index 0000000..bf027c4
Binary files /dev/null and b/Resources/Textures/Interface/AdminActions/clear-parrot.png differ
diff --git a/Resources/Textures/Mobs/Animals/parrot.rsi/parrot.png b/Resources/Textures/Mobs/Animals/parrot.rsi/parrot.png
deleted file mode 100644 (file)
index fd9079f..0000000
Binary files a/Resources/Textures/Mobs/Animals/parrot.rsi/parrot.png and /dev/null differ
diff --git a/Resources/Textures/Mobs/Animals/parrot/displacement.rsi/ears.png b/Resources/Textures/Mobs/Animals/parrot/displacement.rsi/ears.png
new file mode 100644 (file)
index 0000000..7244b37
Binary files /dev/null and b/Resources/Textures/Mobs/Animals/parrot/displacement.rsi/ears.png differ
diff --git a/Resources/Textures/Mobs/Animals/parrot/displacement.rsi/meta.json b/Resources/Textures/Mobs/Animals/parrot/displacement.rsi/meta.json
new file mode 100644 (file)
index 0000000..ad46a0b
--- /dev/null
@@ -0,0 +1,18 @@
+{
+  "version": 1,
+  "license": "CC-BY-3.0",
+  "copyright": "Made by Crude Oil",
+  "size": {
+    "x": 32,
+    "y": 32
+  },
+  "load": {
+    "srgb": false
+  },
+  "states": [
+    {
+      "name": "ears",
+      "directions": 1
+    }
+  ]
+}
diff --git a/Resources/Textures/Mobs/Animals/parrot/parrot.rsi/parrot.png b/Resources/Textures/Mobs/Animals/parrot/parrot.rsi/parrot.png
new file mode 100644 (file)
index 0000000..48b71f7
Binary files /dev/null and b/Resources/Textures/Mobs/Animals/parrot/parrot.rsi/parrot.png differ