diff --git a/Content.Server/Administration/Systems/AdminSystem.cs b/Content.Server/Administration/Systems/AdminSystem.cs
index 56bbc225cc..78433db129 100644
--- a/Content.Server/Administration/Systems/AdminSystem.cs
+++ b/Content.Server/Administration/Systems/AdminSystem.cs
@@ -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);
}
}
+
+///
+/// Event fired after a player is erased by an admin
+///
+/// NetUserId of the player that was the target of the Erase
+[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
index 0000000000..9d0c8a2d01
--- /dev/null
+++ b/Content.Server/Animals/Components/ParrotListenerComponent.cs
@@ -0,0 +1,32 @@
+using Content.Shared.Whitelist;
+
+namespace Content.Server.Animals.Components;
+
+///
+/// Makes an entity able to listen to messages from IC chat and attempt to commit them to memory
+///
+[RegisterComponent]
+public sealed partial class ParrotListenerComponent : Component
+{
+ ///
+ /// 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
+ ///
+ [DataField]
+ public EntityWhitelist? Whitelist;
+
+ ///
+ /// 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.
+ ///
+ [DataField]
+ public EntityWhitelist? Blacklist;
+}
diff --git a/Content.Server/Animals/Components/ParrotMemoryComponent.cs b/Content.Server/Animals/Components/ParrotMemoryComponent.cs
new file mode 100644
index 0000000000..2c3771cfac
--- /dev/null
+++ b/Content.Server/Animals/Components/ParrotMemoryComponent.cs
@@ -0,0 +1,57 @@
+using Robust.Shared.Network;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Server.Animals.Components;
+
+///
+/// Makes an entity able to memorize chat/radio messages
+///
+[RegisterComponent]
+[AutoGenerateComponentPause]
+public sealed partial class ParrotMemoryComponent : Component
+{
+ ///
+ /// List of SpeechMemory records this entity has learned
+ ///
+ [DataField]
+ public List SpeechMemories = [];
+
+ ///
+ /// The % chance an entity with this component learns a phrase when learning is off cooldown
+ ///
+ [DataField]
+ public float LearnChance = 0.4f;
+
+ ///
+ /// Time after which another attempt can be made at learning a phrase
+ ///
+ [DataField]
+ public TimeSpan LearnCooldown = TimeSpan.FromMinutes(1);
+
+ ///
+ /// Next time at which the parrot can attempt to learn something
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
+ [AutoPausedField]
+ public TimeSpan NextLearnInterval = TimeSpan.Zero;
+
+ ///
+ /// The number of speech entries that are remembered
+ ///
+ [DataField]
+ public int MaxSpeechMemory = 50;
+
+ ///
+ /// Minimum length of a speech entry
+ ///
+ [DataField]
+ public int MinEntryLength = 4;
+
+ ///
+ /// Maximum length of a speech entry
+ ///
+ [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
index 0000000000..eb3429821c
--- /dev/null
+++ b/Content.Server/Animals/Systems/ParrotMemorySystem.cs
@@ -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;
+
+///
+/// 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.
+///
+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(OnErase);
+
+ SubscribeLocalEvent>(OnGetVerbs);
+
+ SubscribeLocalEvent(ListenerOnMapInit);
+
+ SubscribeLocalEvent(OnListen);
+ SubscribeLocalEvent(OnHeadsetReceive);
+
+ SubscribeLocalEvent(OnTryVocalize);
+ }
+
+ private void OnErase(ref EraseEvent args)
+ {
+ DeletePlayerMessages(args.PlayerNetUserId);
+ }
+
+ private void OnGetVerbs(Entity entity, ref GetVerbsEvent 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 entity, ref MapInitEvent args)
+ {
+ // If an entity has a ParrotListenerComponent it really ought to have an ActiveListenerComponent
+ if (!HasComp(entity))
+ Log.Warning($"Entity {ToPrettyString(entity)} has a ParrotListenerComponent but was not given an ActiveListenerComponent");
+ }
+
+ private void OnListen(Entity entity, ref ListenEvent args)
+ {
+
+ TryLearn(entity.Owner, args.Message, args.Source);
+ }
+
+ private void OnHeadsetReceive(Entity entity, ref HeadsetRadioReceiveRelayEvent args)
+ {
+ var message = args.RelayedEvent.Message;
+ var source = args.RelayedEvent.MessageSource;
+
+ TryLearn(entity.Owner, message, source);
+ }
+
+ ///
+ /// Called when an entity with a ParrotMemoryComponent tries to vocalize.
+ /// This function picks a message from memory and sets the event to handled
+ ///
+ private void OnTryVocalize(Entity 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;
+ }
+
+ ///
+ /// 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
+ ///
+ /// Entity learning a new word
+ /// Message to learn
+ /// Source EntityUid of the message
+ public void TryLearn(Entity 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);
+ }
+
+ ///
+ /// Actually learn a message and commit it to memory
+ ///
+ /// Entity learning a new word
+ /// Message to learn
+ /// Source EntityUid of the message
+ private void Learn(Entity 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;
+ }
+
+ ///
+ /// Delete all messages from a specified player on all ParrotMemoryComponents
+ ///
+ /// The player of whom to delete messages
+ private void DeletePlayerMessages(NetUserId playerNetUserId)
+ {
+ // query to enumerate all entities with a memorycomponent
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out _, out var memory))
+ {
+ DeletePlayerMessages(memory, playerNetUserId);
+ }
+ }
+
+ ///
+ /// Delete all messages from a specified player on a given ParrotMemoryComponent
+ ///
+ /// The ParrotMemoryComponent on which to delete messages
+ /// The player of whom to delete messages
+ 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);
+ }
+ }
+}
diff --git a/Content.Server/Radio/EntitySystems/HeadsetSystem.cs b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs
index d18b044205..e3f8070311 100644
--- a/Content.Server/Radio/EntitySystems/HeadsetSystem.cs
+++ b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs
@@ -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);
}
diff --git a/Content.Server/Radio/RadioEvent.cs b/Content.Server/Radio/RadioEvent.cs
index fafa66674e..49ff63f824 100644
--- a/Content.Server/Radio/RadioEvent.cs
+++ b/Content.Server/Radio/RadioEvent.cs
@@ -6,6 +6,12 @@ namespace Content.Server.Radio;
[ByRefEvent]
public readonly record struct RadioReceiveEvent(string Message, EntityUid MessageSource, RadioChannelPrototype Channel, EntityUid RadioSource, MsgChatMessage ChatMsg);
+///
+/// Event raised on the parent entity of a headset radio when a radio message is received
+///
+[ByRefEvent]
+public readonly record struct HeadsetRadioReceiveRelayEvent(RadioReceiveEvent RelayedEvent);
+
///
/// Use this event to cancel sending message per receiver
///
diff --git a/Content.Server/Vocalization/Components/RadioVocalizerComponent.cs b/Content.Server/Vocalization/Components/RadioVocalizerComponent.cs
new file mode 100644
index 0000000000..b2414f3796
--- /dev/null
+++ b/Content.Server/Vocalization/Components/RadioVocalizerComponent.cs
@@ -0,0 +1,14 @@
+namespace Content.Server.Vocalization.Components;
+
+///
+/// Makes an entity able to vocalize through an equipped radio
+///
+[RegisterComponent]
+public sealed partial class RadioVocalizerComponent : Component
+{
+ ///
+ /// chance the vocalizing entity speaks on the radio.
+ ///
+ [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
index 0000000000..0dfde9751b
--- /dev/null
+++ b/Content.Server/Vocalization/Components/VocalizerComponent.cs
@@ -0,0 +1,30 @@
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Server.Vocalization.Components;
+
+///
+/// Makes an entity vocalize at set intervals
+///
+[RegisterComponent]
+[AutoGenerateComponentPause]
+public sealed partial class VocalizerComponent : Component
+{
+ ///
+ /// Minimum time to wait after speaking to vocalize again
+ ///
+ [DataField]
+ public TimeSpan MinVocalizeInterval = TimeSpan.FromMinutes(2);
+
+ ///
+ /// Maximum time to wait after speaking to vocalize again
+ ///
+ [DataField]
+ public TimeSpan MaxVocalizeInterval = TimeSpan.FromMinutes(6);
+
+ ///
+ /// Next time at which to vocalize
+ ///
+ [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
index 0000000000..bdb14168bb
--- /dev/null
+++ b/Content.Server/Vocalization/Systems/RadioVocalizationSystem.cs
@@ -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;
+
+///
+/// RadioVocalizationSystem handles vocalizing things via equipped radios when a VocalizeEvent is fired
+///
+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(OnVocalize);
+ }
+
+ ///
+ /// Called whenever an entity with a VocalizerComponent tries to speak
+ ///
+ private void OnVocalize(Entity 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);
+ }
+
+ ///
+ /// 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
+ ///
+ private bool TryPickRandomRadioChannel(EntityUid entity, out string channel)
+ {
+ HashSet 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(item, out var radio))
+ continue;
+
+ potentialChannels.UnionWith(radio.Channels);
+ }
+
+ if (potentialChannels.Count == 0)
+ {
+ channel = string.Empty;
+ return false;
+ }
+
+ channel = _random.Pick(potentialChannels);
+
+ return true;
+ }
+
+ ///
+ /// Attempts to speak on the radio. Returns false if there is no radio or talking on radio fails somehow
+ ///
+ /// Entity to try and make speak on the radio
+ /// Message to speak
+ private bool TrySpeakRadio(Entity 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(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
index 0000000000..b0a2e232a4
--- /dev/null
+++ b/Content.Server/Vocalization/Systems/VocalizationSystem.cs
@@ -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;
+
+///
+/// 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
+///
+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!;
+
+ ///
+ /// Try speaking by raising a TryVocalizeEvent
+ /// This event is passed to systems adding a message to it and setting it to handled
+ ///
+ private void TrySpeak(Entity 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);
+ }
+
+ ///
+ /// Actually say something.
+ ///
+ private void Speak(Entity 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();
+ 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));
+ }
+ }
+}
+
+///
+/// Fired when the entity wants to try vocalizing, but doesn't have a message yet
+///
+/// Message to send, this is null when the event is just fired and should be set by a system
+/// Whether the message was handled by a system
+[ByRefEvent]
+public record struct TryVocalizeEvent(string? Message = null, bool Handled = false);
+
+///
+/// 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
+///
+/// Message to send
+/// Whether the message was handled by a system
+[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
index 0000000000..8262324d81
--- /dev/null
+++ b/Resources/Locale/en-US/animals/parrot/parrot.ftl
@@ -0,0 +1,2 @@
+parrot-verb-clear-memory = Clear parrot memory
+parrot-popup-memory-cleared = Parrot memory cleared
diff --git a/Resources/Prototypes/Catalog/Cargo/cargo_livestock.yml b/Resources/Prototypes/Catalog/Cargo/cargo_livestock.yml
index 667b83ec3f..68b4b2dc58 100644
--- a/Resources/Prototypes/Catalog/Cargo/cargo_livestock.yml
+++ b/Resources/Prototypes/Catalog/Cargo/cargo_livestock.yml
@@ -151,7 +151,7 @@
- type: cargoProduct
id: LivestockParrot
icon:
- sprite: Mobs/Animals/parrot.rsi
+ sprite: Mobs/Animals/parrot/parrot.rsi
state: parrot
product: CrateNPCParrot
cost: 3000
diff --git a/Resources/Prototypes/Entities/Markers/Spawners/Mobs/animals.yml b/Resources/Prototypes/Entities/Markers/Spawners/Mobs/animals.yml
index 5691d984b7..ea39785032 100644
--- a/Resources/Prototypes/Entities/Markers/Spawners/Mobs/animals.yml
+++ b/Resources/Prototypes/Entities/Markers/Spawners/Mobs/animals.yml
@@ -210,7 +210,7 @@
layers:
- state: green
- state: parrot
- sprite: Mobs/Animals/parrot.rsi
+ sprite: Mobs/Animals/parrot/parrot.rsi
- type: ConditionalSpawner
prototypes:
- MobParrot
diff --git a/Resources/Prototypes/Entities/Markers/Spawners/Mobs/pets.yml b/Resources/Prototypes/Entities/Markers/Spawners/Mobs/pets.yml
index 0708f9a108..f590bcb508 100644
--- a/Resources/Prototypes/Entities/Markers/Spawners/Mobs/pets.yml
+++ b/Resources/Prototypes/Entities/Markers/Spawners/Mobs/pets.yml
@@ -218,6 +218,21 @@
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
diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml
index 2a599d70be..2b837b768e 100644
--- a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml
+++ b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml
@@ -2174,9 +2174,9 @@
# 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
@@ -2187,7 +2187,7 @@
layers:
- map: ["enum.DamageStateVisualLayers.Base"]
state: parrot
- sprite: Mobs/Animals/parrot.rsi
+ sprite: Mobs/Animals/parrot/parrot.rsi
- type: Fixtures
fixtures:
fix1:
@@ -2217,7 +2217,29 @@
- 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
@@ -2228,6 +2250,13 @@
- 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
diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml b/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml
index 738b1ac129..86be44dd05 100644
--- a/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml
+++ b/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml
@@ -858,3 +858,21 @@
# - 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
index 0000000000..5460d730eb
--- /dev/null
+++ b/Resources/Prototypes/InventoryTemplates/parrot_inventory_template.yml
@@ -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
diff --git a/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml b/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml
index 1448a07cba..13455651a2 100644
--- a/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml
+++ b/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml
@@ -103,6 +103,11 @@
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
index 0000000000..bf027c421d
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
index fd9079fd66..0000000000
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
index 0000000000..7244b37f5c
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
index 0000000000..ad46a0b6ed
--- /dev/null
+++ b/Resources/Textures/Mobs/Animals/parrot/displacement.rsi/meta.json
@@ -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.rsi/dead.png b/Resources/Textures/Mobs/Animals/parrot/parrot.rsi/dead.png
similarity index 100%
rename from Resources/Textures/Mobs/Animals/parrot.rsi/dead.png
rename to Resources/Textures/Mobs/Animals/parrot/parrot.rsi/dead.png
diff --git a/Resources/Textures/Mobs/Animals/parrot.rsi/icon.png b/Resources/Textures/Mobs/Animals/parrot/parrot.rsi/icon.png
similarity index 100%
rename from Resources/Textures/Mobs/Animals/parrot.rsi/icon.png
rename to Resources/Textures/Mobs/Animals/parrot/parrot.rsi/icon.png
diff --git a/Resources/Textures/Mobs/Animals/parrot.rsi/meta.json b/Resources/Textures/Mobs/Animals/parrot/parrot.rsi/meta.json
similarity index 100%
rename from Resources/Textures/Mobs/Animals/parrot.rsi/meta.json
rename to Resources/Textures/Mobs/Animals/parrot/parrot.rsi/meta.json
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
index 0000000000..48b71f7b25
Binary files /dev/null and b/Resources/Textures/Mobs/Animals/parrot/parrot.rsi/parrot.png differ
diff --git a/Resources/Textures/Mobs/Animals/parrot.rsi/sit.png b/Resources/Textures/Mobs/Animals/parrot/parrot.rsi/sit.png
similarity index 100%
rename from Resources/Textures/Mobs/Animals/parrot.rsi/sit.png
rename to Resources/Textures/Mobs/Animals/parrot/parrot.rsi/sit.png