Files
Delta-v/Content.Shared/Chat/SharedChatSystem.cs
T
003e08b583 Flavourful emotes (#5595)
* Changed up how emoting works

This was a long 10 hours just to get the chat to behave slightly different.

At least we can now scream over radio!

Will most definitely need some more work to get bugs out since this is the first implementation.

* Not ok

This was unmarked for some reason... I don't remember unmarking this.

* DeltaV comments

Added comments to explain changes.

Moved localization files to the _DV folder.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Moved code

Moved code to more suitable files and added some comments.

* Rights for robots

Now those with intrinsic radios can also emote!

* Fixes up some small stuff

Changes to fix some small issues with the code to align it more with modern standards.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Fix by-ref event

Never worked with this event type before so gonna hope this works.

* Update Content.Shared/Chat/SharedChatSystem.Emote.cs

Signed-off-by: pathetic meowmeow <uhhadd@gmail.com>

* Update Content.Shared/Chat/SharedChatSystem.cs

Signed-off-by: pathetic meowmeow <uhhadd@gmail.com>

* Update Content.Server/Chat/Systems/ChatSystem.cs

Signed-off-by: pathetic meowmeow <uhhadd@gmail.com>

* fix style nits

* remove conflicting chatsan

---------

Signed-off-by: pathetic meowmeow <uhhadd@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: pathetic meowmeow <uhhadd@gmail.com>
2026-06-04 17:25:58 +00:00

513 lines
20 KiB
C#

using System.Collections.Frozen;
using System.Text.RegularExpressions;
using Content.Shared._DV.Chat;
using Content.Shared.ActionBlocker;
using Content.Shared.Chat.Prototypes;
using Content.Shared.Popups;
using Content.Shared.Radio;
using Content.Shared.Speech;
using Content.Shared.Whitelist;
using Robust.Shared.Audio;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Console;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Utility;
namespace Content.Shared.Chat;
public abstract partial class SharedChatSystem : EntitySystem
{
public const char RadioCommonPrefix = ';';
public const char RadioChannelPrefix = ':';
public const char RadioChannelAltPrefix = '.';
public const char LocalPrefix = '>';
public const char ConsolePrefix = '/';
public const char DeadPrefix = '\\';
public const char LOOCPrefix = '(';
public const char OOCPrefix = '[';
public const char EmotesPrefix = '@';
public const char EmotesAltPrefix = '*';
public const char AudibleEmotePrefix = '!'; // DeltaV - You may now scream audibly!
public const char PossessiveEmotePrefix = '\''; // DeltaV - You may now be possessive of things! Whatever that means.
public const char AdminPrefix = ']';
public const char WhisperPrefix = ',';
public const char TelepathicPrefix = '='; //Nyano - Summary: Adds the telepathic channel's prefix.
public const char DefaultChannelKey = 'h';
public const int VoiceRange = 10; // how far voice goes in world units
public const int WhisperClearRange = 2; // how far whisper goes while still being understandable, in world units
public const int WhisperMuffledRange = 5; // how far whisper goes at all, in world units
public static readonly SoundSpecifier DefaultAnnouncementSound
= new SoundPathSpecifier("/Audio/Announcements/announce.ogg");
public static readonly ProtoId<RadioChannelPrototype> CommonChannel = "Common";
public static readonly string DefaultChannelPrefix = $"{RadioChannelPrefix}{DefaultChannelKey}";
public static readonly ProtoId<SpeechVerbPrototype> DefaultSpeechVerb = "Default";
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly EntityWhitelistSystem _whitelist = default!;
[Dependency] private readonly ActionBlockerSystem _actionBlocker = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly INetManager _net = default!;
/// <summary>
/// Cache of the keycodes for faster lookup.
/// </summary>
private FrozenDictionary<char, RadioChannelPrototype> _keyCodes = default!;
public override void Initialize()
{
base.Initialize();
DebugTools.Assert(_prototypeManager.HasIndex(CommonChannel));
SubscribeLocalEvent<PrototypesReloadedEventArgs>(OnPrototypeReload);
CacheRadios();
CacheEmotes();
}
protected virtual void OnPrototypeReload(PrototypesReloadedEventArgs obj)
{
if (obj.WasModified<RadioChannelPrototype>())
CacheRadios();
if (obj.WasModified<EmotePrototype>())
CacheEmotes();
}
private void CacheRadios()
{
_keyCodes = _prototypeManager.EnumeratePrototypes<RadioChannelPrototype>()
.ToFrozenDictionary(x => x.KeyCode);
}
/// <summary>
/// Attempts to find an applicable <see cref="SpeechVerbPrototype"/> for a speaking entity's message.
/// If one is not found, returns <see cref="DefaultSpeechVerb"/>.
/// </summary>
public SpeechVerbPrototype GetSpeechVerb(EntityUid source, string message, SpeechComponent? speech = null)
{
if (!Resolve(source, ref speech, false))
return _prototypeManager.Index(DefaultSpeechVerb);
// check for a suffix-applicable speech verb
SpeechVerbPrototype? current = null;
foreach (var (str, id) in speech.SuffixSpeechVerbs)
{
var proto = _prototypeManager.Index(id);
if (message.EndsWith(Loc.GetString(str)) && proto.Priority >= (current?.Priority ?? 0))
{
current = proto;
}
}
// if no applicable suffix verb return the normal one used by the entity
return current ?? _prototypeManager.Index(speech.SpeechVerb);
}
/// <summary>
/// Splits the input message into a radio prefix part and the rest to preserve it during sanitization.
/// </summary>
/// <remarks>
/// This is primarily for the chat emote sanitizer, which can match against ":b" as an emote, which is a valid radio keycode.
/// </remarks>
public void GetRadioKeycodePrefix(EntityUid source,
string input,
out string output,
out string prefix)
{
prefix = string.Empty;
output = input;
// If the string is less than 2, then it's probably supposed to be an emote.
// No one is sending empty radio messages!
if (input.Length <= 2)
return;
if (!(input.StartsWith(RadioChannelPrefix) || input.StartsWith(RadioChannelAltPrefix)))
return;
if (!_keyCodes.TryGetValue(char.ToLower(input[1]), out _))
return;
prefix = input[..2];
output = input[2..];
}
/// <summary>
/// Attempts to resolve radio prefixes in chat messages (e.g., remove a leading ":e" and resolve the requested
/// channel. Returns true if a radio message was attempted, even if the channel is invalid.
/// </summary>
/// <param name="source">Source of the message</param>
/// <param name="input">The message to be modified</param>
/// <param name="output">The modified message</param>
/// <param name="channel">The channel that was requested, if any</param>
/// <param name="quiet">Whether or not to generate an informative pop-up message.</param>
/// <returns></returns>
public bool TryProcessRadioMessage(
EntityUid source,
string input,
out string output,
out RadioChannelPrototype? channel,
bool capitalize = true, // DeltaV - We might not want to capitalize the first letter if we send in emotes.
bool quiet = false)
{
output = input.Trim();
channel = null;
if (input.Length == 0)
return false;
if (input.StartsWith(RadioCommonPrefix))
{
output = capitalize ? SanitizeMessageCapital(input[1..].TrimStart()) : input[1..].TrimStart(); // DeltaV
channel = _prototypeManager.Index<RadioChannelPrototype>(CommonChannel);
return true;
}
if (!(input.StartsWith(RadioChannelPrefix) || input.StartsWith(RadioChannelAltPrefix)))
return false;
if (input.Length < 2 || char.IsWhiteSpace(input[1]))
{
output = capitalize ? SanitizeMessageCapital(input[1..].TrimStart()) : input[1..].TrimStart(); // DeltaV
if (!quiet)
_popup.PopupEntity(Loc.GetString("chat-manager-no-radio-key"), source, source);
return true;
}
var channelKey = input[1];
channelKey = char.ToLower(channelKey);
output = capitalize ? SanitizeMessageCapital(input[2..].TrimStart()) : input[2..].TrimStart(); // DeltaV
if (channelKey == DefaultChannelKey)
{
var ev = new GetDefaultRadioChannelEvent();
RaiseLocalEvent(source, ev);
if (ev.Channel != null)
_prototypeManager.TryIndex(ev.Channel, out channel);
return true;
}
if (!_keyCodes.TryGetValue(channelKey, out channel) && !quiet)
{
var msg = Loc.GetString("chat-manager-no-such-channel", ("key", channelKey));
_popup.PopupEntity(msg, source, source);
}
return true;
}
public string SanitizeMessageCapital(string message)
{
if (string.IsNullOrEmpty(message))
return message;
// Capitalize first letter
message = OopsConcat(char.ToUpper(message[0]).ToString(), message.Remove(0, 1));
return message;
}
private static string OopsConcat(string a, string b)
{
// This exists to prevent Roslyn being clever and compiling something that fails sandbox checks.
return a + b;
}
public string SanitizeMessageCapitalizeTheWordI(string message, string theWordI = "i")
{
if (string.IsNullOrEmpty(message))
return message;
for
(
var index = message.IndexOf(theWordI);
index != -1;
index = message.IndexOf(theWordI, index + 1)
)
{
// Stops the code If It's tryIng to capItalIze the letter I In the mIddle of words
// Repeating the code twice is the simplest option
if (index + 1 < message.Length && char.IsLetter(message[index + 1]))
continue;
if (index - 1 >= 0 && char.IsLetter(message[index - 1]))
continue;
var beforeTarget = message.Substring(0, index);
var target = message.Substring(index, theWordI.Length);
var afterTarget = message.Substring(index + theWordI.Length);
message = beforeTarget + target.ToUpper() + afterTarget;
}
return message;
}
public static string SanitizeAnnouncement(string message, int maxLength = 0, int maxNewlines = 2)
{
var trimmed = message.Trim();
if (maxLength > 0 && trimmed.Length > maxLength)
{
trimmed = $"{message[..maxLength]}...";
}
// No more than max newlines, other replaced to spaces
if (maxNewlines > 0)
{
var chars = trimmed.ToCharArray();
var newlines = 0;
for (var i = 0; i < chars.Length; i++)
{
if (chars[i] != '\n')
continue;
if (newlines >= maxNewlines)
chars[i] = ' ';
newlines++;
}
return new string(chars);
}
return trimmed;
}
public static string InjectTagInsideTag(ChatMessage message, string outerTag, string innerTag, string? tagParameter)
{
var rawmsg = message.WrappedMessage;
var tagStart = rawmsg.IndexOf($"[{outerTag}]");
var tagEnd = rawmsg.IndexOf($"[/{outerTag}]");
if (tagStart < 0 || tagEnd < 0) //If the outer tag is not found, the injection is not performed
return rawmsg;
tagStart += outerTag.Length + 2;
string innerTagProcessed = tagParameter != null ? $"[{innerTag}={tagParameter}]" : $"[{innerTag}]";
rawmsg = rawmsg.Insert(tagEnd, $"[/{innerTag}]");
rawmsg = rawmsg.Insert(tagStart, innerTagProcessed);
return rawmsg;
}
/// <summary>
/// Injects a tag around all found instances of a specific string in a ChatMessage.
/// Excludes strings inside other tags and brackets.
/// </summary>
public static string InjectTagAroundString(ChatMessage message, string targetString, string tag, string? tagParameter)
{
var rawmsg = message.WrappedMessage;
rawmsg = Regex.Replace(rawmsg, "(?i)(" + targetString + ")(?-i)(?![^[]*])", $"[{tag}={tagParameter}]$1[/{tag}]");
return rawmsg;
}
public static string GetStringInsideTag(ChatMessage message, string tag)
{
var rawmsg = message.WrappedMessage;
var tagStart = rawmsg.IndexOf($"[{tag}]");
var tagEnd = rawmsg.IndexOf($"[/{tag}]");
if (tagStart < 0 || tagEnd < 0)
return "";
tagStart += tag.Length + 2;
return rawmsg.Substring(tagStart, tagEnd - tagStart);
}
// DeltaV
protected virtual void SendAudibleEntityEmote(
EntityUid source,
string action,
ChatTransmitRange range,
string? nameOverride,
RadioChannelPrototype? channel,
EmoteType? emoteType,
bool hideLog = false,
bool checkEmote = true,
bool ignoreActionBlocker = false,
NetUserId? author = null
)
{
}
// DeltaV - End
protected virtual void SendEntityEmote(
EntityUid source,
string action,
ChatTransmitRange range,
string? nameOverride,
EmoteType? emoteType,
bool hideLog = false,
bool checkEmote = true,
bool ignoreActionBlocker = false,
NetUserId? author = null
)
{ }
/// <summary>
/// Sends an in-character chat message to relevant clients.
/// </summary>
/// <param name="source">The entity that is speaking.</param>
/// <param name="message">The message being spoken or emoted.</param>
/// <param name="desiredType">The chat type.</param>
/// <param name="hideChat">Whether or not this message should appear in the chat window.</param>
/// <param name="hideLog">Whether or not this message should appear in the adminlog window.</param>
/// <param name="shell"></param>
/// <param name="player">The player doing the speaking.</param>
/// <param name="nameOverride">The name to use for the speaking entity. Usually this should just be modified via <see cref="TransformSpeakerNameEvent"/>. If this is set, the event will not get raised.</param>
/// <param name="checkRadioPrefix">Whether or not <paramref name="message"/> should be parsed with consideration of radio channel prefix text at start the start.</param>
/// <param name="ignoreActionBlocker">If set to true, action blocker will not be considered for whether an entity can send this message.</param>
public virtual void TrySendInGameICMessage(
EntityUid source,
string message,
InGameICChatType desiredType,
bool hideChat,
bool hideLog = false,
IConsoleShell? shell = null,
ICommonSession? player = null,
string? nameOverride = null,
bool checkRadioPrefix = true,
bool ignoreActionBlocker = false)
{ }
/// <summary>
/// Sends an in-character chat message to relevant clients.
/// </summary>
/// <param name="source">The entity that is speaking.</param>
/// <param name="message">The message being spoken or emoted.</param>
/// <param name="desiredType">The chat type.</param>
/// <param name="range">Conceptual range of transmission, if it shows in the chat window, if it shows to far-away ghosts or ghosts at all...</param>
/// <param name="hideLog">Disables the admin log for this message if true. Used for entities that are not players, like vendors, cloning, etc.</param>
/// <param name="shell"></param>
/// <param name="player">The player doing the speaking.</param>
/// <param name="nameOverride">The name to use for the speaking entity. Usually this should just be modified via <see cref="TransformSpeakerNameEvent"/>. If this is set, the event will not get raised.</param>
/// <param name="ignoreActionBlocker">If set to true, action blocker will not be considered for whether an entity can send this message.</param>
public virtual void TrySendInGameICMessage(
EntityUid source,
string message,
InGameICChatType desiredType,
ChatTransmitRange range,
bool hideLog = false,
IConsoleShell? shell = null,
ICommonSession? player = null,
string? nameOverride = null,
bool checkRadioPrefix = true,
bool ignoreActionBlocker = false
)
{ }
/// <summary>
/// Sends an out-of-character chat message to relevant clients.
/// </summary>
/// <param name="source">The entity that is speaking.</param>
/// <param name="message">The message being spoken or emoted.</param>
/// <param name="type">The chat type.</param>
/// <param name="hideChat">Whether or not to show the message in the chat window.</param>
/// <param name="shell"></param>
/// <param name="player">The player doing the speaking.</param>
public virtual void TrySendInGameOOCMessage(
EntityUid source,
string message,
InGameOOCChatType type,
bool hideChat,
IConsoleShell? shell = null,
ICommonSession? player = null
)
{ }
/// <summary>
/// Dispatches an announcement to all.
/// </summary>
/// <param name="message">The contents of the message.</param>
/// <param name="sender">The sender (Communications Console in Communications Console Announcement).</param>
/// <param name="playSound">Play the announcement sound.</param>
/// <param name="announcementSound">Sound to play.</param>
/// <param name="colorOverride">Optional color for the announcement message.</param>
public virtual void DispatchGlobalAnnouncement(
string message,
string? sender = null,
bool playSound = true,
SoundSpecifier? announcementSound = null,
Color? colorOverride = null
)
{ }
/// <summary>
/// Dispatches an announcement to players selected by filter.
/// </summary>
/// <param name="filter">Filter to select players who will recieve the announcement.</param>
/// <param name="message">The contents of the message.</param>
/// <param name="source">The entity making the announcement (used to determine the station).</param>
/// <param name="sender">The sender (Communications Console in Communications Console Announcement).</param>
/// <param name="playSound">Play the announcement sound.</param>
/// <param name="announcementSound">Sound to play.</param>
/// <param name="colorOverride">Optional color for the announcement message.</param>
public virtual void DispatchFilteredAnnouncement(
Filter filter,
string message,
EntityUid? source = null,
string? sender = null,
bool playSound = true,
SoundSpecifier? announcementSound = null,
Color? colorOverride = null)
{ }
/// <summary>
/// Dispatches an announcement on a specific station.
/// </summary>
/// <param name="source">The entity making the announcement (used to determine the station).</param>
/// <param name="message">The contents of the message.</param>
/// <param name="sender">The sender (Communications Console in Communications Console Announcement).</param>
/// <param name="playDefaultSound">Play the announcement sound.</param>
/// <param name="announcementSound">Sound to play.</param>
/// <param name="colorOverride">Optional color for the announcement message.</param>
public virtual void DispatchStationAnnouncement(
EntityUid source,
string message,
string? sender = null,
bool playDefaultSound = true,
SoundSpecifier? announcementSound = null,
Color? colorOverride = null)
{ }
}
/// <summary>
/// Controls transmission of chat.
/// </summary>
public enum ChatTransmitRange : byte
{
/// Acts normal, ghosts can hear across the map, etc.
Normal,
/// Normal but ghosts are still range-limited.
GhostRangeLimit,
/// Hidden from the chat window.
HideChat,
/// Ghosts can't hear or see it at all. Regular players can if in-range.
NoGhosts
}
/// <summary>
/// InGame IC chat is for chat that is specifically ingame (not lobby) but is also in character, i.e. speaking.
/// </summary>
// ReSharper disable once InconsistentNaming
public enum InGameICChatType : byte
{
Speak,
Emote,
Whisper,
Telepathic // DeltaV - Psionic Chat
}
/// <summary>
/// InGame OOC chat is for chat that is specifically ingame (not lobby) but is OOC, like deadchat or LOOC.
/// </summary>
public enum InGameOOCChatType : byte
{
Looc,
Dead
}