Parroting Parrots part 1: Help maints! SQUAWK! Maints! (#38243)

* 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
This commit is contained in:
Crude Oil
2025-07-09 21:04:57 +02:00
committed by GitHub
parent e08e920c62
commit 9ebf6a24c4
27 changed files with 725 additions and 7 deletions

View File

@@ -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);
}
}
}