fish-station/Content.Server/Chat/Systems/ChatSystem.cs
vinogradgarden bdc46a629f
Переработка системы спавна и стыковки шаттлов к станциям, Офицер Внутреннего Надзора, Офицер Специальных Операций, добавление музыки в лобби, добавление наших шаттлов ОБР, РХБЗЗ в билд (#21)
* Перемещение определения работы BlueShieldEnsign и BlueShieldOfficer в закомментированный блок

* Обновление URL для книги корпоративного права

* Update README.md

* Добавление новых плюшевых игрушек CE vulpa и Engie xeno

* fix(maps): update centcomm map path and name template

Update map path to use _Fish variant and modify station name template to include special operations HQ

* add IAA job assets and uniform variants

add new job icons, law department badge, and centcom officer uniform variants
include localization files and prototype configurations for new assets
modify IAA role loadout to use new badge and uniform

* fix(localization): correct jumpskirts uniform file path and content

Move jumpskirts uniform localization file to correct directory and ensure content is properly formatted

* New Ghosts

Add a new ghosts from the old fish station

* Добавление новых ролей и загрузок для CentComm и BlueShield, включая описание и иконки.

* Оперативник «Синий щит» (гост-роль сопровождения ПЦК и тд) и исправления локализации «Синий щит» и ИСН (#2608)

* ERT Uplink Tweaks (#2600)

* Automatic changelog update

* Печать штаба спецопераций (#2599)

* Имплант "Жуткий Смех" в набор вора (#2607)

* Респрайт Мехов (#2617)

* Automatic changelog update

* Очередной фикс маркингов (#2635)

* fix editor (#2641)

* dispacement-drawing-fix (#2642)

* reset pos on client restart (#2643)

* save pos when closing by button (#2644)

* Добавлены новые роли: шеф и врач. Обновлены иконки для новых ролей.

* Updated centcom map

* Добавлены описания и названия для новых ролей Центкома и «Синий Щит» в английском и русском языках.

* add turtle ghost theme and update ghost assets

- Add new turtle ghost theme with animations and localization
- Replace default ghost assets with custom _Fish variants
- Update logo manifest and references to use new ghost sprites
- Remove old _Sunrise ghost assets and consolidate under _Fish

* update splash logo comment in manifest.yml

* remove slippery component from honkbot and update descriptions

The slippery component was causing unintended behavior with honkbot movement. Also updated the bot's description in both English and Russian locales to better reflect its nature.

* Добавлены новые роли для СЦК и печать Секторального судьи и прочие изменение.

* Refactor Centcom Blue Shield identifiers and update job configurations

- Changed SpawnPointCentcomBlueShield to SpawnPointCentcommBlueShield for consistency.
- Updated centcom_blueshield_officer.yml to change icon from JobIconBlueShield to JobIconNanotrasen.
- Adjusted delta.yml to maintain consistent naming conventions for BlueShield roles and added NtrOfficer.
- Cleaned up migration.yml by removing unused references to BlueShield lockers and suits.

* Обновлены роли в дельте. Изменено количество роли на дельте.

* Удалены устаревшие имя роли Центком и добавлены новые имя роли для английского и русского языков

* moved duplicate ghost theme file and consolidate strings

* Изменено название роли "Офицер Синего Щита Центком" на "оперативник «Синий щит»" для улучшения точности перевода.

* Обновлены роли и идентификаторы Центком для улучшения точности и актуальности

* update ghost theme sprite path and localization

* remove extra blank line in ghost_themes.yml

* add von Valantius painting and related assets

Add new painting texture, localization files, and prototype for Centcomm von Valantius painting. Includes English and Russian translations. Also reorganize some existing localization files for sectoral judge items.

* minor update elizabeth von valantius painting texture

* add new audio, textures, and prototypes, major changes in grid spawning and docking system

- Add new lobby music tracks and sound effects with proper licensing
- Implement new job roles: Oversight Officer and SpecOps Officer
- Add new shuttle docking system for special operations teams
- Include localization files for new content
- Update station maps and job configurations
- Add new equipment and weapons for special operations personnel
- Implemented updated automatic grid spawning and docking system

* update centcom oversight equipment to use correct headset

Change headset from Magistrate to FishCentCom variant to match intended equipment specification

* update logo-fish-splash.png texture

---------

Co-authored-by: VladSukr <vladsukr@gmail.com>
Co-authored-by: ReWAFFlution <153686236+ReWAFFlution@users.noreply.github.com>
Co-authored-by: SplikZerys <tronezero700@yandex.ru>
Co-authored-by: pixarF <sadfrogisfrog@gmail.com>
Co-authored-by: KaiserMaus <kaiser.ratte@gmail.com>
Co-authored-by: Sunrise-Bot <sunrise.project.top@gmail.com>
Co-authored-by: Kendrick <133131761+Kendrick15@users.noreply.github.com>
Co-authored-by: haiwwkes <49613070+rhailrake@users.noreply.github.com>
2025-07-28 19:27:13 +03:00

1188 lines
47 KiB
C#

using System.Globalization;
using System.Linq;
using System.Text;
using Content.Server._Sunrise.Chat;
using Content.Server._Sunrise.Chat.Sanitization;
using Content.Server.Administration.Logs;
using Content.Server.Administration.Managers;
using Content.Server.Chat.Managers;
using Content.Server.GameTicking;
using Content.Server.Speech.Prototypes;
using Content.Server.Speech.EntitySystems;
using Content.Server.Station.Components;
using Content.Server.Station.Systems;
using Content.Shared._Sunrise.Antags.Abductor;
using Content.Shared._Sunrise.CollectiveMind;
using Content.Shared.ActionBlocker;
using Content.Shared.Administration;
using Content.Shared.CCVar;
using Content.Shared.Chat;
using Content.Shared.Database;
using Content.Shared.Examine;
using Content.Shared.Ghost;
using Content.Shared.IdentityManagement;
using Content.Shared.Mobs.Systems;
using Content.Shared.Players;
using Content.Shared.Players.RateLimiting;
using Content.Shared.Popups;
using Content.Shared.Radio;
using Content.Shared.Whitelist;
using Robust.Server.Player;
using Robust.Shared.Audio;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Configuration;
using Robust.Shared.Console;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Replays;
using Robust.Shared.Utility;
namespace Content.Server.Chat.Systems;
// TODO refactor whatever active warzone this class and chatmanager have become
/// <summary>
/// ChatSystem is responsible for in-simulation chat handling, such as whispering, speaking, emoting, etc.
/// ChatSystem depends on ChatManager to actually send the messages.
/// </summary>
public sealed partial class ChatSystem : SharedChatSystem
{
[Dependency] private readonly IReplayRecordingManager _replay = default!;
[Dependency] private readonly IConfigurationManager _configurationManager = default!;
[Dependency] private readonly IChatManager _chatManager = default!;
[Dependency] private readonly IChatSanitizationManager _sanitizer = default!;
[Dependency] private readonly IAdminManager _adminManager = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly IAdminLogManager _adminLogger = default!;
[Dependency] private readonly ActionBlockerSystem _actionBlocker = default!;
[Dependency] private readonly StationSystem _stationSystem = default!;
[Dependency] private readonly MobStateSystem _mobStateSystem = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly ReplacementAccentSystem _wordreplacement = default!;
[Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
[Dependency] private readonly ExamineSystemShared _examineSystem = default!;
[Dependency] private readonly SharedPopupSystem _popupSystem = default!;
public const string DefaultAnnouncementSound = "/Audio/Announcements/announce.ogg"; // Fish-edit
private bool _loocEnabled = true;
private bool _deadLoocEnabled;
private bool _critLoocEnabled;
private readonly bool _adminLoocEnabled = true;
public override void Initialize()
{
base.Initialize();
CacheEmotes();
Subs.CVar(_configurationManager, CCVars.LoocEnabled, OnLoocEnabledChanged, true);
Subs.CVar(_configurationManager, CCVars.DeadLoocEnabled, OnDeadLoocEnabledChanged, true);
Subs.CVar(_configurationManager, CCVars.CritLoocEnabled, OnCritLoocEnabledChanged, true);
SubscribeLocalEvent<GameRunLevelChangedEvent>(OnGameChange);
}
private void OnLoocEnabledChanged(bool val)
{
if (_loocEnabled == val) return;
_loocEnabled = val;
_chatManager.DispatchServerAnnouncement(
Loc.GetString(val ? "chat-manager-looc-chat-enabled-message" : "chat-manager-looc-chat-disabled-message"));
}
private void OnDeadLoocEnabledChanged(bool val)
{
if (_deadLoocEnabled == val) return;
_deadLoocEnabled = val;
_chatManager.DispatchServerAnnouncement(
Loc.GetString(val ? "chat-manager-dead-looc-chat-enabled-message" : "chat-manager-dead-looc-chat-disabled-message"));
}
private void OnCritLoocEnabledChanged(bool val)
{
if (_critLoocEnabled == val)
return;
_critLoocEnabled = val;
_chatManager.DispatchServerAnnouncement(
Loc.GetString(val ? "chat-manager-crit-looc-chat-enabled-message" : "chat-manager-crit-looc-chat-disabled-message"));
}
private void OnGameChange(GameRunLevelChangedEvent ev)
{
switch (ev.New)
{
case GameRunLevel.InRound:
if (!_configurationManager.GetCVar(CCVars.OocEnableDuringRound))
_configurationManager.SetCVar(CCVars.OocEnabled, false);
break;
case GameRunLevel.PostRound:
case GameRunLevel.PreRoundLobby:
if (!_configurationManager.GetCVar(CCVars.OocEnableDuringRound))
_configurationManager.SetCVar(CCVars.OocEnabled, true);
break;
}
}
/// <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>
public 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,
bool isFormatted = false) // sunrise-edit
{
TrySendInGameICMessage(source, message, desiredType, hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, hideLog, shell, player, nameOverride, checkRadioPrefix, ignoreActionBlocker, isFormatted); //sunrise-edit
}
/// <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="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 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,
bool isFormatted = false //sunrise-edit
)
{
if (TryComp<AbductorComponent>(source, out var comp))
{
_prototypeManager.TryIndex<CollectiveMindPrototype>(comp.AbductorCollectiveMindProto, out var channel);
SendCollectiveMindChat(source, message, channel);
return;
}
if (HasComp<GhostComponent>(source))
{
// Ghosts can only send dead chat messages, so we'll forward it to InGame OOC.
TrySendInGameOOCMessage(source, message, InGameOOCChatType.Dead, range == ChatTransmitRange.HideChat, shell, player);
return;
}
if (player != null && _chatManager.HandleRateLimit(player) != RateLimitStatus.Allowed)
return;
// Sus
if (player?.AttachedEntity is { Valid: true } entity && source != entity)
{
return;
}
if (!CanSendInGame(message, shell, player))
return;
// Sunrise added start - для санитизации чата
var trySendEvent = new TrySendChatMessageEvent(message, desiredType);
RaiseLocalEvent(source, trySendEvent);
if (trySendEvent.Cancelled)
return;
message = trySendEvent.Message;
// Sunrise added end
ignoreActionBlocker = CheckIgnoreSpeechBlocker(source, ignoreActionBlocker);
// this method is a disaster
// every second i have to spend working with this code is fucking agony
// scientists have to wonder how any of this was merged
// coding any game admin feature that involves chat code is pure torture
// changing even 10 lines of code feels like waterboarding myself
// and i dont feel like vibe checking 50 code paths
// so we set this here
// todo free me from chat code
if (player != null)
{
_chatManager.EnsurePlayer(player.UserId).AddEntity(GetNetEntity(source));
}
if (desiredType == InGameICChatType.Speak && message.StartsWith(LocalPrefix))
{
// prevent radios and remove prefix.
checkRadioPrefix = false;
message = message[1..];
}
bool shouldCapitalize = (desiredType != InGameICChatType.Emote);
bool shouldPunctuate = _configurationManager.GetCVar(CCVars.ChatPunctuation);
// Capitalizing the word I only happens in English, so we check language here
bool shouldCapitalizeTheWordI = (!CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Parent.Name == "en")
|| (CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Name == "en");
message = SanitizeInGameICMessage(source, message, out var emoteStr, shouldCapitalize, shouldPunctuate, shouldCapitalizeTheWordI);
// Was there an emote in the message? If so, send it.
if (player != null && emoteStr != message && emoteStr != null)
{
SendEntityEmote(source, emoteStr, range, nameOverride, ignoreActionBlocker, isFormatted); //sunrise-edit
}
// This can happen if the entire string is sanitized out.
if (string.IsNullOrEmpty(message))
return;
// This message may have a radio prefix, and should then be whispered to the resolved radio channel
if (checkRadioPrefix)
{
if (TryProccessRadioMessage(source, message, out var modMessage, out var channel))
{
SendEntityWhisper(source, modMessage, range, channel, nameOverride, hideLog, ignoreActionBlocker, isFormatted); //sunrise-edit
return;
}
}
// Sunrise-Start
if (desiredType == InGameICChatType.CollectiveMind)
{
if (TryProccessCollectiveMindMessage(source, message, out var modMessage, out var channel))
{
SendCollectiveMindChat(source, modMessage, channel);
return;
}
}
// Sunrise-End
// Otherwise, send whatever type.
switch (desiredType)
{
case InGameICChatType.Speak:
SendEntitySpeak(source, message, range, nameOverride, hideLog, ignoreActionBlocker, isFormatted); //sunrise-edit-hg
break;
case InGameICChatType.Whisper:
SendEntityWhisper(source, message, range, null, nameOverride, hideLog, ignoreActionBlocker, isFormatted); // sunrise-edit
break;
case InGameICChatType.Emote:
SendEntityEmote(source, message, range, nameOverride, hideLog: hideLog, ignoreActionBlocker: ignoreActionBlocker, isFormatted: isFormatted); //sunrise-edit
break;
}
}
public void TrySendInGameOOCMessage(
EntityUid source,
string message,
InGameOOCChatType type,
bool hideChat,
IConsoleShell? shell = null,
ICommonSession? player = null
)
{
if (!CanSendInGame(message, shell, player))
return;
if (player != null && _chatManager.HandleRateLimit(player) != RateLimitStatus.Allowed)
return;
// It doesn't make any sense for a non-player to send in-game OOC messages, whereas non-players may be sending
// in-game IC messages.
if (player?.AttachedEntity is not { Valid: true } entity || source != entity)
return;
// Sunrise added start - для санитизации чата
var trySendEvent = new TrySendChatMessageEvent(message, oocChatType: type);
RaiseLocalEvent(source, trySendEvent);
if (trySendEvent.Cancelled)
return;
message = trySendEvent.Message;
// Sunrise added end
message = SanitizeInGameOOCMessage(message);
var sendType = type;
// If dead player LOOC is disabled, unless you are an admin with Moderator perms, send dead messages to dead chat
if ((_adminManager.IsAdmin(player) && _adminManager.HasAdminFlag(player, AdminFlags.Moderator)) // Override if admin
|| _deadLoocEnabled
|| (!HasComp<GhostComponent>(source) && !_mobStateSystem.IsDead(source))) // Check that player is not dead
{
}
else
sendType = InGameOOCChatType.Dead;
// If crit player LOOC is disabled, don't send the message at all.
if (!_critLoocEnabled && _mobStateSystem.IsCritical(source))
return;
switch (sendType)
{
case InGameOOCChatType.Dead:
SendDeadChat(source, player, message, hideChat);
break;
case InGameOOCChatType.Looc:
SendLOOC(source, player, message, hideChat);
break;
}
}
#region Announcements
/// <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="playTts"></param>
/// <param name="colorOverride">Optional color for the announcement message</param>
public void DispatchGlobalAnnouncement(
string message,
string? sender = null,
bool playDefault = true,
SoundSpecifier? announcementSound = null,
bool playTts = true, // Sunrise-edit,
string? announceVoice = null,
Color? colorOverride = null
)
{
sender ??= Loc.GetString("chat-manager-sender-announcement");
var wrappedMessage = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender), ("message", FormattedMessage.EscapeText(message)));
_chatManager.ChatMessageToAll(ChatChannel.Radio, message, wrappedMessage, default, false, true, colorOverride);
// Sunrise-start
if (playDefault && announcementSound == null)
{
announcementSound ??= new SoundPathSpecifier(DefaultAnnouncementSound);
}
if (playTts && announcementSound != null)
{
var announcementEv = new AnnouncementSpokeEvent(Filter.Broadcast(), message, _audio.ResolveSound(announcementSound), announceVoice);
RaiseLocalEvent(announcementEv);
}
// Sunrise-end
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Global station announcement from {sender}: {message}");
}
/// <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="playDefaultSound">Play the announcement sound</param>
/// <param name="announcementSound">Sound to play</param>
/// <param name="colorOverride">Optional color for the announcement message</param>
public void DispatchFilteredAnnouncement(
Filter filter,
string message,
EntityUid? source = null,
string? sender = null,
bool playDefault = true, // Sunrise-edit
bool playTts = true, // Sunrise-edit
string? announceVoice = null, // Sunrise-edit
SoundSpecifier? announcementSound = null,
Color? colorOverride = null)
{
sender ??= Loc.GetString("chat-manager-sender-announcement");
var wrappedMessage = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender), ("message", FormattedMessage.EscapeText(message)));
_chatManager.ChatMessageToManyFiltered(filter, ChatChannel.Radio, message, wrappedMessage, source ?? default, false, true, colorOverride);
// Sunrise-start
if (playDefault && announcementSound == null)
announcementSound = new SoundPathSpecifier(DefaultAnnouncementSound);
if (playTts && announcementSound != null)
{
RaiseLocalEvent(new AnnouncementSpokeEvent(filter, message, _audio.ResolveSound(announcementSound), announceVoice));
}
// Sunrise-edit
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Station Announcement from {sender}: {message}");
}
/// <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="playSound"></param>
/// <param name="playTts"></param>
/// <param name="colorOverride">Optional color for the announcement message</param>
public void DispatchStationAnnouncement(
EntityUid source,
string message,
string? sender = null,
bool playDefault = true, // Sunrise-edit
bool playTts = true, // Sunrise-edit
Color? colorOverride = null,
string? announceVoice = null, // Sunrise-edit
SoundSpecifier? announcementSound = null)
{
sender ??= Loc.GetString("chat-manager-sender-announcement");
var wrappedMessage = Loc.GetString("chat-manager-sender-announcement-wrap-message", ("sender", sender), ("message", FormattedMessage.EscapeText(message)));
var station = _stationSystem.GetOwningStation(source);
if (station == null)
{
// you can't make a station announcement without a station
return;
}
if (!EntityManager.TryGetComponent<StationDataComponent>(station, out var stationDataComp)) return;
var filter = _stationSystem.GetInStation(stationDataComp);
_chatManager.ChatMessageToManyFiltered(filter, ChatChannel.Radio, message, wrappedMessage, source, false, true, colorOverride);
// Sunrise-start
if (playDefault && announcementSound == null)
announcementSound = new SoundPathSpecifier(DefaultAnnouncementSound);
if (playTts && announcementSound != null)
{
RaiseLocalEvent(new AnnouncementSpokeEvent(filter, message, _audio.ResolveSound(announcementSound), announceVoice));
}
// Sunrise-edit
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Station Announcement on {station} from {sender}: {message}");
}
#endregion
#region Private API
// Sunrise-Start
private void SendCollectiveMindChat(EntityUid source, string message, CollectiveMindPrototype? collectiveMind)
{
if (_mobStateSystem.IsDead(source))
return;
if (collectiveMind == null || message == "")
return;
if (!TryComp<CollectiveMindComponent>(source, out var sourseCollectiveMindComp))
return;
if (!sourseCollectiveMindComp.Minds.Contains(collectiveMind.ID))
return;
// Sunrise added start - для санитизации чата
var trySendEvent = new TrySendChatMessageEvent(message, InGameICChatType.CollectiveMind);
RaiseLocalEvent(source, trySendEvent);
if (trySendEvent.Cancelled)
return;
message = trySendEvent.Message;
// Sunrise added end
var clients = Filter.Empty();
var mindQuery = EntityQueryEnumerator<CollectiveMindComponent, ActorComponent>();
while (mindQuery.MoveNext(out var uid, out var collectMindComp, out var actorComp))
{
if (_mobStateSystem.IsDead(uid))
continue;
if (collectMindComp.Minds.Contains(collectiveMind.ID))
{
clients.AddPlayer(actorComp.PlayerSession);
}
}
var admins = _adminManager.ActiveAdmins
.Select(p => p.Channel);
string messageWrap;
string adminMessageWrap;
messageWrap = Loc.GetString("collective-mind-chat-wrap-message",
("message", message),
("channel", collectiveMind.LocalizedName));
adminMessageWrap = Loc.GetString("collective-mind-chat-wrap-message-admin",
("source", source),
("message", message),
("channel", collectiveMind.LocalizedName));
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"CollectiveMind chat from {ToPrettyString(source):Player}: {message}");
_chatManager.ChatMessageToManyFiltered(clients,
ChatChannel.CollectiveMind,
message,
messageWrap,
source,
false,
true,
collectiveMind.Color);
_chatManager.ChatMessageToMany(ChatChannel.CollectiveMind,
message,
adminMessageWrap,
source,
false,
true,
admins,
collectiveMind.Color);
}
// Sunrise-End
private void SendEntitySpeak(
EntityUid source,
string originalMessage,
ChatTransmitRange range,
string? nameOverride,
bool hideLog = false,
bool ignoreActionBlocker = false,
bool isFormatted = false //sunrise-edit
)
{
if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker)
return;
var message = TransformSpeech(source, originalMessage);
if (message.Length == 0)
return;
var speech = GetSpeechVerb(source, message);
// get the entity's apparent name (if no override provided).
string name;
if (nameOverride != null)
{
name = nameOverride;
}
else
{
var nameEv = new TransformSpeakerNameEvent(source, Name(source));
RaiseLocalEvent(source, nameEv);
name = nameEv.VoiceName;
// Check for a speech verb override
if (nameEv.SpeechVerb != null && _prototypeManager.TryIndex(nameEv.SpeechVerb, out var proto))
speech = proto;
}
name = FormattedMessage.EscapeText(name);
if (HasComp<CanFormatMessagesComponent>(source)) isFormatted = true; //sunrise-edit
var wrappedMessage = Loc.GetString(speech.Bold ? "chat-manager-entity-say-bold-wrap-message" : "chat-manager-entity-say-wrap-message",
("entityName", name),
("verb", Loc.GetString(_random.Pick(speech.SpeechVerbStrings))),
("fontType", speech.FontId),
("fontSize", speech.FontSize),
("message", isFormatted ? message : FormattedMessage.EscapeText(message))); //sunrise-edit
SendInVoiceRange(ChatChannel.Local, message, wrappedMessage, source, range);
var ev = new EntitySpokeEvent(source, message, originalMessage, null, null);
RaiseLocalEvent(source, ev, true);
// To avoid logging any messages sent by entities that are not players, like vendors, cloning, etc.
// Also doesn't log if hideLog is true.
if (!HasComp<ActorComponent>(source) || hideLog)
return;
if (originalMessage == message)
{
if (name != Name(source))
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Say from {ToPrettyString(source):user} as {name}: {originalMessage}.");
else
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Say from {ToPrettyString(source):user}: {originalMessage}.");
}
else
{
if (name != Name(source))
_adminLogger.Add(LogType.Chat, LogImpact.Low,
$"Say from {ToPrettyString(source):user} as {name}, original: {originalMessage}, transformed: {message}.");
else
_adminLogger.Add(LogType.Chat, LogImpact.Low,
$"Say from {ToPrettyString(source):user}, original: {originalMessage}, transformed: {message}.");
}
}
private void SendEntityWhisper(
EntityUid source,
string originalMessage,
ChatTransmitRange range,
RadioChannelPrototype? channel,
string? nameOverride,
bool hideLog = false,
bool ignoreActionBlocker = false,
bool isFormatted = false //sunrise-edit
)
{
if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker)
return;
var message = TransformSpeech(source, FormattedMessage.RemoveMarkupOrThrow(originalMessage));
if (message.Length == 0)
return;
var obfuscatedMessage = ObfuscateMessageReadability(message, 0.2f);
// get the entity's name by visual identity (if no override provided).
string nameIdentity = FormattedMessage.EscapeText(nameOverride ?? Identity.Name(source, EntityManager));
// get the entity's name by voice (if no override provided).
string name;
if (nameOverride != null)
{
name = nameOverride;
}
else
{
var nameEv = new TransformSpeakerNameEvent(source, Name(source));
RaiseLocalEvent(source, nameEv);
name = nameEv.VoiceName;
}
name = FormattedMessage.EscapeText(name);
if (HasComp<CanFormatMessagesComponent>(source)) isFormatted = true; //sunrise-edit
var wrappedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message",
("entityName", name), ("message", isFormatted ? message : FormattedMessage.EscapeText(message))); //sunrise-edit
var wrappedobfuscatedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message",
("entityName", nameIdentity), ("message", isFormatted ? obfuscatedMessage : FormattedMessage.EscapeText(obfuscatedMessage))); //sunrise-edit
var wrappedUnknownMessage = Loc.GetString("chat-manager-entity-whisper-unknown-wrap-message",
("message", isFormatted ? obfuscatedMessage : FormattedMessage.EscapeText(obfuscatedMessage))); //sunrise-edit
foreach (var (session, data) in GetRecipients(source, WhisperMuffledRange))
{
EntityUid listener;
if (session.AttachedEntity is not { Valid: true } playerEntity)
continue;
listener = session.AttachedEntity.Value;
if (MessageRangeCheck(session, data, range) != MessageRangeCheckResult.Full)
continue; // Won't get logged to chat, and ghosts are too far away to see the pop-up, so we just won't send it to them.
if (data.Range <= WhisperClearRange)
_chatManager.ChatMessageToOne(ChatChannel.Whisper, message, wrappedMessage, source, false, session.Channel);
//If listener is too far, they only hear fragments of the message
else if (_examineSystem.InRangeUnOccluded(source, listener, WhisperMuffledRange))
_chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedobfuscatedMessage, source, false, session.Channel);
//If listener is too far and has no line of sight, they can't identify the whisperer's identity
else
_chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedUnknownMessage, source, false, session.Channel);
}
_replay.RecordServerMessage(new ChatMessage(ChatChannel.Whisper, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range)));
var ev = new EntitySpokeEvent(source, message, originalMessage, channel, obfuscatedMessage);
RaiseLocalEvent(source, ev, true);
if (!hideLog)
if (originalMessage == message)
{
if (name != Name(source))
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user} as {name}: {originalMessage}.");
else
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user}: {originalMessage}.");
}
else
{
if (name != Name(source))
_adminLogger.Add(LogType.Chat, LogImpact.Low,
$"Whisper from {ToPrettyString(source):user} as {name}, original: {originalMessage}, transformed: {message}.");
else
_adminLogger.Add(LogType.Chat, LogImpact.Low,
$"Whisper from {ToPrettyString(source):user}, original: {originalMessage}, transformed: {message}.");
}
}
private void SendEntityEmote(
EntityUid source,
string action,
ChatTransmitRange range,
string? nameOverride,
bool hideLog = false,
bool checkEmote = true,
bool ignoreActionBlocker = false,
NetUserId? author = null,
bool isFormatted = false //sunrise-edit
)
{
if (!_actionBlocker.CanEmote(source) && !ignoreActionBlocker)
return;
// get the entity's apparent name (if no override provided).
var ent = Identity.Entity(source, EntityManager);
string name = FormattedMessage.EscapeText(nameOverride ?? Name(ent));
if (HasComp<CanFormatMessagesComponent>(source)) isFormatted = true; //sunrise-edit
// Emotes use Identity.Name, since it doesn't actually involve your voice at all.
var wrappedMessage = Loc.GetString("chat-manager-entity-me-wrap-message",
("entityName", isFormatted ? "" : name), //sunrise-edit
("entity", ent),
("message", isFormatted ? action : FormattedMessage.RemoveMarkupOrThrow(action)));
if (checkEmote)
TryEmoteChatInput(source, action); // sunrise-start
foreach (var (session, data) in GetRecipients(source, VoiceRange))
{
EntityUid listener;
if (session.AttachedEntity is not { Valid: true } playerEntity)
continue;
listener = session.AttachedEntity.Value;
if (MessageRangeCheck(session, data, range) != MessageRangeCheckResult.Full)
continue;
if (_examineSystem.InRangeUnOccluded(source, listener, VoiceRange))
{
_chatManager.ChatMessageToOne(ChatChannel.Emotes, action, wrappedMessage, source, false, session.Channel);
}
} // sunrise-end
if (!hideLog)
if (name != Name(source))
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user} as {name}: {action}");
else
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user}: {action}");
}
// ReSharper disable once InconsistentNaming
private void SendLOOC(EntityUid source, ICommonSession player, string message, bool hideChat)
{
var name = FormattedMessage.EscapeText(Identity.Name(source, EntityManager));
if (_adminManager.IsAdmin(player))
{
if (!_adminLoocEnabled) return;
}
else if (!_loocEnabled) return;
// If crit player LOOC is disabled, don't send the message at all.
if (!_critLoocEnabled && _mobStateSystem.IsCritical(source))
return;
var wrappedMessage = Loc.GetString("chat-manager-entity-looc-wrap-message",
("entityName", name),
("message", FormattedMessage.EscapeText(message)));
SendInVoiceRange(ChatChannel.LOOC, message, wrappedMessage, source, hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, player.UserId);
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"LOOC from {player:Player}: {message}");
}
private void SendDeadChat(EntityUid source, ICommonSession player, string message, bool hideChat)
{
var clients = GetDeadChatClients();
var playerName = Name(source);
string wrappedMessage;
if (_adminManager.IsAdmin(player))
{
wrappedMessage = Loc.GetString("chat-manager-send-admin-dead-chat-wrap-message",
("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")),
("userName", player.Channel.UserName),
("message", FormattedMessage.EscapeText(message)));
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Admin dead chat from {player:Player}: {message}");
}
else
{
wrappedMessage = Loc.GetString("chat-manager-send-dead-chat-wrap-message",
("deadChannelName", Loc.GetString("chat-manager-dead-channel-name")),
("playerName", (playerName)),
("message", FormattedMessage.EscapeText(message)));
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Dead chat from {player:Player}: {message}");
}
_chatManager.ChatMessageToMany(ChatChannel.Dead, message, wrappedMessage, source, hideChat, true, clients.ToList(), author: player.UserId);
}
#endregion
#region Utility
private enum MessageRangeCheckResult
{
Disallowed,
HideChat,
Full
}
/// <summary>
/// If hideChat should be set as far as replays are concerned.
/// </summary>
private bool MessageRangeHideChatForReplay(ChatTransmitRange range)
{
return range == ChatTransmitRange.HideChat;
}
/// <summary>
/// Checks if a target as returned from GetRecipients should receive the message.
/// Keep in mind data.Range is -1 for out of range observers.
/// </summary>
private MessageRangeCheckResult MessageRangeCheck(ICommonSession session, ICChatRecipientData data, ChatTransmitRange range)
{
var initialResult = MessageRangeCheckResult.Full;
switch (range)
{
case ChatTransmitRange.Normal:
initialResult = MessageRangeCheckResult.Full;
break;
case ChatTransmitRange.GhostRangeLimit:
initialResult = (data.Observer && data.Range < 0 && !_adminManager.IsAdmin(session)) ? MessageRangeCheckResult.HideChat : MessageRangeCheckResult.Full;
break;
case ChatTransmitRange.HideChat:
initialResult = MessageRangeCheckResult.HideChat;
break;
case ChatTransmitRange.NoGhosts:
initialResult = (data.Observer && !_adminManager.IsAdmin(session)) ? MessageRangeCheckResult.Disallowed : MessageRangeCheckResult.Full;
break;
}
var insistHideChat = data.HideChatOverride ?? false;
var insistNoHideChat = !(data.HideChatOverride ?? true);
if (insistHideChat && initialResult == MessageRangeCheckResult.Full)
return MessageRangeCheckResult.HideChat;
if (insistNoHideChat && initialResult == MessageRangeCheckResult.HideChat)
return MessageRangeCheckResult.Full;
return initialResult;
}
/// <summary>
/// Sends a chat message to the given players in range of the source entity.
/// </summary>
public void SendInVoiceRange(ChatChannel channel, string message, string wrappedMessage, EntityUid source, ChatTransmitRange range, NetUserId? author = null, Color? color = null)
{
foreach (var (session, data) in GetRecipients(source, VoiceRange))
{
var entRange = MessageRangeCheck(session, data, range);
if (entRange == MessageRangeCheckResult.Disallowed)
continue;
var entHideChat = entRange == MessageRangeCheckResult.HideChat;
_chatManager.ChatMessageToOne(channel, message, wrappedMessage, source, entHideChat, session.Channel, author: author, colorOverride: color);
}
_replay.RecordServerMessage(new ChatMessage(channel, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range)));
}
/// <summary>
/// Returns true if the given player is 'allowed' to send the given message, false otherwise.
/// </summary>
private bool CanSendInGame(string message, IConsoleShell? shell = null, ICommonSession? player = null)
{
// Non-players don't have to worry about these restrictions.
if (player == null)
return true;
var mindContainerComponent = player.ContentData()?.Mind;
if (mindContainerComponent == null)
{
shell?.WriteError("You don't have a mind!");
return false;
}
if (player.AttachedEntity is not { Valid: true } _)
{
shell?.WriteError("You don't have an entity!");
return false;
}
return !_chatManager.MessageCharacterLimit(player, message);
}
// ReSharper disable once InconsistentNaming
private string SanitizeInGameICMessage(EntityUid source, string message, out string? emoteStr, bool capitalize = true, bool punctuate = false, bool capitalizeTheWordI = true)
{
var newMessage = SanitizeMessageReplaceWords(message.Trim());
GetRadioKeycodePrefix(source, newMessage, out newMessage, out var prefix);
// Sanitize it first as it might change the word order
_sanitizer.TrySanitizeEmoteShorthands(newMessage, source, out newMessage, out emoteStr);
if (capitalize)
newMessage = SanitizeMessageCapital(newMessage);
if (capitalizeTheWordI)
newMessage = SanitizeMessageCapitalizeTheWordI(newMessage, "i");
if (punctuate)
newMessage = SanitizeMessagePeriod(newMessage);
return prefix + newMessage;
}
private string SanitizeInGameOOCMessage(string message)
{
var newMessage = message.Trim();
newMessage = FormattedMessage.EscapeText(newMessage);
return newMessage;
}
public string TransformSpeech(EntityUid sender, string message)
{
var ev = new TransformSpeechEvent(sender, message);
RaiseLocalEvent(ev);
return ev.Message;
}
public bool CheckIgnoreSpeechBlocker(EntityUid sender, bool ignoreBlocker)
{
if (ignoreBlocker)
return ignoreBlocker;
var ev = new CheckIgnoreSpeechBlockerEvent(sender, ignoreBlocker);
RaiseLocalEvent(sender, ev, true);
return ev.IgnoreBlocker;
}
private IEnumerable<INetChannel> GetDeadChatClients()
{
return Filter.Empty()
.AddWhereAttachedEntity(HasComp<GhostComponent>)
.Recipients
.Union(_adminManager.ActiveAdmins)
.Select(p => p.Channel);
}
private string SanitizeMessagePeriod(string message)
{
if (string.IsNullOrEmpty(message))
return message;
// Adds a period if the last character is a letter.
if (char.IsLetter(message[^1]))
message += ".";
return message;
}
[ValidatePrototypeId<ReplacementAccentPrototype>]
public const string ChatSanitizeAccent = "chatsanitize_sunrise"; // Sunrise-Edit
public string SanitizeMessageReplaceWords(string message)
{
if (string.IsNullOrEmpty(message)) return message;
var msg = message;
msg = _wordreplacement.ApplyReplacements(msg, ChatSanitizeAccent);
return msg;
}
/// <summary>
/// Returns list of players and ranges for all players withing some range. Also returns observers with a range of -1.
/// </summary>
private Dictionary<ICommonSession, ICChatRecipientData> GetRecipients(EntityUid source, float voiceGetRange)
{
// TODO proper speech occlusion
var recipients = new Dictionary<ICommonSession, ICChatRecipientData>();
var ghostHearing = GetEntityQuery<GhostHearingComponent>();
var xforms = GetEntityQuery<TransformComponent>();
var transformSource = xforms.GetComponent(source);
var sourceMapId = transformSource.MapID;
var sourceCoords = transformSource.Coordinates;
foreach (var player in _playerManager.Sessions)
{
if (player.AttachedEntity is not { Valid: true } playerEntity)
continue;
var transformEntity = xforms.GetComponent(playerEntity);
if (transformEntity.MapID != sourceMapId)
continue;
var observer = ghostHearing.HasComponent(playerEntity);
// even if they are a ghost hearer, in some situations we still need the range
if (sourceCoords.TryDistance(EntityManager, transformEntity.Coordinates, out var distance) && distance < voiceGetRange)
{
recipients.Add(player, new ICChatRecipientData(distance, observer));
continue;
}
if (observer)
recipients.Add(player, new ICChatRecipientData(-1, true));
}
RaiseLocalEvent(new ExpandICChatRecipientsEvent(source, voiceGetRange, recipients));
return recipients;
}
public readonly record struct ICChatRecipientData(float Range, bool Observer, bool? HideChatOverride = null)
{
}
private string ObfuscateMessageReadability(string message, float chance)
{
var modifiedMessage = new StringBuilder(message);
for (var i = 0; i < message.Length; i++)
{
if (char.IsWhiteSpace((modifiedMessage[i])))
{
continue;
}
if (_random.Prob(1 - chance))
{
modifiedMessage[i] = '~';
}
}
return modifiedMessage.ToString();
}
public string BuildGibberishString(IReadOnlyList<char> charOptions, int length)
{
var sb = new StringBuilder();
for (var i = 0; i < length; i++)
{
sb.Append(_random.Pick(charOptions));
}
return sb.ToString();
}
#endregion
}
/// <summary>
/// This event is raised before chat messages are sent out to clients. This enables some systems to send the chat
/// messages to otherwise out-of view entities (e.g. for multiple viewports from cameras).
/// </summary>
public record ExpandICChatRecipientsEvent(EntityUid Source, float VoiceRange, Dictionary<ICommonSession, ChatSystem.ICChatRecipientData> Recipients)
{
}
/// <summary>
/// Raised broadcast in order to transform speech.transmit
/// </summary>
public sealed class TransformSpeechEvent : EntityEventArgs
{
public EntityUid Sender;
public string Message;
public TransformSpeechEvent(EntityUid sender, string message)
{
Sender = sender;
Message = message;
}
}
public sealed class CheckIgnoreSpeechBlockerEvent : EntityEventArgs
{
public EntityUid Sender;
public bool IgnoreBlocker;
public CheckIgnoreSpeechBlockerEvent(EntityUid sender, bool ignoreBlocker)
{
Sender = sender;
IgnoreBlocker = ignoreBlocker;
}
}
/// <summary>
/// Raised on an entity when it speaks, either through 'say' or 'whisper'.
/// </summary>
public sealed class EntitySpokeEvent : EntityEventArgs
{
public readonly EntityUid Source;
public readonly string Message;
public readonly string? ObfuscatedMessage; // not null if this was a whisper
public readonly string OriginalMessage; // Sunrise-TTS
public readonly bool IsRadio; // Sunrise-TTS
/// <summary>
/// If the entity was trying to speak into a radio, this was the channel they were trying to access. If a radio
/// message gets sent on this channel, this should be set to null to prevent duplicate messages.
/// </summary>
public RadioChannelPrototype? Channel;
public EntitySpokeEvent(EntityUid source, string message, string originalMessage, RadioChannelPrototype? channel, string? obfuscatedMessage)
{
Source = source;
Message = message;
Channel = channel;
ObfuscatedMessage = obfuscatedMessage;
OriginalMessage = originalMessage; // Sunrise-TTS
IsRadio = channel != null; // Sunrise-TTS
}
}
/// <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, // Sunrise-TTS
CollectiveMind, // Sunrise-Edit
}
/// <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
}
/// <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
}
// Sunrise-TTS-Start
public sealed class AnnouncementSpokeEvent(
Filter source,
string message,
ResolvedSoundSpecifier? announcementSound,
string? announceVoice)
: EntityEventArgs
{
public readonly Filter Source = source;
public readonly string Message = message;
public readonly string? AnnounceVoice = announceVoice;
public readonly ResolvedSoundSpecifier? AnnouncementSound = announcementSound;
}
public sealed class RadioSpokeEvent(EntityUid source, string message, EntityUid[] receivers) : EntityEventArgs
{
public readonly EntityUid Source = source;
public readonly string Message = message;
public readonly EntityUid[] Receivers = receivers;
}
// Sunrise-TTS-End