From 39756781b82d02e57d4d7af8799ae41c0b5c7f21 Mon Sep 17 00:00:00 2001 From: DrSmugleaf Date: Sun, 5 Nov 2023 18:19:59 -0800 Subject: [PATCH 01/91] Fix erase verb not removing chat messages in some cases (#21355) * Fix erase verb not removing chat messages in some cases * Admin changelog * Fix deleting messages with entity id 0 --- Content.Server/Chat/ChatUser.cs | 34 +++++++++++ Content.Server/Chat/Managers/ChatManager.cs | 64 +++++++++++++------- Content.Server/Chat/Managers/IChatManager.cs | 21 +++---- Content.Server/Chat/Systems/ChatSystem.cs | 47 ++++++-------- Resources/Changelog/Admin.yml | 6 ++ 5 files changed, 108 insertions(+), 64 deletions(-) create mode 100644 Content.Server/Chat/ChatUser.cs diff --git a/Content.Server/Chat/ChatUser.cs b/Content.Server/Chat/ChatUser.cs new file mode 100644 index 00000000000..9b63dbc42cc --- /dev/null +++ b/Content.Server/Chat/ChatUser.cs @@ -0,0 +1,34 @@ +using Content.Shared.Chat; + +namespace Content.Server.Chat; + +public sealed class ChatUser +{ + /// + /// The unique key associated with this chat user, starting from 1 and incremented. + /// Used when the server sends . + /// Used on the client to delete messages sent by this user when receiving + /// . + /// + public readonly int Key; + + /// + /// All entities that this chat user was attached to while sending chat messages. + /// Sent to the client to delete messages sent by those entities when receiving + /// . + /// + public readonly HashSet Entities = new(); + + public ChatUser(int key) + { + Key = key; + } + + public void AddEntity(NetEntity entity) + { + if (!entity.Valid) + return; + + Entities.Add(entity); + } +} diff --git a/Content.Server/Chat/Managers/ChatManager.cs b/Content.Server/Chat/Managers/ChatManager.cs index 59a9d305bd2..51aa1e3afc3 100644 --- a/Content.Server/Chat/Managers/ChatManager.cs +++ b/Content.Server/Chat/Managers/ChatManager.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Runtime.InteropServices; using Content.Server.Administration.Logs; @@ -49,8 +50,7 @@ internal sealed class ChatManager : IChatManager private bool _oocEnabled = true; private bool _adminOocEnabled = true; - public Dictionary SenderKeys { get; } = new(); - public Dictionary> SenderEntities { get; } = new(); + private readonly Dictionary _players = new(); public void Initialize() { @@ -79,13 +79,26 @@ private void OnAdminOocEnabledChanged(bool val) public void DeleteMessagesBy(ICommonSession player) { - var key = SenderKeys.GetValueOrDefault(player); - var entities = SenderEntities.GetValueOrDefault(player) ?? new HashSet(); - var msg = new MsgDeleteChatMessagesBy { Key = key, Entities = entities }; + if (!_players.TryGetValue(player.UserId, out var user)) + return; + var msg = new MsgDeleteChatMessagesBy { Key = user.Key, Entities = user.Entities }; _netManager.ServerSendToAll(msg); } + [return: NotNullIfNotNull(nameof(author))] + public ChatUser? EnsurePlayer(NetUserId? author) + { + if (author == null) + return null; + + ref var user = ref CollectionsMarshal.GetValueRefOrAddDefault(_players, author.Value, out var exists); + if (!exists || user == null) + user = new ChatUser(_players.Count); + + return user; + } + #region Server Announcements public void DispatchServerAnnouncement(string message, Color? colorOverride = null) @@ -214,12 +227,8 @@ private void SendOOC(ICommonSession player, string message) wrappedMessage = Loc.GetString("chat-manager-send-ooc-patron-wrap-message", ("patronColor", patronColor),("playerName", player.Name), ("message", FormattedMessage.EscapeText(message))); } - ref var key = ref CollectionsMarshal.GetValueRefOrAddDefault(SenderKeys, player, out var exists); - if (!exists) - key = SenderKeys.Count; - //TODO: player.Name color, this will need to change the structure of the MsgChatMessage - ChatMessageToAll(ChatChannel.OOC, message, wrappedMessage, EntityUid.Invalid, hideChat: false, recordReplay: true, colorOverride: colorOverride, senderKey: key); + ChatMessageToAll(ChatChannel.OOC, message, wrappedMessage, EntityUid.Invalid, hideChat: false, recordReplay: true, colorOverride: colorOverride, author: player.UserId); _mommiLink.SendOOCMessage(player.Name, message); _adminLogger.Add(LogType.Chat, LogImpact.Low, $"OOC from {player:Player}: {message}"); } @@ -237,10 +246,6 @@ private void SendAdminChat(ICommonSession player, string message) ("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")), ("playerName", player.Name), ("message", FormattedMessage.EscapeText(message))); - ref var key = ref CollectionsMarshal.GetValueRefOrAddDefault(SenderKeys, player, out var exists); - if (!exists) - key = SenderKeys.Count; - foreach (var client in clients) { var isSource = client != player.ConnectedClient; @@ -251,7 +256,8 @@ private void SendAdminChat(ICommonSession player, string message) false, client, audioPath: isSource ? _netConfigManager.GetClientCVar(client, CCVars.AdminChatSoundPath) : default, - audioVolume: isSource ? _netConfigManager.GetClientCVar(client, CCVars.AdminChatSoundVolume) : default, senderKey: key); + audioVolume: isSource ? _netConfigManager.GetClientCVar(client, CCVars.AdminChatSoundVolume) : default, + author: player.UserId); } _adminLogger.Add(LogType.Chat, $"Admin chat from {player:Player}: {message}"); @@ -261,9 +267,13 @@ private void SendAdminChat(ICommonSession player, string message) #region Utility - public void ChatMessageToOne(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, INetChannel client, Color? colorOverride = null, bool recordReplay = false, string? audioPath = null, float audioVolume = 0, int? senderKey = null) + public void ChatMessageToOne(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, INetChannel client, Color? colorOverride = null, bool recordReplay = false, string? audioPath = null, float audioVolume = 0, NetUserId? author = null) { - var msg = new ChatMessage(channel, message, wrappedMessage, _entityManager.GetNetEntity(source), senderKey, hideChat, colorOverride, audioPath, audioVolume); + var user = author == null ? null : EnsurePlayer(author); + var netSource = _entityManager.GetNetEntity(source); + user?.AddEntity(netSource); + + var msg = new ChatMessage(channel, message, wrappedMessage, netSource, user?.Key, hideChat, colorOverride, audioPath, audioVolume); _netManager.ServerSendMessage(new MsgChatMessage() { Message = msg }, client); if (!recordReplay) @@ -276,12 +286,16 @@ public void ChatMessageToOne(ChatChannel channel, string message, string wrapped } } - public void ChatMessageToMany(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, IEnumerable clients, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0) - => ChatMessageToMany(channel, message, wrappedMessage, source, hideChat, recordReplay, clients.ToList(), colorOverride, audioPath, audioVolume); + public void ChatMessageToMany(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, IEnumerable clients, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0, NetUserId? author = null) + => ChatMessageToMany(channel, message, wrappedMessage, source, hideChat, recordReplay, clients.ToList(), colorOverride, audioPath, audioVolume, author); - public void ChatMessageToMany(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, List clients, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0) + public void ChatMessageToMany(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, List clients, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0, NetUserId? author = null) { - var msg = new ChatMessage(channel, message, wrappedMessage, _entityManager.GetNetEntity(source), null, hideChat, colorOverride, audioPath, audioVolume); + var user = author == null ? null : EnsurePlayer(author); + var netSource = _entityManager.GetNetEntity(source); + user?.AddEntity(netSource); + + var msg = new ChatMessage(channel, message, wrappedMessage, netSource, user?.Key, hideChat, colorOverride, audioPath, audioVolume); _netManager.ServerSendToMany(new MsgChatMessage() { Message = msg }, clients); if (!recordReplay) @@ -309,9 +323,13 @@ public void ChatMessageToManyFiltered(Filter filter, ChatChannel channel, string ChatMessageToMany(channel, message, wrappedMessage, source, hideChat, recordReplay, clients, colorOverride, audioPath, audioVolume); } - public void ChatMessageToAll(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0, int? senderKey = null) + public void ChatMessageToAll(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0, NetUserId? author = null) { - var msg = new ChatMessage(channel, message, wrappedMessage, _entityManager.GetNetEntity(source), senderKey, hideChat, colorOverride, audioPath, audioVolume); + var user = author == null ? null : EnsurePlayer(author); + var netSource = _entityManager.GetNetEntity(source); + user?.AddEntity(netSource); + + var msg = new ChatMessage(channel, message, wrappedMessage, netSource, user?.Key, hideChat, colorOverride, audioPath, audioVolume); _netManager.ServerSendToAll(new MsgChatMessage() { Message = msg }); if (!recordReplay) diff --git a/Content.Server/Chat/Managers/IChatManager.cs b/Content.Server/Chat/Managers/IChatManager.cs index 5317b3054e4..34f16fe3111 100644 --- a/Content.Server/Chat/Managers/IChatManager.cs +++ b/Content.Server/Chat/Managers/IChatManager.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Content.Shared.Chat; using Robust.Shared.Network; using Robust.Shared.Player; @@ -6,17 +7,6 @@ namespace Content.Server.Chat.Managers { public interface IChatManager { - /// - /// Keys identifying messages sent by a specific player, used when sending - /// - /// - Dictionary SenderKeys { get; } - - /// - /// Tracks which entities a player was attached to while sending messages. - /// - Dictionary> SenderEntities { get; } - void Initialize(); /// @@ -36,17 +26,20 @@ public interface IChatManager void SendAdminAlert(EntityUid player, string message); void ChatMessageToOne(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, - INetChannel client, Color? colorOverride = null, bool recordReplay = false, string? audioPath = null, float audioVolume = 0, int? senderKey = null); + INetChannel client, Color? colorOverride = null, bool recordReplay = false, string? audioPath = null, float audioVolume = 0, NetUserId? author = null); void ChatMessageToMany(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, - IEnumerable clients, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0); + IEnumerable clients, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0, NetUserId? author = null); void ChatMessageToManyFiltered(Filter filter, ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, Color? colorOverride, string? audioPath = null, float audioVolume = 0); - void ChatMessageToAll(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0, int? senderKey = null); + void ChatMessageToAll(ChatChannel channel, string message, string wrappedMessage, EntityUid source, bool hideChat, bool recordReplay, Color? colorOverride = null, string? audioPath = null, float audioVolume = 0, NetUserId? author = null); bool MessageCharacterLimit(ICommonSession player, string message); void DeleteMessagesBy(ICommonSession player); + + [return: NotNullIfNotNull(nameof(author))] + ChatUser? EnsurePlayer(NetUserId? author); } } diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs index 3e51189455c..0f3e728a10f 100644 --- a/Content.Server/Chat/Systems/ChatSystem.cs +++ b/Content.Server/Chat/Systems/ChatSystem.cs @@ -1,12 +1,12 @@ using System.Globalization; using System.Linq; using System.Text; -using Content.Server.Speech.EntitySystems; -using Content.Server.Speech.Components; using Content.Server.Administration.Logs; using Content.Server.Administration.Managers; using Content.Server.Chat.Managers; using Content.Server.GameTicking; +using Content.Server.Speech.Components; +using Content.Server.Speech.EntitySystems; using Content.Server.Station.Components; using Content.Server.Station.Systems; using Content.Shared.ActionBlocker; @@ -19,7 +19,6 @@ using Content.Shared.Mobs.Systems; using Content.Shared.Players; using Content.Shared.Radio; -using Robust.Server.GameObjects; using Robust.Server.Player; using Robust.Shared.Audio; using Robust.Shared.Configuration; @@ -200,8 +199,18 @@ public void TrySendInGameICMessage( if (!CanSendInGame(message, shell, player)) return; + // 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.SenderEntities.GetOrNew(player).Add(GetNetEntity(source)); + { + _chatManager.EnsurePlayer(player.UserId).AddEntity(GetNetEntity(source)); + } if (desiredType == InGameICChatType.Speak && message.StartsWith(LocalPrefix)) { @@ -572,7 +581,8 @@ private void SendEntityEmote( string? nameOverride, bool hideLog = false, bool checkEmote = true, - bool ignoreActionBlocker = false + bool ignoreActionBlocker = false, + NetUserId? author = null ) { if (!_actionBlocker.CanEmote(source) && !ignoreActionBlocker) @@ -590,7 +600,7 @@ private void SendEntityEmote( if (checkEmote) TryEmoteChatInput(source, action); - SendInVoiceRange(ChatChannel.Emotes, action, wrappedMessage, source, range, false, "", ""); + SendInVoiceRange(ChatChannel.Emotes, action, wrappedMessage, source, range, author); if (!hideLog) if (name != Name(source)) _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user} as {name}: {action}"); @@ -617,9 +627,7 @@ private void SendLOOC(EntityUid source, ICommonSession player, string message, b ("entityName", name), ("message", FormattedMessage.EscapeText(message))); - _chatManager.SenderEntities.GetOrNew(player).Add(GetNetEntity(source)); - - SendInVoiceRange(ChatChannel.LOOC, message, wrappedMessage, source, hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, false, "", ""); + SendInVoiceRange(ChatChannel.LOOC, message, wrappedMessage, source, hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, player.UserId); _adminLogger.Add(LogType.Chat, LogImpact.Low, $"LOOC from {player:Player}: {message}"); } @@ -645,9 +653,7 @@ private void SendDeadChat(EntityUid source, ICommonSession player, string messag _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Dead chat from {player:Player}: {message}"); } - _chatManager.SenderEntities.GetOrNew(player).Add(GetNetEntity(source)); - - _chatManager.ChatMessageToMany(ChatChannel.Dead, message, wrappedMessage, source, hideChat, true, clients.ToList()); + _chatManager.ChatMessageToMany(ChatChannel.Dead, message, wrappedMessage, source, hideChat, true, clients.ToList(), author: player.UserId); } #endregion @@ -701,7 +707,7 @@ private MessageRangeCheckResult MessageRangeCheck(ICommonSession session, ICChat /// /// Sends a chat message to the given players in range of the source entity. /// - private void SendInVoiceRange(ChatChannel channel, string message, string wrappedMessage, EntityUid source, ChatTransmitRange range, bool Canilunzt, string CanilunztMessage, string CanilunztwrappedMessage) + private void SendInVoiceRange(ChatChannel channel, string message, string wrappedMessage, EntityUid source, ChatTransmitRange range, NetUserId? author = null) { foreach (var (session, data) in GetRecipients(source, VoiceRange)) { @@ -709,20 +715,7 @@ private void SendInVoiceRange(ChatChannel channel, string message, string wrappe if (entRange == MessageRangeCheckResult.Disallowed) continue; var entHideChat = entRange == MessageRangeCheckResult.HideChat; - - EntityUid listener; - - if (session.AttachedEntity is not { Valid: true } playerEntity) - continue; - listener = session.AttachedEntity.Value; - - if (Canilunzt && !IsCanilunztListener(listener)) - { - message = CanilunztMessage; - wrappedMessage = CanilunztwrappedMessage; - } - - _chatManager.ChatMessageToOne(channel, message, wrappedMessage, source, entHideChat, session.ConnectedClient); + _chatManager.ChatMessageToOne(channel, message, wrappedMessage, source, entHideChat, session.ConnectedClient, author: author); } _replay.RecordServerMessage(new ChatMessage(channel, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range))); diff --git a/Resources/Changelog/Admin.yml b/Resources/Changelog/Admin.yml index a5e13ab040b..92641ad69e7 100644 --- a/Resources/Changelog/Admin.yml +++ b/Resources/Changelog/Admin.yml @@ -55,3 +55,9 @@ Entries: commands.', type: Tweak} id: 8 time: '2023-10-21T09:53:00.0000000+00:00' +- author: DrSmugleaf + changes: + - {message: 'Fixed the Erase verb not removing all chat messages from the player + in some cases.', type: Fix} + id: 9 + time: '2023-10-30T01:28:00.0000000+00:00' From 228503765b4c2bab99fcf1a1e9fed623cecc4ffb Mon Sep 17 00:00:00 2001 From: Dvir Date: Fri, 1 Dec 2023 20:19:10 +0200 Subject: [PATCH 02/91] Removing vulp speak as a temp fix --- Content.Server/Chat/Systems/ChatSystem.cs | 149 ++-------------------- 1 file changed, 8 insertions(+), 141 deletions(-) diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs index 0f3e728a10f..61db8d82115 100644 --- a/Content.Server/Chat/Systems/ChatSystem.cs +++ b/Content.Server/Chat/Systems/ChatSystem.cs @@ -29,10 +29,6 @@ using Robust.Shared.Random; using Robust.Shared.Replays; using Robust.Shared.Utility; -using Content.Shared.Inventory; -using Content.Shared.Hands.EntitySystems; -using Content.Server.PowerCell; -using Content.Server.VulpLangauge; namespace Content.Server.Chat.Systems; @@ -58,9 +54,6 @@ public sealed partial class ChatSystem : SharedChatSystem [Dependency] private readonly SharedAudioSystem _audio = default!; [Dependency] private readonly SharedInteractionSystem _interactionSystem = default!; [Dependency] private readonly ReplacementAccentSystem _wordreplacement = default!; - [Dependency] private readonly InventorySystem _inventorySystem = default!; - [Dependency] private readonly SharedHandsSystem _handsSystem = default!; - [Dependency] private readonly PowerCellSystem _cell = default!; 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 @@ -409,28 +402,13 @@ private void SendEntitySpeak( ("fontSize", speech.FontSize), ("message", FormattedMessage.EscapeText(message))); - var Canilunzt = false; - var CanilunztMessage = ""; - var CanilunztwrappedMessage = ""; - if (IsCanilunztSpeaker(source) && !HasCanilunztTranslator(source)) - { - Canilunzt = true; - CanilunztMessage = MessagetoCanilunzt(message); - CanilunztwrappedMessage = 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", FormattedMessage.EscapeText(CanilunztMessage))); - } + SendInVoiceRange(ChatChannel.Local, message, wrappedMessage, source, range); - SendInVoiceRange(ChatChannel.Local, message, wrappedMessage, source, range, Canilunzt, CanilunztMessage, CanilunztwrappedMessage); - - var ev = new EntitySpokeEvent(source, message, null, null, Canilunzt); + var ev = new EntitySpokeEvent(source, message, 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. + // Also doesn't log if hideLog is true. if (!HasComp(source) || hideLog == true) return; @@ -497,27 +475,6 @@ private void SendEntityWhisper( var wrappedUnknownMessage = Loc.GetString("chat-manager-entity-whisper-unknown-wrap-message", ("message", FormattedMessage.EscapeText(obfuscatedMessage))); - var canilunzt = false; - var canilunztmessage = ""; - var canilunztobfuscatedMessage = ""; - var canilunztwrappedMessage = ""; - var canilunztwrappedobfuscatedMessage = ""; - var canilunztwrappedUnknownMessage = ""; - if (IsCanilunztSpeaker(source) && !HasCanilunztTranslator(source)) - { - canilunzt = true; - canilunztmessage = MessagetoCanilunzt(message); - canilunztobfuscatedMessage = ObfuscateMessageReadability(canilunztmessage, 0.2f); - canilunztwrappedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message", - ("entityName", name), ("message", FormattedMessage.EscapeText(canilunztmessage))); - - canilunztwrappedobfuscatedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message", - ("entityName", nameIdentity), ("message", FormattedMessage.EscapeText(canilunztobfuscatedMessage))); - - canilunztwrappedUnknownMessage = Loc.GetString("chat-manager-entity-whisper-unknown-wrap-message", - ("message", FormattedMessage.EscapeText(canilunztobfuscatedMessage))); - } - foreach (var (session, data) in GetRecipients(source, WhisperMuffledRange)) { @@ -530,15 +487,6 @@ private void SendEntityWhisper( 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 (canilunzt && !IsCanilunztListener(listener)) - { - message = canilunztmessage; - obfuscatedMessage = canilunztobfuscatedMessage; - wrappedMessage = canilunztwrappedMessage; - wrappedobfuscatedMessage = canilunztwrappedobfuscatedMessage; - wrappedUnknownMessage = canilunztwrappedUnknownMessage; - } - if (data.Range <= WhisperClearRange) _chatManager.ChatMessageToOne(ChatChannel.Whisper, message, wrappedMessage, source, false, session.ConnectedClient); //If listener is too far, they only hear fragments of the message @@ -552,9 +500,8 @@ private void SendEntityWhisper( _replay.RecordServerMessage(new ChatMessage(ChatChannel.Whisper, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range))); - var ev = new EntitySpokeEvent(source, message, channel, obfuscatedMessage, canilunzt); + var ev = new EntitySpokeEvent(source, message, channel, obfuscatedMessage); RaiseLocalEvent(source, ev, true); - if (!hideLog) if (originalMessage == message) { @@ -659,7 +606,8 @@ private void SendDeadChat(EntityUid source, ICommonSession player, string messag #region Utility - private enum MessageRangeCheckResult { + private enum MessageRangeCheckResult + { Disallowed, HideChat, Full @@ -831,7 +779,7 @@ private Dictionary GetRecipients(EntityUid foreach (var player in _playerManager.Sessions) { - if (player.AttachedEntity is not {Valid: true} playerEntity) + if (player.AttachedEntity is not { Valid: true } playerEntity) continue; var transformEntity = xforms.GetComponent(playerEntity); @@ -880,85 +828,6 @@ private string ObfuscateMessageReadability(string message, float chance) return modifiedMessage.ToString(); } - private static readonly IReadOnlyList CanilunztSyllables = new List{ - "rur","ya","cen","rawr","bar","kuk","tek","qat","uk","wu","vuh","tah","tch","schz","auch","ist","ein","entch","zwichs","tut","mir","wo","bis","es","vor","nic","gro","lll","enem","zandt","tzch","noch","hel","ischt","far","wa","baram","iereng","tech","lach","sam","mak","lich","gen","or","ag","eck","gec","stag","onn","bin","ket","jarl","vulf","einech","cresthz","azunein","ghzth" - }.AsReadOnly(); - - public string MessagetoCanilunzt(string message) - { - var msg = message; - var words = message.Split(); - var accentedMessage = new StringBuilder(message.Length + 2); - for (var i = 0; i < words.Length; i++) - { - accentedMessage.Append(_random.Pick(CanilunztSyllables)); - if (i < words.Length - 1) - accentedMessage.Append(' '); - } - accentedMessage.Append('.'); - msg = accentedMessage.ToString(); - - return msg; - } - - public bool IsCanilunztSpeaker(EntityUid source) - { - if (HasComp(source)) - { - return true; - } - return false; - } - - public bool IsCanilunztListener(EntityUid source) - { - if (HasComp(source) || HasComp(source)) - { - return true; - } - return false; - } - - private bool CheckItemForCanilunztTranslator(EntityUid source) - { - if (HasComp(source)) - { - if (_cell.TryUseActivatableCharge(source)) - { - return true; - } - } - return false; - } - - public bool HasCanilunztTranslator(EntityUid source) - { - foreach (var item in _handsSystem.EnumerateHeld(source)) - { - if (CheckItemForCanilunztTranslator(item)) - { - return true; - } - } - - if (_inventorySystem.TryGetSlotEntity(source, "pocket1", out var item2)) - { - if (item2 is { Valid : true } stationUid && CheckItemForCanilunztTranslator(stationUid)) - { - return true; - } - } - else if (_inventorySystem.TryGetSlotEntity(source, "pocket2", out var item3)) - { - if (item3 is { Valid : true } stationUid && CheckItemForCanilunztTranslator(stationUid)) - { - return true; - } - } - - return false; - } - #endregion } @@ -1005,7 +874,6 @@ 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 bool Canilunzt; // If is a Canilunzt Message /// /// If the entity was trying to speak into a radio, this was the channel they were trying to access. If a radio @@ -1013,13 +881,12 @@ public sealed class EntitySpokeEvent : EntityEventArgs /// public RadioChannelPrototype? Channel; - public EntitySpokeEvent(EntityUid source, string message, RadioChannelPrototype? channel, string? obfuscatedMessage, bool canilunzt) + public EntitySpokeEvent(EntityUid source, string message, RadioChannelPrototype? channel, string? obfuscatedMessage) { Source = source; Message = message; Channel = channel; ObfuscatedMessage = obfuscatedMessage; - Canilunzt = canilunzt; } } From 4693f3c0a89ec2180f9b385416e448b850f283ad Mon Sep 17 00:00:00 2001 From: Dvir Date: Fri, 1 Dec 2023 20:21:20 +0200 Subject: [PATCH 03/91] Update HeadsetSystem.cs --- Content.Server/Radio/EntitySystems/HeadsetSystem.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Content.Server/Radio/EntitySystems/HeadsetSystem.cs b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs index 8872e3512f7..adaad492dc2 100644 --- a/Content.Server/Radio/EntitySystems/HeadsetSystem.cs +++ b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs @@ -48,10 +48,6 @@ private void UpdateRadioChannels(EntityUid uid, HeadsetComponent headset, Encryp private void OnSpeak(EntityUid uid, WearingHeadsetComponent component, EntitySpokeEvent args) { - if (args.Canilunzt) - { - return; - } if (args.Channel != null && TryComp(component.Headset, out EncryptionKeyHolderComponent? keys) && keys.Channels.Contains(args.Channel.ID)) From d06fe5a5bf4e3d856c00802a3286800759cef310 Mon Sep 17 00:00:00 2001 From: Dvir Date: Fri, 1 Dec 2023 20:29:14 +0200 Subject: [PATCH 04/91] Update RadioSystem.cs --- Content.Server/Radio/EntitySystems/RadioSystem.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/Content.Server/Radio/EntitySystems/RadioSystem.cs b/Content.Server/Radio/EntitySystems/RadioSystem.cs index 8836f571582..fa00514e610 100644 --- a/Content.Server/Radio/EntitySystems/RadioSystem.cs +++ b/Content.Server/Radio/EntitySystems/RadioSystem.cs @@ -39,10 +39,6 @@ public override void Initialize() private void OnIntrinsicSpeak(EntityUid uid, IntrinsicRadioTransmitterComponent component, EntitySpokeEvent args) { - if (args.Canilunzt) - { - return; - } if (args.Channel != null && component.Channels.Contains(args.Channel.ID)) { SendRadioMessage(uid, args.Message, args.Channel, uid); From 4ee3c4e719ef5365654c4d9a03255914e2005dc0 Mon Sep 17 00:00:00 2001 From: Dvir Date: Fri, 1 Dec 2023 20:30:20 +0200 Subject: [PATCH 05/91] Update ListeningSystem.cs --- Content.Server/Speech/EntitySystems/ListeningSystem.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Content.Server/Speech/EntitySystems/ListeningSystem.cs b/Content.Server/Speech/EntitySystems/ListeningSystem.cs index dd0c0e8a81e..3fb98f502c8 100644 --- a/Content.Server/Speech/EntitySystems/ListeningSystem.cs +++ b/Content.Server/Speech/EntitySystems/ListeningSystem.cs @@ -18,10 +18,6 @@ public override void Initialize() private void OnSpeak(EntitySpokeEvent ev) { - if (ev.Canilunzt) - { - return; - } PingListeners(ev.Source, ev.Message, ev.ObfuscatedMessage); } @@ -39,7 +35,7 @@ public void PingListeners(EntityUid source, string message, string? obfuscatedMe var obfuscatedEv = obfuscatedMessage == null ? null : new ListenEvent(obfuscatedMessage, source); var query = EntityQueryEnumerator(); - while(query.MoveNext(out var listenerUid, out var listener, out var xform)) + while (query.MoveNext(out var listenerUid, out var listener, out var xform)) { if (xform.MapID != sourceXform.MapID) continue; From e66adbc8d116eef6207ed0cf5ce140ec573b5969 Mon Sep 17 00:00:00 2001 From: fox Date: Tue, 5 Dec 2023 11:32:50 +0300 Subject: [PATCH 06/91] Feat/languages: Initial commit (something works now) --- Content.Server/Chat/Systems/ChatSystem.cs | 178 +++++++++++++----- .../Radio/EntitySystems/HeadsetSystem.cs | 15 +- .../Radio/EntitySystems/RadioDeviceSystem.cs | 4 +- .../Radio/EntitySystems/RadioSystem.cs | 68 ++++--- Content.Server/Radio/RadioEvent.cs | 13 +- .../Speech/EntitySystems/ListeningSystem.cs | 7 +- Content.Server/Speech/LanguageSystem.cs | 177 +++++++++++++++++ Content.Shared/Language/LanguagePrototype.cs | 27 +++ .../Language/LanguageSpeakerComponent.cs | 23 +++ .../UniversalLanguageSpeakerComponent.cs | 11 ++ .../Entities/Mobs/Species/vulpkanin.yml | 3 + .../Entities/Mobs/Player/observer.yml | 1 + .../Prototypes/Entities/Mobs/Species/base.yml | 5 +- .../_Nyano/Entities/Mobs/NPCs/dogs.yml | 5 +- .../_Nyano/Entities/Mobs/NPCs/mothroach.yml | 5 +- Resources/Prototypes/languages.yml | 109 +++++++++++ 16 files changed, 567 insertions(+), 84 deletions(-) create mode 100644 Content.Server/Speech/LanguageSystem.cs create mode 100644 Content.Shared/Language/LanguagePrototype.cs create mode 100644 Content.Shared/Language/LanguageSpeakerComponent.cs create mode 100644 Content.Shared/Language/UniversalLanguageSpeakerComponent.cs create mode 100644 Resources/Prototypes/languages.yml diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs index 61db8d82115..b0c4627f554 100644 --- a/Content.Server/Chat/Systems/ChatSystem.cs +++ b/Content.Server/Chat/Systems/ChatSystem.cs @@ -5,6 +5,7 @@ using Content.Server.Administration.Managers; using Content.Server.Chat.Managers; using Content.Server.GameTicking; +using Content.Server.Speech; using Content.Server.Speech.Components; using Content.Server.Speech.EntitySystems; using Content.Server.Station.Components; @@ -16,6 +17,7 @@ using Content.Shared.Ghost; using Content.Shared.IdentityManagement; using Content.Shared.Interaction; +using Content.Shared.Language; using Content.Shared.Mobs.Systems; using Content.Shared.Players; using Content.Shared.Radio; @@ -39,6 +41,8 @@ namespace Content.Server.Chat.Systems; /// public sealed partial class ChatSystem : SharedChatSystem { + public const float DefaultObfuscationFactor = 0.2f; + [Dependency] private readonly IReplayRecordingManager _replay = default!; [Dependency] private readonly IConfigurationManager _configurationManager = default!; [Dependency] private readonly IChatManager _chatManager = default!; @@ -54,6 +58,7 @@ public sealed partial class ChatSystem : SharedChatSystem [Dependency] private readonly SharedAudioSystem _audio = default!; [Dependency] private readonly SharedInteractionSystem _interactionSystem = default!; [Dependency] private readonly ReplacementAccentSystem _wordreplacement = default!; + [Dependency] private readonly LanguageSystem _language = default!; 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 @@ -173,7 +178,8 @@ public void TrySendInGameICMessage( ICommonSession? player = null, string? nameOverride = null, bool checkRadioPrefix = true, - bool ignoreActionBlocker = false + bool ignoreActionBlocker = false, + LanguagePrototype? languageOverride = null ) { if (HasComp(source)) @@ -244,10 +250,10 @@ public void TrySendInGameICMessage( switch (desiredType) { case InGameICChatType.Speak: - SendEntitySpeak(source, message, range, nameOverride, hideLog, ignoreActionBlocker); + SendEntitySpeak(source, message, range, nameOverride, hideLog, ignoreActionBlocker, languageOverride: languageOverride); break; case InGameICChatType.Whisper: - SendEntityWhisper(source, message, range, null, nameOverride, hideLog, ignoreActionBlocker); + SendEntityWhisper(source, message, range, null, nameOverride, hideLog, ignoreActionBlocker, languageOverride: languageOverride); break; case InGameICChatType.Emote: SendEntityEmote(source, message, range, nameOverride, hideLog: hideLog, ignoreActionBlocker: ignoreActionBlocker); @@ -370,7 +376,8 @@ private void SendEntitySpeak( ChatTransmitRange range, string? nameOverride, bool hideLog = false, - bool ignoreActionBlocker = false + bool ignoreActionBlocker = false, + LanguagePrototype? languageOverride = null ) { if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker) @@ -393,23 +400,21 @@ private void SendEntitySpeak( name = nameEv.Name; } + var language = languageOverride ?? _language.GetLanguage(source); + name = FormattedMessage.EscapeText(name); - var speech = GetSpeechVerb(source, message); - 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", FormattedMessage.EscapeText(message))); + var wrappedMessage = WrapPublicMessage(source, name, message); + var obfuscated = _language.ObfuscateSpeech(source, message, language); + var wrappedObfuscated = WrapPublicMessage(source, name, obfuscated); - SendInVoiceRange(ChatChannel.Local, message, wrappedMessage, source, range); + SendInVoiceRange(ChatChannel.Local, name, message, wrappedMessage, obfuscated, wrappedObfuscated, source, range); - var ev = new EntitySpokeEvent(source, message, null, null); + var ev = new EntitySpokeEvent(source, message, null, false, language); 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(source) || hideLog == true) + if (!HasComp(source) || hideLog) return; if (originalMessage == message) @@ -437,7 +442,8 @@ private void SendEntityWhisper( RadioChannelPrototype? channel, string? nameOverride, bool hideLog = false, - bool ignoreActionBlocker = false + bool ignoreActionBlocker = false, + LanguagePrototype? languageOverride = null ) { if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker) @@ -447,8 +453,6 @@ private void SendEntityWhisper( 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). @@ -463,44 +467,60 @@ private void SendEntityWhisper( RaiseLocalEvent(source, nameEv); name = nameEv.Name; } - name = FormattedMessage.EscapeText(name); - - - var wrappedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message", - ("entityName", name), ("message", FormattedMessage.EscapeText(message))); - var wrappedobfuscatedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message", - ("entityName", nameIdentity), ("message", FormattedMessage.EscapeText(obfuscatedMessage))); - - var wrappedUnknownMessage = Loc.GetString("chat-manager-entity-whisper-unknown-wrap-message", - ("message", FormattedMessage.EscapeText(obfuscatedMessage))); + name = FormattedMessage.EscapeText(name); + var language = languageOverride ?? _language.GetLanguage(source); + var languageObfuscatedMessage = _language.ObfuscateSpeech(source, message, language); + // There are 6 possible message states. It's not worth to precompute everything here. foreach (var (session, data) in GetRecipients(source, WhisperMuffledRange)) { - EntityUid listener; - - if (session.AttachedEntity is not { Valid: true } playerEntity) + if (session.AttachedEntity is not { Valid: true } listener) 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. + var canUnderstand = _language.CanUnderstand(source, listener); + var finalMessage = canUnderstand ? message : languageObfuscatedMessage; + if (data.Range <= WhisperClearRange) - _chatManager.ChatMessageToOne(ChatChannel.Whisper, message, wrappedMessage, source, false, session.ConnectedClient); - //If listener is too far, they only hear fragments of the message - //Collisiongroup.Opaque is not ideal for this use. Preferably, there should be a check specifically with "Can Ent1 see Ent2" in mind - else if (_interactionSystem.InRangeUnobstructed(source, listener, WhisperMuffledRange, Shared.Physics.CollisionGroup.Opaque)) //Shared.Physics.CollisionGroup.Opaque - _chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedobfuscatedMessage, source, false, session.ConnectedClient); - //If listener is too far and has no line of sight, they can't identify the whisperer's identity + { + var wrappedResult = Loc.GetString("chat-manager-entity-whisper-wrap-message", + ("entityName", name), + ("message", FormattedMessage.EscapeText(finalMessage))); + + // If the listener is in the clear range, do not perform further obfuscations + _chatManager.ChatMessageToOne(ChatChannel.Whisper, finalMessage, wrappedResult, source, false, session.ConnectedClient); + } + else if (_interactionSystem.InRangeUnobstructed(source, listener, WhisperMuffledRange, Shared.Physics.CollisionGroup.Opaque)) + { + + // If the listener is too far, they only hear fragments of the message + // Collisiongroup.Opaque is not ideal for this use. Preferably, there should be a check specifically with "Can Ent1 see Ent2" in mind + var result = ObfuscateMessageReadability(finalMessage); + var wrappedResult = Loc.GetString("chat-manager-entity-whisper-wrap-message", + ("entityName", nameIdentity), ("message", FormattedMessage.EscapeText(result))); + + _chatManager.ChatMessageToOne(ChatChannel.Whisper, result, wrappedResult, source, false, session.ConnectedClient); + } else - _chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedUnknownMessage, source, false, session.ConnectedClient); + { + //If listener is too far and has no line of sight, they can't identify the whisperer's identity + var result = ObfuscateMessageReadability(finalMessage); + var wrappedResult = Loc.GetString("chat-manager-entity-whisper-unknown-wrap-message", + ("message", FormattedMessage.EscapeText(result))); + + _chatManager.ChatMessageToOne(ChatChannel.Whisper, result, wrappedResult, source, false, session.ConnectedClient); + } } - _replay.RecordServerMessage(new ChatMessage(ChatChannel.Whisper, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range))); + var replayWrap = Loc.GetString("chat-manager-entity-whisper-wrap-message", + ("entityName", name), + ("message", FormattedMessage.EscapeText(message))); + _replay.RecordServerMessage(new ChatMessage(ChatChannel.Whisper, message, replayWrap, GetNetEntity(source), null, MessageRangeHideChatForReplay(range))); - var ev = new EntitySpokeEvent(source, message, channel, obfuscatedMessage); + var ev = new EntitySpokeEvent(source, message, channel, true, language); RaiseLocalEvent(source, ev, true); if (!hideLog) if (originalMessage == message) @@ -547,7 +567,8 @@ private void SendEntityEmote( if (checkEmote) TryEmoteChatInput(source, action); - SendInVoiceRange(ChatChannel.Emotes, action, wrappedMessage, source, range, author); + // Emotes are skipped in obfuscation check + SendInVoiceRange(ChatChannel.Emotes, name, action, wrappedMessage, obfuscated: "", obfuscatedWrappedMessage: "", source, range, author); if (!hideLog) if (name != Name(source)) _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user} as {name}: {action}"); @@ -574,7 +595,12 @@ private void SendLOOC(EntityUid source, ICommonSession player, string message, b ("entityName", name), ("message", FormattedMessage.EscapeText(message))); - SendInVoiceRange(ChatChannel.LOOC, message, wrappedMessage, source, hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, player.UserId); + SendInVoiceRange(ChatChannel.LOOC, name, message, wrappedMessage, + obfuscated: string.Empty, + obfuscatedWrappedMessage: string.Empty, // will be skipped anyway + source, + hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, + player.UserId); _adminLogger.Add(LogType.Chat, LogImpact.Low, $"LOOC from {player:Player}: {message}"); } @@ -655,7 +681,7 @@ private MessageRangeCheckResult MessageRangeCheck(ICommonSession session, ICChat /// /// Sends a chat message to the given players in range of the source entity. /// - private void SendInVoiceRange(ChatChannel channel, string message, string wrappedMessage, EntityUid source, ChatTransmitRange range, NetUserId? author = null) + private void SendInVoiceRange(ChatChannel channel, string name, string message, string wrappedMessage, string obfuscated, string obfuscatedWrappedMessage, EntityUid source, ChatTransmitRange range, NetUserId? author = null) { foreach (var (session, data) in GetRecipients(source, VoiceRange)) { @@ -663,7 +689,19 @@ private void SendInVoiceRange(ChatChannel channel, string message, string wrappe if (entRange == MessageRangeCheckResult.Disallowed) continue; var entHideChat = entRange == MessageRangeCheckResult.HideChat; - _chatManager.ChatMessageToOne(channel, message, wrappedMessage, source, entHideChat, session.ConnectedClient, author: author); + + if (session.AttachedEntity is not { Valid: true } playerEntity) + continue; + EntityUid listener = session.AttachedEntity.Value; + + if (channel == ChatChannel.LOOC || channel == ChatChannel.Emotes || _language.CanUnderstand(source, listener)) + { + _chatManager.ChatMessageToOne(channel, message, wrappedMessage, source, entHideChat, session.ConnectedClient, author: author); + } + else + { + _chatManager.ChatMessageToOne(channel, obfuscated, obfuscatedWrappedMessage, source, entHideChat, session.ConnectedClient, author: author); + } } _replay.RecordServerMessage(new ChatMessage(channel, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range))); @@ -729,6 +767,17 @@ public string TransformSpeech(EntityUid sender, string message) return ev.Message; } + /// + /// Transforms the given message perceived by the given listener and returns the new message and the new wrapped message. + /// + public string TransformPerceivedSpeech(EntityUid speaker, EntityUid listener, string message) + { + var ev = new TransformPerceivedMessageEvent(speaker, listener, message); + RaiseLocalEvent(ev); + + return ev.Message; + } + private IEnumerable GetDeadChatClients() { return Filter.Empty() @@ -762,6 +811,18 @@ public string SanitizeMessageReplaceWords(string message) return msg; } + public string WrapPublicMessage(EntityUid source, string name, string message) + { + var speech = GetSpeechVerb(source, message); + var verbName = Loc.GetString(_random.Pick(speech.SpeechVerbStrings)); + return Loc.GetString(speech.Bold ? "chat-manager-entity-say-bold-wrap-message" : "chat-manager-entity-say-wrap-message", + ("entityName", name), + ("verb", verbName), + ("fontType", speech.FontId), + ("fontSize", speech.FontSize), + ("message", FormattedMessage.EscapeText(message))); + } + /// /// Returns list of players and ranges for all players withing some range. Also returns observers with a range of -1. /// @@ -808,7 +869,7 @@ public readonly record struct ICChatRecipientData(float Range, bool Observer, bo { } - private string ObfuscateMessageReadability(string message, float chance) + public string ObfuscateMessageReadability(string message, float chance = DefaultObfuscationFactor) { var modifiedMessage = new StringBuilder(message); @@ -866,6 +927,23 @@ public TransformSpeechEvent(EntityUid sender, string message) } } +/// +/// Raised broadcast in order to transform the way an entity perceives a message. +/// +public sealed class TransformPerceivedMessageEvent : EntityEventArgs +{ + public EntityUid Sender; + public EntityUid Listener; + public string Message; + + public TransformPerceivedMessageEvent(EntityUid sender, EntityUid listener, string message) + { + Sender = sender; + Listener = listener; + Message = message; + } +} + /// /// Raised on an entity when it speaks, either through 'say' or 'whisper'. /// @@ -873,7 +951,8 @@ 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 bool IsWhisper; + public readonly LanguagePrototype Language; /// /// If the entity was trying to speak into a radio, this was the channel they were trying to access. If a radio @@ -881,12 +960,13 @@ public sealed class EntitySpokeEvent : EntityEventArgs /// public RadioChannelPrototype? Channel; - public EntitySpokeEvent(EntityUid source, string message, RadioChannelPrototype? channel, string? obfuscatedMessage) + public EntitySpokeEvent(EntityUid source, string message, RadioChannelPrototype? channel, bool isWhisper, LanguagePrototype language) { Source = source; Message = message; Channel = channel; - ObfuscatedMessage = obfuscatedMessage; + IsWhisper = isWhisper; + Language = language; } } diff --git a/Content.Server/Radio/EntitySystems/HeadsetSystem.cs b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs index adaad492dc2..e9887aa2b60 100644 --- a/Content.Server/Radio/EntitySystems/HeadsetSystem.cs +++ b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs @@ -1,6 +1,8 @@ using Content.Server.Chat.Systems; using Content.Server.Emp; using Content.Server.Radio.Components; +using Content.Server.Speech; +using Content.Shared.Chat; using Content.Shared.Inventory.Events; using Content.Shared.Radio; using Content.Shared.Radio.Components; @@ -14,6 +16,7 @@ public sealed class HeadsetSystem : SharedHeadsetSystem { [Dependency] private readonly INetManager _netMan = default!; [Dependency] private readonly RadioSystem _radio = default!; + [Dependency] private readonly LanguageSystem _language = default!; public override void Initialize() { @@ -99,8 +102,16 @@ public void SetEnabled(EntityUid uid, bool value, HeadsetComponent? component = private void OnHeadsetReceive(EntityUid uid, HeadsetComponent component, ref RadioReceiveEvent args) { - if (TryComp(Transform(uid).ParentUid, out ActorComponent? actor)) - _netMan.ServerSendMessage(args.ChatMsg, actor.PlayerSession.ConnectedClient); + var parent = Transform(uid).ParentUid; + if (TryComp(parent, out ActorComponent? actor)) + { + var canUnderstand = _language.CanUnderstand(parent, args.Language); + var msg = new MsgChatMessage + { + Message = canUnderstand ? args.UnderstoodChatMsg : args.NotUnderstoodChatMsg + }; + _netMan.ServerSendMessage(msg, actor.PlayerSession.ConnectedClient); + } } private void OnEmpPulse(EntityUid uid, HeadsetComponent component, ref EmpPulseEvent args) diff --git a/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs b/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs index 6a6c644fbbf..c34a9935391 100644 --- a/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs +++ b/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs @@ -29,6 +29,7 @@ public sealed class RadioDeviceSystem : EntitySystem [Dependency] private readonly InteractionSystem _interaction = default!; [Dependency] private readonly SharedAppearanceSystem _appearance = default!; [Dependency] private readonly UserInterfaceSystem _ui = default!; + [Dependency] private readonly LanguageSystem _language = default!; // Used to prevent a shitter from using a bunch of radios to spam chat. private HashSet<(string, EntityUid)> _recentlySent = new(); @@ -203,7 +204,8 @@ private void OnReceiveRadio(EntityUid uid, RadioSpeakerComponent component, ref ("originalName", nameEv.Name)); // log to chat so people can identity the speaker/source, but avoid clogging ghost chat if there are many radios - _chat.TrySendInGameICMessage(uid, args.Message, InGameICChatType.Whisper, ChatTransmitRange.GhostRangeLimit, nameOverride: name, checkRadioPrefix: false); + var message = args.UnderstoodChatMsg.Message; // The chat system will handle the rest and re-obfuscate if needed. + _chat.TrySendInGameICMessage(uid, message, InGameICChatType.Whisper, ChatTransmitRange.GhostRangeLimit, nameOverride: name, checkRadioPrefix: false); } private void OnBeforeIntercomUiOpen(EntityUid uid, IntercomComponent component, BeforeActivatableUIOpenEvent args) diff --git a/Content.Server/Radio/EntitySystems/RadioSystem.cs b/Content.Server/Radio/EntitySystems/RadioSystem.cs index fa00514e610..7def85c7d6a 100644 --- a/Content.Server/Radio/EntitySystems/RadioSystem.cs +++ b/Content.Server/Radio/EntitySystems/RadioSystem.cs @@ -2,9 +2,11 @@ using Content.Server.Chat.Systems; using Content.Server.Power.Components; using Content.Server.Radio.Components; +using Content.Server.Speech; using Content.Server.VoiceMask; using Content.Shared.Chat; using Content.Shared.Database; +using Content.Shared.Language; using Content.Shared.Radio; using Content.Shared.Radio.Components; using Robust.Shared.Map; @@ -26,6 +28,7 @@ public sealed class RadioSystem : EntitySystem [Dependency] private readonly IAdminLogManager _adminLogger = default!; [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly ChatSystem _chat = default!; + [Dependency] private readonly LanguageSystem _language = default!; // set used to prevent radio feedback loops. private readonly HashSet _messages = new(); @@ -41,7 +44,7 @@ private void OnIntrinsicSpeak(EntityUid uid, IntrinsicRadioTransmitterComponent { if (args.Channel != null && component.Channels.Contains(args.Channel.ID)) { - SendRadioMessage(uid, args.Message, args.Channel, uid); + SendRadioMessage(uid, args.Message, args.Channel, uid, args.Language); args.Channel = null; // prevent duplicate messages from other listeners. } } @@ -49,7 +52,16 @@ private void OnIntrinsicSpeak(EntityUid uid, IntrinsicRadioTransmitterComponent private void OnIntrinsicReceive(EntityUid uid, IntrinsicRadioReceiverComponent component, ref RadioReceiveEvent args) { if (TryComp(uid, out ActorComponent? actor)) - _netMan.ServerSendMessage(args.ChatMsg, actor.PlayerSession.ConnectedClient); + { + var listener = component.Owner; + var msg = args.UnderstoodChatMsg; + if (listener != null && !_language.CanUnderstand(listener, args.Language)) + { + msg = args.NotUnderstoodChatMsg; + } + + _netMan.ServerSendMessage(new MsgChatMessage { Message = msg}, actor.PlayerSession.ConnectedClient); + } } /// @@ -57,8 +69,14 @@ private void OnIntrinsicReceive(EntityUid uid, IntrinsicRadioReceiverComponent c /// /// Entity that spoke the message /// Entity that picked up the message and will send it, e.g. headset - public void SendRadioMessage(EntityUid messageSource, string message, RadioChannelPrototype channel, EntityUid radioSource) + /// The language to send the message in. Defaults to galactic common. + public void SendRadioMessage(EntityUid messageSource, string message, RadioChannelPrototype channel, EntityUid radioSource, LanguagePrototype? language = null) { + if (language == null) + { + language = _language.GetLanguage(messageSource); // Will return galactic common for non-player sources + } + // TODO if radios ever garble / modify messages, feedback-prevention needs to be handled better than this. if (!_messages.Add(message)) return; @@ -69,26 +87,17 @@ public void SendRadioMessage(EntityUid messageSource, string message, RadioChann name = FormattedMessage.EscapeText(name); - var speech = _chat.GetSpeechVerb(messageSource, message); + // most radios are relayed to chat, so lets parse the chat message beforehand + // A message that the listener could understand + var wrappedMessage = WrapRadioMessage(messageSource, channel, name, message); + var msg = new ChatMessage(ChatChannel.Radio, message, wrappedMessage, NetEntity.Invalid, null); - var wrappedMessage = Loc.GetString(speech.Bold ? "chat-radio-message-wrap-bold" : "chat-radio-message-wrap", - ("color", channel.Color), - ("fontType", speech.FontId), - ("fontSize", speech.FontSize), - ("verb", Loc.GetString(_random.Pick(speech.SpeechVerbStrings))), - ("channel", $"\\[{channel.LocalizedName}\\]"), - ("name", name), - ("message", FormattedMessage.EscapeText(message))); + // ... you guess it + var obfuscated = _language.ObfuscateSpeech(null, message, language); + var obfuscatedWrapped = WrapRadioMessage(messageSource, channel, name, obfuscated); + var notUdsMsg = new ChatMessage(ChatChannel.Radio, obfuscated, obfuscatedWrapped, NetEntity.Invalid, null); - // most radios are relayed to chat, so lets parse the chat message beforehand - var chat = new ChatMessage( - ChatChannel.Radio, - message, - wrappedMessage, - NetEntity.Invalid, - null); - var chatMsg = new MsgChatMessage { Message = chat }; - var ev = new RadioReceiveEvent(message, messageSource, channel, chatMsg); + var ev = new RadioReceiveEvent(messageSource, channel, msg, notUdsMsg, language); var sendAttemptEv = new RadioSendAttemptEvent(channel, radioSource); RaiseLocalEvent(ref sendAttemptEv); @@ -121,7 +130,7 @@ public void SendRadioMessage(EntityUid messageSource, string message, RadioChann if (attemptEv.Cancelled) continue; - // send the message + // Send the message RaiseLocalEvent(receiver, ref ev); } @@ -130,10 +139,23 @@ public void SendRadioMessage(EntityUid messageSource, string message, RadioChann else _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Radio message from {ToPrettyString(messageSource):user} on {channel.LocalizedName}: {message}"); - _replay.RecordServerMessage(chat); + _replay.RecordServerMessage(notUdsMsg); _messages.Remove(message); } + private string WrapRadioMessage(EntityUid source, RadioChannelPrototype channel, string name, string message) + { + var speech = _chat.GetSpeechVerb(source, message); + return Loc.GetString(speech.Bold ? "chat-radio-message-wrap-bold" : "chat-radio-message-wrap", + ("color", channel.Color), + ("fontType", speech.FontId), + ("fontSize", speech.FontSize), + ("verb", Loc.GetString(_random.Pick(speech.SpeechVerbStrings))), + ("channel", $"\\[{channel.LocalizedName}\\]"), + ("name", name), + ("message", FormattedMessage.EscapeText(message))); + } + /// private bool HasActiveServer(MapId mapId, string channelId) { diff --git a/Content.Server/Radio/RadioEvent.cs b/Content.Server/Radio/RadioEvent.cs index 69d764ffe67..b8c4506d24c 100644 --- a/Content.Server/Radio/RadioEvent.cs +++ b/Content.Server/Radio/RadioEvent.cs @@ -1,10 +1,21 @@ using Content.Shared.Chat; +using Content.Shared.Language; using Content.Shared.Radio; namespace Content.Server.Radio; +/// +/// The message to display when the speaker can understand "language" +/// The message to display when the speaker cannot understand "language" +/// [ByRefEvent] -public readonly record struct RadioReceiveEvent(string Message, EntityUid MessageSource, RadioChannelPrototype Channel, MsgChatMessage ChatMsg); +public readonly record struct RadioReceiveEvent( + EntityUid MessageSource, + RadioChannelPrototype Channel, + ChatMessage UnderstoodChatMsg, + ChatMessage NotUnderstoodChatMsg, + LanguagePrototype Language +); /// /// Use this event to cancel sending message per receiver diff --git a/Content.Server/Speech/EntitySystems/ListeningSystem.cs b/Content.Server/Speech/EntitySystems/ListeningSystem.cs index 3fb98f502c8..3730256d8fe 100644 --- a/Content.Server/Speech/EntitySystems/ListeningSystem.cs +++ b/Content.Server/Speech/EntitySystems/ListeningSystem.cs @@ -8,6 +8,7 @@ namespace Content.Server.Speech.EntitySystems; /// public sealed class ListeningSystem : EntitySystem { + [Dependency] private readonly ChatSystem _chat = default!; [Dependency] private readonly SharedTransformSystem _xforms = default!; public override void Initialize() @@ -18,10 +19,10 @@ public override void Initialize() private void OnSpeak(EntitySpokeEvent ev) { - PingListeners(ev.Source, ev.Message, ev.ObfuscatedMessage); + PingListeners(ev.Source, ev.Message, ev.IsWhisper); } - public void PingListeners(EntityUid source, string message, string? obfuscatedMessage) + public void PingListeners(EntityUid source, string message, bool isWhisper) { // TODO whispering / audio volume? Microphone sensitivity? // for now, whispering just arbitrarily reduces the listener's max range. @@ -32,7 +33,7 @@ public void PingListeners(EntityUid source, string message, string? obfuscatedMe var attemptEv = new ListenAttemptEvent(source); var ev = new ListenEvent(message, source); - var obfuscatedEv = obfuscatedMessage == null ? null : new ListenEvent(obfuscatedMessage, source); + var obfuscatedEv = !isWhisper ? null : new ListenEvent(_chat.ObfuscateMessageReadability(message), source); var query = EntityQueryEnumerator(); while (query.MoveNext(out var listenerUid, out var listener, out var xform)) diff --git a/Content.Server/Speech/LanguageSystem.cs b/Content.Server/Speech/LanguageSystem.cs new file mode 100644 index 00000000000..92888e5cd7a --- /dev/null +++ b/Content.Server/Speech/LanguageSystem.cs @@ -0,0 +1,177 @@ +using System.Linq; +using System.Text; +using Content.Server.Chat.Systems; +using Content.Shared.Language; +using Robust.Shared.Prototypes; +using Robust.Shared.Random; + +namespace Content.Server.Speech; + +public sealed class LanguageSystem : EntitySystem +{ + private static LanguagePrototype? _galacticCommon; + private static LanguagePrototype? _universal; + public static LanguagePrototype GalacticCommon { get => _galacticCommon!; } + public static LanguagePrototype Universal { get => _universal!; } + + [Dependency] private readonly IPrototypeManager _prototype = default!; + [Dependency] private readonly IRobustRandom _random = default!; + private ISawmill _sawmill = default!; + + public override void Initialize() + { + _prototype.TryIndex("GalacticCommon", out LanguagePrototype? gc); + _prototype.TryIndex("Universal", out LanguagePrototype? universal); + _galacticCommon = gc; + _universal = universal; + _sawmill = Logger.GetSawmill("language"); + + SubscribeLocalEvent(OnInitLanguageSpeaker); + } + + private void OnInitLanguageSpeaker(EntityUid uid, LanguageSpeakerComponent component, ComponentInit args) + { + if (component.Languages.Count == 0) + { + throw new ArgumentException("Language speaker must know at least one language."); + } + + if (string.IsNullOrEmpty(component.CurrentLanguage)) + { + component.CurrentLanguage = component.Languages.First(); + }; + } + + /// + /// Obfuscate speech of the given entity, or using the given language. + /// + /// The speaker whose message needs to be obfuscated. Must not be null if "language" is not set. + /// The language for obfuscation. Must not be null if "source" is null. + public string ObfuscateSpeech(EntityUid? source, string message, LanguagePrototype? language = null) + { + if (language == null) + { + if (source == null) + { + throw new NullReferenceException("Either source or language must be set."); + } + language = GetLanguage(source.Value); + } + + var builder = new StringBuilder(); + if (language.ObfuscateSyllables) + { + // Go through every syllable in every sentence and replace it with "replacement", preserving spaces + // Effectively, the number of syllables in a word is equal to the number of vowels in it. + for (var i = 0; i < message.Length; i++) + { + var ch = message[i]; + if (char.IsWhiteSpace(ch) || IsSentenceEnd(ch)) + { + builder.Append(ch); // This will preserve major punctuation and spaces + } + else if (IsVowel(ch)) + { + builder.Append(_random.Pick(language.Replacement)); + _sawmill.Debug("Found a vowel: " + ch); + } + } + } + else + { + // Replace every sentence with "replacement" + for (int i = 0; i < message.Length; i++) + { + var ch = message[i]; + if (IsSentenceEnd(ch)) + { + builder.Append(_random.Pick(language.Replacement)); + builder.Append(' '); + } + + // Skip any consequent sentence ends to account for !.., ?.., ?!, and similar + while (i < message.Length - 1 && (IsSentenceEnd(message[i + 1]) || char.IsWhiteSpace(message[i + 1]))) + i++; + } + + // Finally, add one more string unless the last character is a sentence end + if (IsSentenceEnd(message[^1])) + builder.Append(_random.Pick(language.Replacement)); + } + + _sawmill.Info($"Got {message}, obfuscated to {builder.ToString()}. Language: {language.ID}, replacements: {string.Join(", ", language.Replacement)}"); + + return builder.ToString(); + } + + public bool CanUnderstand(EntityUid listener, + LanguagePrototype language, + LanguageSpeakerComponent? listenerLanguageComp = null) + { + if (HasComp(listener)) + { + return true; + } + + if (listenerLanguageComp == null && !TryComp(listener, out listenerLanguageComp)) + { + return false; + } + + return language.ID == listenerLanguageComp.CurrentLanguage || + listenerLanguageComp.Languages.Contains(language.ID, StringComparer.Ordinal); + } + + public bool CanUnderstand(EntityUid speaker, EntityUid listener, + LanguageSpeakerComponent? speakerLanguage = null, + LanguageSpeakerComponent? listenerLanguage = null) + { + if (HasComp(listener) || HasComp(speaker)) + { + return true; + } + + if (speakerLanguage == null && !TryComp(speaker, out speakerLanguage)) + { + return false; + } + + if (listenerLanguage == null && !TryComp(listener, out listenerLanguage)) + { + return false; + } + + return listenerLanguage.Languages.Contains(speakerLanguage.CurrentLanguage, StringComparer.Ordinal); + } + + // + // Returns the current language of the given entity. Assumes GalacticCommon if not specified. + // + public LanguagePrototype GetLanguage(EntityUid speaker, LanguageSpeakerComponent? languageComp = null) + { + if (languageComp != null || TryComp(speaker, out languageComp)) + { + return _prototype.Index(languageComp.CurrentLanguage); + } + // Fall back if the entity is not a language speaker. + // This may include breads, doors, walls - anything an admin can decide to possess + // TODO: may want to use Universal instead! + return GalacticCommon; + } + + private bool HasIntersectingLanguages(LanguageSpeakerComponent speaker, LanguageSpeakerComponent listener) + { + return listener != null && listener.Languages.Contains(speaker.CurrentLanguage, StringComparer.Ordinal); + } + + private bool IsVowel(char ch) + { + // This is not a language-agnostic approach and will totally break with non-latin languages. + return ch == 'a' || ch == 'e' || ch == 'i' || ch == 'o' || ch == 'u' || ch == 'y'; + } + + private bool IsSentenceEnd(char ch) + { + return ch == '.' || ch == '!' || ch == '?'; + } +} diff --git a/Content.Shared/Language/LanguagePrototype.cs b/Content.Shared/Language/LanguagePrototype.cs new file mode 100644 index 00000000000..ef3fe298e57 --- /dev/null +++ b/Content.Shared/Language/LanguagePrototype.cs @@ -0,0 +1,27 @@ +using Robust.Shared.Prototypes; + +namespace Content.Shared.Language; + +[Prototype("language")] +public sealed class LanguagePrototype : IPrototype +{ + [ViewVariables] + [IdDataField] + public string ID { get; private set; } = default!; + + // + // If true, obfuscated phrases of creatures speaking this language will have their syllables replaced with "replacement" syllables. + // Otherwise entire sentences will be replaced. + // + [ViewVariables] + [DataField("name")] + public bool ObfuscateSyllables { get; private set; } = false; + + // + // Lists all syllables that are used to obfuscate a message a listener cannot understand if obfuscateSyllables is true, + // Otherwise uses all possible phrases the creature can make when trying to say anything. + // + [ViewVariables] + [DataField("replacement")] + public List Replacement = new(); +} diff --git a/Content.Shared/Language/LanguageSpeakerComponent.cs b/Content.Shared/Language/LanguageSpeakerComponent.cs new file mode 100644 index 00000000000..63963749a11 --- /dev/null +++ b/Content.Shared/Language/LanguageSpeakerComponent.cs @@ -0,0 +1,23 @@ +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List; + +namespace Content.Shared.Language; + +[RegisterComponent, AutoGenerateComponentState] +public sealed partial class LanguageSpeakerComponent : Component +{ + /// + /// The current language the entity may use to speak. + /// Other listeners will hear the entity speak in this language. + /// + [ViewVariables(VVAccess.ReadWrite)] + [AutoNetworkedField] + public string CurrentLanguage = default!; + + /// + /// List of languages this entity can speak and understand. + /// + [ViewVariables] + [DataField("languages", customTypeSerializer: typeof(PrototypeIdListSerializer), required: true)] + public List Languages = new(); +} diff --git a/Content.Shared/Language/UniversalLanguageSpeakerComponent.cs b/Content.Shared/Language/UniversalLanguageSpeakerComponent.cs new file mode 100644 index 00000000000..a44f2aed8d1 --- /dev/null +++ b/Content.Shared/Language/UniversalLanguageSpeakerComponent.cs @@ -0,0 +1,11 @@ +namespace Content.Shared.Language; + +// +// Signifies that this entity can speak and understand any language. +// Applies to such entities as ghosts. +// +[RegisterComponent] +public sealed partial class UniversalLanguageSpeakerComponent : Component +{ + +} diff --git a/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml b/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml index 0cde00041c0..b4160cef4a4 100644 --- a/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml +++ b/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml @@ -114,6 +114,9 @@ damage: types: Heat: 1.5 # Rip Fur + - type: LanguageSpeaker + languages: + - Canilunzt - type: entity save: false diff --git a/Resources/Prototypes/Entities/Mobs/Player/observer.yml b/Resources/Prototypes/Entities/Mobs/Player/observer.yml index c7cac426a41..ac700229c76 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/observer.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/observer.yml @@ -59,6 +59,7 @@ - type: Tag tags: - BypassInteractionRangeChecks + - type: UniversalLanguageSpeaker # Ghosts should understand any language. - type: entity id: ActionGhostBoo diff --git a/Resources/Prototypes/Entities/Mobs/Species/base.yml b/Resources/Prototypes/Entities/Mobs/Species/base.yml index f72e573098f..54bfcafc59a 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/base.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/base.yml @@ -214,7 +214,7 @@ - type: MobPrice price: 1500 # Kidnapping a living person and selling them for cred is a good move. deathPenalty: 0.01 # However they really ought to be living and intact, otherwise they're worth 100x less. - - type: CanEscapeInventory # Carrying system from nyanotrasen. + - type: CanEscapeInventory # Carrying system from nyanotrasen. - type: Tag tags: - CanPilot @@ -297,6 +297,9 @@ damageRecovery: types: Asphyxiation: -1.0 + - type: LanguageSpeaker + languages: + - GalacticCommon - type: entity save: false diff --git a/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/dogs.yml b/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/dogs.yml index 4305cfc93ad..1f104832769 100644 --- a/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/dogs.yml +++ b/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/dogs.yml @@ -46,8 +46,9 @@ spawned: - id: FoodMeat amount: 3 - - type: ReplacementAccent - accent: dog + - type: LanguageSpeaker + languages: + - Dog - type: InteractionPopup successChance: 0.7 interactSuccessString: petting-success-dog diff --git a/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/mothroach.yml b/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/mothroach.yml index 903bb4f6118..6b231e4d66e 100644 --- a/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/mothroach.yml +++ b/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/mothroach.yml @@ -88,8 +88,9 @@ Quantity: 5 - type: Bloodstream bloodMaxVolume: 70 - - type: ReplacementAccent - accent: mothroach + - type: LanguageSpeaker + languages: + - Mothroach - type: Vocal sounds: Male: Mothroach diff --git a/Resources/Prototypes/languages.yml b/Resources/Prototypes/languages.yml new file mode 100644 index 00000000000..0d67dc7a0b2 --- /dev/null +++ b/Resources/Prototypes/languages.yml @@ -0,0 +1,109 @@ +# The universal language, assumed if the entity has a UniversalLanguageSpeakerComponent. +# Do not use otherwise. +- type: language + id: Universal + +# Spoken by most sentient creatures. +- type: language + id: GalacticCommon + obfuscateSyllables: false + replacement: + - Blah blah blah + - Dingle-doingle dingle dangle + - Jibber-jabber jubber + - Zippity zappity zoop + - Wibble wobble wiggle + - Yada yada yada + +# Spoken by the Vulpkanin race. +- type: language + id: Canilunzt + obfuscateSyllables: true + replacement: + - rur + - ya + - cen + - rawr + - bar + - kuk + - tek + - qat + - uk + - wu + - vuh + - tah + - tch + - schz + - auch + - ist + - ein + - entch + - zwichs + - tut + - mir + - wo + - bis + - es + - vor + - nic + - gro + - lll + - enem + - zandt + - tzch + - noch + - hel + - ischt + - far + - wa + - baram + - iereng + - tech + - lach + - sam + - mak + - lich + - gen + - or + - ag + - eck + - gec + - stag + - onn + - bin + - ket + - jarl + - vulf + - einech + - cresthz + - azunein + - ghzth + +# Languages spoken by various critters. +- type: language + id: Cat + obfuscateSyllables: true + replacement: + - murr + - meow + - purr + - mrow + +- type: language + id: Dog + obfuscateSyllables: true + replacement: + - woof + - bark + - ruff + - bork + - raff + - garr + +# TODO: rest of the critters +- type: language + id: Mothroach + replacement: + - Chitter + - Buzz + - Chirp From 75039783d029d89e99e81d703d1d779e97012d11 Mon Sep 17 00:00:00 2001 From: fox Date: Tue, 5 Dec 2023 23:43:57 +0300 Subject: [PATCH 07/91] Rework everything, adding support for spoken/understood langs, as well as overrides for that --- Content.Server/Speech/LanguageSystem.cs | 116 +++++++++++------- .../Language/LanguageSpeakerComponent.cs | 13 +- .../Entities/Mobs/Species/vulpkanin.yml | 4 +- .../Prototypes/Entities/Mobs/Species/base.yml | 6 +- .../_Nyano/Entities/Mobs/NPCs/dogs.yml | 4 +- .../_Nyano/Entities/Mobs/NPCs/mothroach.yml | 4 +- Resources/Prototypes/languages.yml | 2 +- 7 files changed, 96 insertions(+), 53 deletions(-) diff --git a/Content.Server/Speech/LanguageSystem.cs b/Content.Server/Speech/LanguageSystem.cs index 92888e5cd7a..8fb2e6a0e61 100644 --- a/Content.Server/Speech/LanguageSystem.cs +++ b/Content.Server/Speech/LanguageSystem.cs @@ -31,14 +31,14 @@ public override void Initialize() private void OnInitLanguageSpeaker(EntityUid uid, LanguageSpeakerComponent component, ComponentInit args) { - if (component.Languages.Count == 0) + if (component.SpokenLanguages.Count == 0) { - throw new ArgumentException("Language speaker must know at least one language."); + throw new ArgumentException("Language speaker must speak at least one language."); } if (string.IsNullOrEmpty(component.CurrentLanguage)) { - component.CurrentLanguage = component.Languages.First(); + component.CurrentLanguage = component.SpokenLanguages.First(); }; } @@ -65,7 +65,7 @@ public string ObfuscateSpeech(EntityUid? source, string message, LanguagePrototy // Effectively, the number of syllables in a word is equal to the number of vowels in it. for (var i = 0; i < message.Length; i++) { - var ch = message[i]; + var ch = char.ToLower(message[i]); if (char.IsWhiteSpace(ch) || IsSentenceEnd(ch)) { builder.Append(ch); // This will preserve major punctuation and spaces @@ -82,7 +82,7 @@ public string ObfuscateSpeech(EntityUid? source, string message, LanguagePrototy // Replace every sentence with "replacement" for (int i = 0; i < message.Length; i++) { - var ch = message[i]; + var ch = char.ToLower(message[i]); if (IsSentenceEnd(ch)) { builder.Append(_random.Pick(language.Replacement)); @@ -99,7 +99,7 @@ public string ObfuscateSpeech(EntityUid? source, string message, LanguagePrototy builder.Append(_random.Pick(language.Replacement)); } - _sawmill.Info($"Got {message}, obfuscated to {builder.ToString()}. Language: {language.ID}, replacements: {string.Join(", ", language.Replacement)}"); + _sawmill.Info($"Got {message}, obfuscated to {builder}. Language: {language.ID}"); return builder.ToString(); } @@ -108,70 +108,98 @@ public bool CanUnderstand(EntityUid listener, LanguagePrototype language, LanguageSpeakerComponent? listenerLanguageComp = null) { - if (HasComp(listener)) - { + if (language.ID == Universal.ID || HasComp(listener)) return true; - } - if (listenerLanguageComp == null && !TryComp(listener, out listenerLanguageComp)) - { - return false; - } + var listenerLanguages = GetLanguages(listener, listenerLanguageComp).UnderstoodLanguages; - return language.ID == listenerLanguageComp.CurrentLanguage || - listenerLanguageComp.Languages.Contains(language.ID, StringComparer.Ordinal); + return listenerLanguages.Contains(language.ID, StringComparer.Ordinal); } public bool CanUnderstand(EntityUid speaker, EntityUid listener, - LanguageSpeakerComponent? speakerLanguage = null, - LanguageSpeakerComponent? listenerLanguage = null) + LanguageSpeakerComponent? speakerComp = null, + LanguageSpeakerComponent? listenerComp = null) { if (HasComp(listener) || HasComp(speaker)) - { return true; - } - - if (speakerLanguage == null && !TryComp(speaker, out speakerLanguage)) - { - return false; - } - if (listenerLanguage == null && !TryComp(listener, out listenerLanguage)) - { - return false; - } + var speakerLanguage = GetLanguages(speaker, speakerComp).CurrentLanguage; + if (speakerLanguage == Universal.ID) + return true; + var listenerLanguages = GetLanguages(listener, listenerComp).UnderstoodLanguages; - return listenerLanguage.Languages.Contains(speakerLanguage.CurrentLanguage, StringComparer.Ordinal); + return listenerLanguages.Contains(speakerLanguage, StringComparer.Ordinal); } // - // Returns the current language of the given entity. Assumes GalacticCommon if not specified. + // Returns the current language of the given entity. Assumes Universal if not specified. // public LanguagePrototype GetLanguage(EntityUid speaker, LanguageSpeakerComponent? languageComp = null) { - if (languageComp != null || TryComp(speaker, out languageComp)) - { - return _prototype.Index(languageComp.CurrentLanguage); - } - // Fall back if the entity is not a language speaker. - // This may include breads, doors, walls - anything an admin can decide to possess - // TODO: may want to use Universal instead! - return GalacticCommon; - } + var id = GetLanguages(speaker, languageComp).CurrentLanguage; + _prototype.TryIndex(id, out LanguagePrototype? proto); - private bool HasIntersectingLanguages(LanguageSpeakerComponent speaker, LanguageSpeakerComponent listener) - { - return listener != null && listener.Languages.Contains(speaker.CurrentLanguage, StringComparer.Ordinal); + return proto ?? Universal; } private bool IsVowel(char ch) { // This is not a language-agnostic approach and will totally break with non-latin languages. - return ch == 'a' || ch == 'e' || ch == 'i' || ch == 'o' || ch == 'u' || ch == 'y'; + return ch is 'a' or 'e' or 'i' or 'o' or 'u' or 'y'; } private bool IsSentenceEnd(char ch) { - return ch == '.' || ch == '!' || ch == '?'; + return ch is '.' or '!' or '?'; + } + + // This event is reused because re-allocating it each time is way too costly. + private DetermineEntityLanguagesEvent _determineLanguagesEvent = new("", new(), new()); + + /// + /// Dynamically resolves the current language of the entity and the list of all languages it speaks. + /// The returned event is reused and thus must not be held as a reference anywhere but inside the caller function. + /// + private DetermineEntityLanguagesEvent GetLanguages(EntityUid speaker, LanguageSpeakerComponent? comp = null) + { + var ev = _determineLanguagesEvent; + ev.CurrentLanguage = Universal.ID; + ev.SpokenLanguages.Clear(); + ev.UnderstoodLanguages.Clear(); + + if (comp != null || TryComp(speaker, out comp)) + { + ev.CurrentLanguage = comp.CurrentLanguage; + ev.SpokenLanguages.AddRange(comp.SpokenLanguages); + ev.UnderstoodLanguages.AddRange(comp.UnderstoodLanguages); + } + + RaiseLocalEvent(speaker, ev); + + return ev; + } + + /// + /// Raised in order to determine the language an entity speaks at the current moment, + /// as well as the list of all languages the entity may speak and understand. + /// + public sealed class DetermineEntityLanguagesEvent : EntityEventArgs + { + public string CurrentLanguage; + /// + /// The list of all languages the entity may speak. Must NOT be held as a reference! + /// + public List SpokenLanguages; + /// + /// The list of all languages the entity may understand. Must NOT be held as a reference! + /// + public List UnderstoodLanguages; + + public DetermineEntityLanguagesEvent(string currentLanguage, List spokenLanguages, List understoodLanguages) + { + CurrentLanguage = currentLanguage; + SpokenLanguages = spokenLanguages; + UnderstoodLanguages = understoodLanguages; + } } } diff --git a/Content.Shared/Language/LanguageSpeakerComponent.cs b/Content.Shared/Language/LanguageSpeakerComponent.cs index 63963749a11..ec32e032858 100644 --- a/Content.Shared/Language/LanguageSpeakerComponent.cs +++ b/Content.Shared/Language/LanguageSpeakerComponent.cs @@ -15,9 +15,16 @@ public sealed partial class LanguageSpeakerComponent : Component public string CurrentLanguage = default!; /// - /// List of languages this entity can speak and understand. + /// List of languages this entity can speak. /// [ViewVariables] - [DataField("languages", customTypeSerializer: typeof(PrototypeIdListSerializer), required: true)] - public List Languages = new(); + [DataField("speaks", customTypeSerializer: typeof(PrototypeIdListSerializer), required: true)] + public List SpokenLanguages = new(); + + /// + /// List of languages this entity can understand. + /// + [ViewVariables] + [DataField("understands", customTypeSerializer: typeof(PrototypeIdListSerializer), required: true)] + public List UnderstoodLanguages = new(); } diff --git a/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml b/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml index b4160cef4a4..87d527e8a21 100644 --- a/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml +++ b/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml @@ -115,7 +115,9 @@ types: Heat: 1.5 # Rip Fur - type: LanguageSpeaker - languages: + speaks: + - Canilunzt + understands: - Canilunzt - type: entity diff --git a/Resources/Prototypes/Entities/Mobs/Species/base.yml b/Resources/Prototypes/Entities/Mobs/Species/base.yml index 54bfcafc59a..2d2ca83131c 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/base.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/base.yml @@ -298,8 +298,10 @@ types: Asphyxiation: -1.0 - type: LanguageSpeaker - languages: - - GalacticCommon + speaks: + - GalacticCommon + understands: + - GalacticCommon - type: entity save: false diff --git a/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/dogs.yml b/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/dogs.yml index 1f104832769..1272e5970e1 100644 --- a/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/dogs.yml +++ b/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/dogs.yml @@ -47,7 +47,9 @@ - id: FoodMeat amount: 3 - type: LanguageSpeaker - languages: + speaks: + - Dog + understands: - Dog - type: InteractionPopup successChance: 0.7 diff --git a/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/mothroach.yml b/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/mothroach.yml index 6b231e4d66e..94b2f2d6fe1 100644 --- a/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/mothroach.yml +++ b/Resources/Prototypes/_Nyano/Entities/Mobs/NPCs/mothroach.yml @@ -89,7 +89,9 @@ - type: Bloodstream bloodMaxVolume: 70 - type: LanguageSpeaker - languages: + speaks: + - Mothroach + understands: - Mothroach - type: Vocal sounds: diff --git a/Resources/Prototypes/languages.yml b/Resources/Prototypes/languages.yml index 0d67dc7a0b2..fa9778601fa 100644 --- a/Resources/Prototypes/languages.yml +++ b/Resources/Prototypes/languages.yml @@ -1,5 +1,5 @@ # The universal language, assumed if the entity has a UniversalLanguageSpeakerComponent. -# Do not use otherwise. +# Do not use otherwise. Try to use the respective component instead of this language. - type: language id: Universal From 0a61515771cd2e2087eff423e5c521238517569d Mon Sep 17 00:00:00 2001 From: fox Date: Tue, 5 Dec 2023 23:53:39 +0300 Subject: [PATCH 08/91] Minor fixes --- Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs | 2 +- Resources/Prototypes/languages.yml | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs b/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs index c34a9935391..6059cc358e2 100644 --- a/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs +++ b/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs @@ -205,7 +205,7 @@ private void OnReceiveRadio(EntityUid uid, RadioSpeakerComponent component, ref // log to chat so people can identity the speaker/source, but avoid clogging ghost chat if there are many radios var message = args.UnderstoodChatMsg.Message; // The chat system will handle the rest and re-obfuscate if needed. - _chat.TrySendInGameICMessage(uid, message, InGameICChatType.Whisper, ChatTransmitRange.GhostRangeLimit, nameOverride: name, checkRadioPrefix: false); + _chat.TrySendInGameICMessage(uid, message, InGameICChatType.Whisper, ChatTransmitRange.GhostRangeLimit, nameOverride: name, checkRadioPrefix: false, languageOverride: args.Language); } private void OnBeforeIntercomUiOpen(EntityUid uid, IntercomComponent component, BeforeActivatableUIOpenEvent args) diff --git a/Resources/Prototypes/languages.yml b/Resources/Prototypes/languages.yml index fa9778601fa..6a62dd9e2ee 100644 --- a/Resources/Prototypes/languages.yml +++ b/Resources/Prototypes/languages.yml @@ -2,6 +2,8 @@ # Do not use otherwise. Try to use the respective component instead of this language. - type: language id: Universal + replacements: + - "*incomprehensible*" # Spoken by most sentient creatures. - type: language From 60632e2d51e65911eacc7ebaa4292c6c758e2bb3 Mon Sep 17 00:00:00 2001 From: fox Date: Wed, 6 Dec 2023 00:24:15 +0300 Subject: [PATCH 09/91] Fix language checks in ChatSystem not accounting for language overrides --- Content.Server/Chat/Systems/ChatSystem.cs | 14 +++++++++----- Content.Server/Speech/LanguageSystem.cs | 6 +++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs index b0c4627f554..5dc841dc78b 100644 --- a/Content.Server/Chat/Systems/ChatSystem.cs +++ b/Content.Server/Chat/Systems/ChatSystem.cs @@ -407,7 +407,7 @@ private void SendEntitySpeak( var obfuscated = _language.ObfuscateSpeech(source, message, language); var wrappedObfuscated = WrapPublicMessage(source, name, obfuscated); - SendInVoiceRange(ChatChannel.Local, name, message, wrappedMessage, obfuscated, wrappedObfuscated, source, range); + SendInVoiceRange(ChatChannel.Local, name, message, wrappedMessage, obfuscated, wrappedObfuscated, source, range, languageOverride: language); var ev = new EntitySpokeEvent(source, message, null, false, language); RaiseLocalEvent(source, ev, true); @@ -481,7 +481,7 @@ private void SendEntityWhisper( 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. - var canUnderstand = _language.CanUnderstand(source, listener); + var canUnderstand = _language.CanUnderstand(listener, language); var finalMessage = canUnderstand ? message : languageObfuscatedMessage; if (data.Range <= WhisperClearRange) @@ -600,7 +600,8 @@ private void SendLOOC(EntityUid source, ICommonSession player, string message, b obfuscatedWrappedMessage: string.Empty, // will be skipped anyway source, hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, - player.UserId); + player.UserId, + languageOverride: LanguageSystem.Universal); _adminLogger.Add(LogType.Chat, LogImpact.Low, $"LOOC from {player:Player}: {message}"); } @@ -681,8 +682,10 @@ private MessageRangeCheckResult MessageRangeCheck(ICommonSession session, ICChat /// /// Sends a chat message to the given players in range of the source entity. /// - private void SendInVoiceRange(ChatChannel channel, string name, string message, string wrappedMessage, string obfuscated, string obfuscatedWrappedMessage, EntityUid source, ChatTransmitRange range, NetUserId? author = null) + private void SendInVoiceRange(ChatChannel channel, string name, string message, string wrappedMessage, string obfuscated, string obfuscatedWrappedMessage, EntityUid source, ChatTransmitRange range, NetUserId? author = null, LanguagePrototype? languageOverride = null) { + var language = languageOverride ?? _language.GetLanguage(source) + foreach (var (session, data) in GetRecipients(source, VoiceRange)) { var entRange = MessageRangeCheck(session, data, range); @@ -694,7 +697,8 @@ private void SendInVoiceRange(ChatChannel channel, string name, string message, continue; EntityUid listener = session.AttachedEntity.Value; - if (channel == ChatChannel.LOOC || channel == ChatChannel.Emotes || _language.CanUnderstand(source, listener)) + + if (channel == ChatChannel.LOOC || channel == ChatChannel.Emotes || _language.CanUnderstand(listener, language)) { _chatManager.ChatMessageToOne(channel, message, wrappedMessage, source, entHideChat, session.ConnectedClient, author: author); } diff --git a/Content.Server/Speech/LanguageSystem.cs b/Content.Server/Speech/LanguageSystem.cs index 8fb2e6a0e61..937f1c3d80e 100644 --- a/Content.Server/Speech/LanguageSystem.cs +++ b/Content.Server/Speech/LanguageSystem.cs @@ -58,6 +58,10 @@ public string ObfuscateSpeech(EntityUid? source, string message, LanguagePrototy language = GetLanguage(source.Value); } + // TODO: REWORK THIS! + // Should instead use random number of syllables/phrases per word/sentence depending on its length + // Also preferably should use a simple hash code of the word as the random seed to make obfuscation stable + // This will also allow people to learn certain phrases, e.g. how "yes" is spelled in canilunzt. var builder = new StringBuilder(); if (language.ObfuscateSyllables) { @@ -73,7 +77,6 @@ public string ObfuscateSpeech(EntityUid? source, string message, LanguagePrototy else if (IsVowel(ch)) { builder.Append(_random.Pick(language.Replacement)); - _sawmill.Debug("Found a vowel: " + ch); } } } @@ -116,6 +119,7 @@ public bool CanUnderstand(EntityUid listener, return listenerLanguages.Contains(language.ID, StringComparer.Ordinal); } + [Obsolete("Use CanUnderstand(EntityUid, LanguagePrototype) instead if possible.", error: false)] public bool CanUnderstand(EntityUid speaker, EntityUid listener, LanguageSpeakerComponent? speakerComp = null, LanguageSpeakerComponent? listenerComp = null) From 57ffd0d48a7d6ffb6953b951ccc9d8a99ccb4197 Mon Sep 17 00:00:00 2001 From: fox Date: Wed, 6 Dec 2023 00:24:29 +0300 Subject: [PATCH 10/91] Outdated comments --- Content.Server/Radio/EntitySystems/RadioSystem.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Content.Server/Radio/EntitySystems/RadioSystem.cs b/Content.Server/Radio/EntitySystems/RadioSystem.cs index 7def85c7d6a..e35d65912dd 100644 --- a/Content.Server/Radio/EntitySystems/RadioSystem.cs +++ b/Content.Server/Radio/EntitySystems/RadioSystem.cs @@ -69,12 +69,12 @@ private void OnIntrinsicReceive(EntityUid uid, IntrinsicRadioReceiverComponent c /// /// Entity that spoke the message /// Entity that picked up the message and will send it, e.g. headset - /// The language to send the message in. Defaults to galactic common. + /// The language to send the message in. public void SendRadioMessage(EntityUid messageSource, string message, RadioChannelPrototype channel, EntityUid radioSource, LanguagePrototype? language = null) { if (language == null) { - language = _language.GetLanguage(messageSource); // Will return galactic common for non-player sources + language = _language.GetLanguage(messageSource); } // TODO if radios ever garble / modify messages, feedback-prevention needs to be handled better than this. From 547a14948b31e29662c61b317a4cd7c29ed58495 Mon Sep 17 00:00:00 2001 From: fox Date: Wed, 6 Dec 2023 00:25:19 +0300 Subject: [PATCH 11/91] Oops. Missed a semicolon before pushing... --- Content.Server/Chat/Systems/ChatSystem.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs index 5dc841dc78b..96c274c6431 100644 --- a/Content.Server/Chat/Systems/ChatSystem.cs +++ b/Content.Server/Chat/Systems/ChatSystem.cs @@ -684,7 +684,7 @@ private MessageRangeCheckResult MessageRangeCheck(ICommonSession session, ICChat /// private void SendInVoiceRange(ChatChannel channel, string name, string message, string wrappedMessage, string obfuscated, string obfuscatedWrappedMessage, EntityUid source, ChatTransmitRange range, NetUserId? author = null, LanguagePrototype? languageOverride = null) { - var language = languageOverride ?? _language.GetLanguage(source) + var language = languageOverride ?? _language.GetLanguage(source); foreach (var (session, data) in GetRecipients(source, VoiceRange)) { From 648ae248edd425d8e06f83885b2c55c9addf29f4 Mon Sep 17 00:00:00 2001 From: fox Date: Wed, 6 Dec 2023 00:31:17 +0300 Subject: [PATCH 12/91] Remove obsolete API --- Content.Server/Chat/Systems/ChatSystem.cs | 28 ----------------------- 1 file changed, 28 deletions(-) diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs index 96c274c6431..5bfd4937503 100644 --- a/Content.Server/Chat/Systems/ChatSystem.cs +++ b/Content.Server/Chat/Systems/ChatSystem.cs @@ -771,17 +771,6 @@ public string TransformSpeech(EntityUid sender, string message) return ev.Message; } - /// - /// Transforms the given message perceived by the given listener and returns the new message and the new wrapped message. - /// - public string TransformPerceivedSpeech(EntityUid speaker, EntityUid listener, string message) - { - var ev = new TransformPerceivedMessageEvent(speaker, listener, message); - RaiseLocalEvent(ev); - - return ev.Message; - } - private IEnumerable GetDeadChatClients() { return Filter.Empty() @@ -931,23 +920,6 @@ public TransformSpeechEvent(EntityUid sender, string message) } } -/// -/// Raised broadcast in order to transform the way an entity perceives a message. -/// -public sealed class TransformPerceivedMessageEvent : EntityEventArgs -{ - public EntityUid Sender; - public EntityUid Listener; - public string Message; - - public TransformPerceivedMessageEvent(EntityUid sender, EntityUid listener, string message) - { - Sender = sender; - Listener = listener; - Message = message; - } -} - /// /// Raised on an entity when it speaks, either through 'say' or 'whisper'. /// From eefa226225052a67683306ed78cc251949a61b78 Mon Sep 17 00:00:00 2001 From: fox Date: Thu, 7 Dec 2023 00:51:59 +0300 Subject: [PATCH 13/91] Split langsys into common and server systems --- Content.Server/Speech/LanguageSystem.cs | 42 +++++++++---------- Content.Shared/Speech/SharedLanguageSystem.cs | 27 ++++++++++++ 2 files changed, 48 insertions(+), 21 deletions(-) create mode 100644 Content.Shared/Speech/SharedLanguageSystem.cs diff --git a/Content.Server/Speech/LanguageSystem.cs b/Content.Server/Speech/LanguageSystem.cs index 937f1c3d80e..80f5519bb97 100644 --- a/Content.Server/Speech/LanguageSystem.cs +++ b/Content.Server/Speech/LanguageSystem.cs @@ -2,31 +2,18 @@ using System.Text; using Content.Server.Chat.Systems; using Content.Shared.Language; +using Content.Shared.Speech; using Robust.Shared.Prototypes; using Robust.Shared.Random; namespace Content.Server.Speech; -public sealed class LanguageSystem : EntitySystem +public sealed class LanguageSystem : SharedLanguageSystem { - private static LanguagePrototype? _galacticCommon; - private static LanguagePrototype? _universal; - public static LanguagePrototype GalacticCommon { get => _galacticCommon!; } - public static LanguagePrototype Universal { get => _universal!; } - - [Dependency] private readonly IPrototypeManager _prototype = default!; - [Dependency] private readonly IRobustRandom _random = default!; - private ISawmill _sawmill = default!; public override void Initialize() { - _prototype.TryIndex("GalacticCommon", out LanguagePrototype? gc); - _prototype.TryIndex("Universal", out LanguagePrototype? universal); - _galacticCommon = gc; - _universal = universal; - _sawmill = Logger.GetSawmill("language"); - - SubscribeLocalEvent(OnInitLanguageSpeaker); + SubscribeLocalEvent(OnInitLanguageSpeaker);\ } private void OnInitLanguageSpeaker(EntityUid uid, LanguageSpeakerComponent component, ComponentInit args) @@ -39,7 +26,7 @@ private void OnInitLanguageSpeaker(EntityUid uid, LanguageSpeakerComponent compo if (string.IsNullOrEmpty(component.CurrentLanguage)) { component.CurrentLanguage = component.SpokenLanguages.First(); - }; + } } /// @@ -135,6 +122,15 @@ public bool CanUnderstand(EntityUid speaker, EntityUid listener, return listenerLanguages.Contains(speakerLanguage, StringComparer.Ordinal); } + public bool CanSpeak(EntityUid speaker, string language, LanguageSpeakerComponent? speakerComp = null) + { + if (HasComp(speaker)) + return true; + + var langs = GetLanguages(speaker, speakerComp).UnderstoodLanguages; + return langs.Contains(language, StringComparer.Ordinal); + } + // // Returns the current language of the given entity. Assumes Universal if not specified. // @@ -158,7 +154,7 @@ private bool IsSentenceEnd(char ch) } // This event is reused because re-allocating it each time is way too costly. - private DetermineEntityLanguagesEvent _determineLanguagesEvent = new("", new(), new()); + private DetermineEntityLanguagesEvent _determineLanguagesEvent = new(null, new(), new()); /// /// Dynamically resolves the current language of the entity and the list of all languages it speaks. @@ -167,7 +163,7 @@ private bool IsSentenceEnd(char ch) private DetermineEntityLanguagesEvent GetLanguages(EntityUid speaker, LanguageSpeakerComponent? comp = null) { var ev = _determineLanguagesEvent; - ev.CurrentLanguage = Universal.ID; + ev.CurrentLanguage = null; ev.SpokenLanguages.Clear(); ev.UnderstoodLanguages.Clear(); @@ -180,16 +176,20 @@ private DetermineEntityLanguagesEvent GetLanguages(EntityUid speaker, LanguageSp RaiseLocalEvent(speaker, ev); + if (ev.CurrentLanguage == null) + ev.CurrentLanguage = comp?.CurrentLanguage ?? Universal.ID; // Fall back to account for admemes like admins possessing a bread return ev; } + private fun OnGetLanguageMessage + /// /// Raised in order to determine the language an entity speaks at the current moment, /// as well as the list of all languages the entity may speak and understand. /// public sealed class DetermineEntityLanguagesEvent : EntityEventArgs { - public string CurrentLanguage; + public string? CurrentLanguage; /// /// The list of all languages the entity may speak. Must NOT be held as a reference! /// @@ -199,7 +199,7 @@ public sealed class DetermineEntityLanguagesEvent : EntityEventArgs /// public List UnderstoodLanguages; - public DetermineEntityLanguagesEvent(string currentLanguage, List spokenLanguages, List understoodLanguages) + public DetermineEntityLanguagesEvent(string? currentLanguage, List spokenLanguages, List understoodLanguages) { CurrentLanguage = currentLanguage; SpokenLanguages = spokenLanguages; diff --git a/Content.Shared/Speech/SharedLanguageSystem.cs b/Content.Shared/Speech/SharedLanguageSystem.cs new file mode 100644 index 00000000000..ed102a4ee30 --- /dev/null +++ b/Content.Shared/Speech/SharedLanguageSystem.cs @@ -0,0 +1,27 @@ +using Content.Shared.Language; +using Robust.Shared.Prototypes; +using Robust.Shared.Random; +using Robust.Shared.Serialization; + +namespace Content.Shared.Speech; + +public abstract class SharedLanguageSystem : EntitySystem +{ + private static LanguagePrototype? _galacticCommon; + private static LanguagePrototype? _universal; + public static LanguagePrototype GalacticCommon { get => _galacticCommon!; } + public static LanguagePrototype Universal { get => _universal!; } + + [Dependency] protected readonly IPrototypeManager _prototype = default!; + [Dependency] protected readonly IRobustRandom _random = default!; + protected ISawmill _sawmill = default!; + + public override void Initialize() + { + _prototype.TryIndex("GalacticCommon", out LanguagePrototype? gc); + _prototype.TryIndex("Universal", out LanguagePrototype? universal); + _galacticCommon = gc; + _universal = universal; + _sawmill = Logger.GetSawmill("language"); + } +} From 9625489c75e33e18899efdbf947546e1d29053d9 Mon Sep 17 00:00:00 2001 From: fox Date: Thu, 7 Dec 2023 00:55:05 +0300 Subject: [PATCH 14/91] Move stuff over to Content.Server.Language instead of Speech --- Content.Server/Chat/Systems/ChatSystem.cs | 1 + Content.Server/{Speech => Language}/LanguageSystem.cs | 2 +- Content.Server/Radio/EntitySystems/HeadsetSystem.cs | 1 + Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs | 1 + Content.Server/Radio/EntitySystems/RadioSystem.cs | 1 + Content.Shared/{Speech => Language}/SharedLanguageSystem.cs | 2 +- 6 files changed, 6 insertions(+), 2 deletions(-) rename Content.Server/{Speech => Language}/LanguageSystem.cs (99%) rename Content.Shared/{Speech => Language}/SharedLanguageSystem.cs (96%) diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs index 5bfd4937503..c2e92d07e8c 100644 --- a/Content.Server/Chat/Systems/ChatSystem.cs +++ b/Content.Server/Chat/Systems/ChatSystem.cs @@ -5,6 +5,7 @@ using Content.Server.Administration.Managers; using Content.Server.Chat.Managers; using Content.Server.GameTicking; +using Content.Server.Language; using Content.Server.Speech; using Content.Server.Speech.Components; using Content.Server.Speech.EntitySystems; diff --git a/Content.Server/Speech/LanguageSystem.cs b/Content.Server/Language/LanguageSystem.cs similarity index 99% rename from Content.Server/Speech/LanguageSystem.cs rename to Content.Server/Language/LanguageSystem.cs index 80f5519bb97..0948f4429c2 100644 --- a/Content.Server/Speech/LanguageSystem.cs +++ b/Content.Server/Language/LanguageSystem.cs @@ -6,7 +6,7 @@ using Robust.Shared.Prototypes; using Robust.Shared.Random; -namespace Content.Server.Speech; +namespace Content.Server.Language; public sealed class LanguageSystem : SharedLanguageSystem { diff --git a/Content.Server/Radio/EntitySystems/HeadsetSystem.cs b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs index e9887aa2b60..275c6476dbc 100644 --- a/Content.Server/Radio/EntitySystems/HeadsetSystem.cs +++ b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs @@ -1,5 +1,6 @@ using Content.Server.Chat.Systems; using Content.Server.Emp; +using Content.Server.Language; using Content.Server.Radio.Components; using Content.Server.Speech; using Content.Shared.Chat; diff --git a/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs b/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs index 6059cc358e2..85819cb3e0d 100644 --- a/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs +++ b/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs @@ -1,5 +1,6 @@ using Content.Server.Chat.Systems; using Content.Server.Interaction; +using Content.Server.Language; using Content.Server.Popups; using Content.Server.Power.Components; using Content.Server.Power.EntitySystems; diff --git a/Content.Server/Radio/EntitySystems/RadioSystem.cs b/Content.Server/Radio/EntitySystems/RadioSystem.cs index e35d65912dd..3cfdf8d315d 100644 --- a/Content.Server/Radio/EntitySystems/RadioSystem.cs +++ b/Content.Server/Radio/EntitySystems/RadioSystem.cs @@ -1,5 +1,6 @@ using Content.Server.Administration.Logs; using Content.Server.Chat.Systems; +using Content.Server.Language; using Content.Server.Power.Components; using Content.Server.Radio.Components; using Content.Server.Speech; diff --git a/Content.Shared/Speech/SharedLanguageSystem.cs b/Content.Shared/Language/SharedLanguageSystem.cs similarity index 96% rename from Content.Shared/Speech/SharedLanguageSystem.cs rename to Content.Shared/Language/SharedLanguageSystem.cs index ed102a4ee30..778c7071f88 100644 --- a/Content.Shared/Speech/SharedLanguageSystem.cs +++ b/Content.Shared/Language/SharedLanguageSystem.cs @@ -3,7 +3,7 @@ using Robust.Shared.Random; using Robust.Shared.Serialization; -namespace Content.Shared.Speech; +namespace Content.Shared.Language; public abstract class SharedLanguageSystem : EntitySystem { From 4832df8341e444797284eaa7d054a4e587fecb80 Mon Sep 17 00:00:00 2001 From: fox Date: Thu, 7 Dec 2023 18:51:07 +0300 Subject: [PATCH 15/91] Added commands related to languages --- .../Language/Commands/ListLanguagesCommand.cs | 39 ++++++++++ .../Language/Commands/SayLanguageCommand.cs | 53 +++++++++++++ Content.Server/Language/LanguageSystem.cs | 75 ++++++++++--------- .../Language/SharedLanguageSystem.cs | 6 ++ 4 files changed, 136 insertions(+), 37 deletions(-) create mode 100644 Content.Server/Language/Commands/ListLanguagesCommand.cs create mode 100644 Content.Server/Language/Commands/SayLanguageCommand.cs diff --git a/Content.Server/Language/Commands/ListLanguagesCommand.cs b/Content.Server/Language/Commands/ListLanguagesCommand.cs new file mode 100644 index 00000000000..ac7b403707b --- /dev/null +++ b/Content.Server/Language/Commands/ListLanguagesCommand.cs @@ -0,0 +1,39 @@ +using System.Linq; +using Content.Shared.Administration; +using Robust.Shared.Console; +using Robust.Shared.Enums; + +namespace Content.Server.Language.Commands; + +[AnyCommand] +public sealed class ListLanguagesCommand : IConsoleCommand +{ + [Dependency] private readonly LanguageSystem _language = default!; + + public string Command => "lslangs"; + public string Description => "List languages your current entity can speak at the current moment."; + public string Help => "lslangs"; + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (shell.Player is not { } player) + { + shell.WriteError("This command cannot be run from the server."); + return; + } + + if (player.Status != SessionStatus.InGame) + return; + + if (player.AttachedEntity is not {} playerEntity) + { + shell.WriteError("You don't have an entity!"); + return; + } + + var (spokenLangs, knownLangs) = _language.GetAllLanguages(playerEntity); + + shell.WriteLine("Spoken: " + string.Join(", ", spokenLangs)); + shell.WriteLine("Understood: " + string.Join(", ", knownLangs)); + } +} diff --git a/Content.Server/Language/Commands/SayLanguageCommand.cs b/Content.Server/Language/Commands/SayLanguageCommand.cs new file mode 100644 index 00000000000..949f55938b9 --- /dev/null +++ b/Content.Server/Language/Commands/SayLanguageCommand.cs @@ -0,0 +1,53 @@ +using Content.Server.Chat.Systems; +using Content.Shared.Administration; +using Robust.Shared.Console; +using Robust.Shared.Enums; + +namespace Content.Server.Language.Commands; + +[AnyCommand] +public sealed class SayLanguageCommand : IConsoleCommand +{ + [Dependency] private readonly LanguageSystem _language = default!; + [Dependency] private readonly ChatSystem _chat = default!; + + public string Command => "lsay"; + public string Description => "Send chat languages to the local channel or a specific chat channel, in a specific language."; + public string Help => "lsay "; + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (shell.Player is not { } player) + { + shell.WriteError("This command cannot be run from the server."); + return; + } + + if (player.Status != SessionStatus.InGame) + return; + + if (player.AttachedEntity is not {} playerEntity) + { + shell.WriteError("You don't have an entity!"); + return; + } + + if (args.Length < 2) + return; + + var languageId = args[0]; + var message = string.Join(" ", args, startIndex: 1, count: args.Length - 1).Trim(); + + if (string.IsNullOrEmpty(message)) + return; + + var language = _language.GetLanguage(languageId); + if (language == null || !_language.CanSpeak(playerEntity, language.ID)) + { + shell.WriteError($"Language {languageId} is invalid or you cannot speak it!"); + return; + } + + _chat.TrySendInGameICMessage(playerEntity, message, InGameICChatType.Speak, ChatTransmitRange.Normal, false, shell, player, languageOverride: language); + } +} diff --git a/Content.Server/Language/LanguageSystem.cs b/Content.Server/Language/LanguageSystem.cs index 0948f4429c2..ff3df907275 100644 --- a/Content.Server/Language/LanguageSystem.cs +++ b/Content.Server/Language/LanguageSystem.cs @@ -13,7 +13,7 @@ public sealed class LanguageSystem : SharedLanguageSystem public override void Initialize() { - SubscribeLocalEvent(OnInitLanguageSpeaker);\ + SubscribeLocalEvent(OnInitLanguageSpeaker); } private void OnInitLanguageSpeaker(EntityUid uid, LanguageSpeakerComponent component, ComponentInit args) @@ -101,25 +101,9 @@ public bool CanUnderstand(EntityUid listener, if (language.ID == Universal.ID || HasComp(listener)) return true; - var listenerLanguages = GetLanguages(listener, listenerLanguageComp).UnderstoodLanguages; + var listenerLanguages = GetLanguages(listener, listenerLanguageComp)?.UnderstoodLanguages; - return listenerLanguages.Contains(language.ID, StringComparer.Ordinal); - } - - [Obsolete("Use CanUnderstand(EntityUid, LanguagePrototype) instead if possible.", error: false)] - public bool CanUnderstand(EntityUid speaker, EntityUid listener, - LanguageSpeakerComponent? speakerComp = null, - LanguageSpeakerComponent? listenerComp = null) - { - if (HasComp(listener) || HasComp(speaker)) - return true; - - var speakerLanguage = GetLanguages(speaker, speakerComp).CurrentLanguage; - if (speakerLanguage == Universal.ID) - return true; - var listenerLanguages = GetLanguages(listener, listenerComp).UnderstoodLanguages; - - return listenerLanguages.Contains(speakerLanguage, StringComparer.Ordinal); + return listenerLanguages?.Contains(language.ID, StringComparer.Ordinal) ?? false; } public bool CanSpeak(EntityUid speaker, string language, LanguageSpeakerComponent? speakerComp = null) @@ -127,8 +111,8 @@ public bool CanSpeak(EntityUid speaker, string language, LanguageSpeakerComponen if (HasComp(speaker)) return true; - var langs = GetLanguages(speaker, speakerComp).UnderstoodLanguages; - return langs.Contains(language, StringComparer.Ordinal); + var langs = GetLanguages(speaker, speakerComp)?.UnderstoodLanguages; + return langs?.Contains(language, StringComparer.Ordinal) ?? false; } // @@ -136,7 +120,10 @@ public bool CanSpeak(EntityUid speaker, string language, LanguageSpeakerComponen // public LanguagePrototype GetLanguage(EntityUid speaker, LanguageSpeakerComponent? languageComp = null) { - var id = GetLanguages(speaker, languageComp).CurrentLanguage; + var id = GetLanguages(speaker, languageComp)?.CurrentLanguage; + if (id == null) + return Universal; // Fallback + _prototype.TryIndex(id, out LanguagePrototype? proto); return proto ?? Universal; @@ -154,42 +141,56 @@ private bool IsSentenceEnd(char ch) } // This event is reused because re-allocating it each time is way too costly. - private DetermineEntityLanguagesEvent _determineLanguagesEvent = new(null, new(), new()); + private DetermineEntityLanguagesEvent _determineLanguagesEvent = new(string.Empty, new(), new()); + + /// + /// Returns a pair of (spoken, understood) languages of the given entity. + /// + public (List, List) GetAllLanguages(EntityUid speaker) + { + var languages = GetLanguages(speaker); + if (languages == null) + return (new(), new()); + + // The lists need to be copied because the internal ones are re-used for performance reasons. + return (new List(languages.SpokenLanguages), new List(languages.UnderstoodLanguages)); + } /// /// Dynamically resolves the current language of the entity and the list of all languages it speaks. /// The returned event is reused and thus must not be held as a reference anywhere but inside the caller function. /// - private DetermineEntityLanguagesEvent GetLanguages(EntityUid speaker, LanguageSpeakerComponent? comp = null) + private DetermineEntityLanguagesEvent? GetLanguages(EntityUid speaker, LanguageSpeakerComponent? comp = null) { + if (comp == null && !TryComp(speaker, out comp)) + return null; + var ev = _determineLanguagesEvent; - ev.CurrentLanguage = null; ev.SpokenLanguages.Clear(); ev.UnderstoodLanguages.Clear(); - if (comp != null || TryComp(speaker, out comp)) - { - ev.CurrentLanguage = comp.CurrentLanguage; - ev.SpokenLanguages.AddRange(comp.SpokenLanguages); - ev.UnderstoodLanguages.AddRange(comp.UnderstoodLanguages); - } + ev.CurrentLanguage = comp.CurrentLanguage; + ev.SpokenLanguages.AddRange(comp.SpokenLanguages); + ev.UnderstoodLanguages.AddRange(comp.UnderstoodLanguages); - RaiseLocalEvent(speaker, ev); + RaiseLocalEvent(speaker, ev, true); - if (ev.CurrentLanguage == null) + if (ev.CurrentLanguage.Length == 0) ev.CurrentLanguage = comp?.CurrentLanguage ?? Universal.ID; // Fall back to account for admemes like admins possessing a bread return ev; } - private fun OnGetLanguageMessage - /// /// Raised in order to determine the language an entity speaks at the current moment, /// as well as the list of all languages the entity may speak and understand. /// public sealed class DetermineEntityLanguagesEvent : EntityEventArgs { - public string? CurrentLanguage; + /// + /// The default language of this entity. If empty, remain unchanged. + /// This field has no effect if the entity decides to speak in a concrete language. + /// + public string CurrentLanguage; /// /// The list of all languages the entity may speak. Must NOT be held as a reference! /// @@ -199,7 +200,7 @@ public sealed class DetermineEntityLanguagesEvent : EntityEventArgs /// public List UnderstoodLanguages; - public DetermineEntityLanguagesEvent(string? currentLanguage, List spokenLanguages, List understoodLanguages) + public DetermineEntityLanguagesEvent(string currentLanguage, List spokenLanguages, List understoodLanguages) { CurrentLanguage = currentLanguage; SpokenLanguages = spokenLanguages; diff --git a/Content.Shared/Language/SharedLanguageSystem.cs b/Content.Shared/Language/SharedLanguageSystem.cs index 778c7071f88..9349a6e4ab9 100644 --- a/Content.Shared/Language/SharedLanguageSystem.cs +++ b/Content.Shared/Language/SharedLanguageSystem.cs @@ -24,4 +24,10 @@ public override void Initialize() _universal = universal; _sawmill = Logger.GetSawmill("language"); } + + public LanguagePrototype? GetLanguage(string id) + { + _prototype.TryIndex(id, out var proto); + return proto; + } } From 98c8c3462f627a8c24642180cba85689a8285bcc Mon Sep 17 00:00:00 2001 From: fox Date: Thu, 7 Dec 2023 20:08:03 +0300 Subject: [PATCH 16/91] Implement translators --- Content.Server/Language/TranslatorSystem.cs | 159 ++++++++++++++++++ .../Language/TranslatorComponent.cs | 70 ++++++++ .../Locale/en-US/language/translator.ftl | 2 + 3 files changed, 231 insertions(+) create mode 100644 Content.Server/Language/TranslatorSystem.cs create mode 100644 Content.Shared/Language/TranslatorComponent.cs create mode 100644 Resources/Locale/en-US/language/translator.ftl diff --git a/Content.Server/Language/TranslatorSystem.cs b/Content.Server/Language/TranslatorSystem.cs new file mode 100644 index 00000000000..6b9157e55ce --- /dev/null +++ b/Content.Server/Language/TranslatorSystem.cs @@ -0,0 +1,159 @@ +using System.Linq; +using Content.Server.Popups; +using Content.Shared.Hands; +using Content.Shared.Interaction; +using Content.Shared.Inventory; +using Content.Shared.Inventory.Events; +using Content.Shared.Language; +using static Content.Server.Language.LanguageSystem; + +namespace Content.Server.Language; + +// this does not support holding multiple translators at once yet. +// that should not be an issue for now, but it better get fixed later. +public sealed class TranslatorSystem : EntitySystem +{ + [Dependency] private readonly PopupSystem _popup = default!; + + public override void Initialize() + { + SubscribeLocalEvent(OnInitTranslator); + SubscribeLocalEvent(ApplyTranslation); + + SubscribeLocalEvent(OnTranslatorToggle); + SubscribeLocalEvent(OnEquipTranslator); + SubscribeLocalEvent(OnHandEquipTranslator); + SubscribeLocalEvent(OnUnequipTranslator); + SubscribeLocalEvent(OnHandUnequipTranslator); + } + + private void OnInitTranslator(EntityUid uid, BaseTranslatorComponent component, ComponentInit args) + { + // TODO: anything? + } + + private void ApplyTranslation(EntityUid uid, IntrinsicTranslatorComponent component, + DetermineEntityLanguagesEvent ev) + { + if (!component.Enabled) + return; + + foreach (var language in component.SpokenLanguages) + { + AddIfNotExists(ev.SpokenLanguages, language); + } + + foreach (var language in component.UnderstoodLanguages) + { + AddIfNotExists(ev.UnderstoodLanguages, language); + } + + if (component.CurrentSpeechLanguage != null && ev.CurrentLanguage.Length == 0) + { + ev.CurrentLanguage = component.CurrentSpeechLanguage; + } + } + + private void OnEquipTranslator(EntityUid uid, HandheldTranslatorComponent component, EquippedEventBase args) + { + // TODO: make this customizable? + if (args.SlotFlags.HasFlag(SlotFlags.POCKET) || args.SlotFlags.HasFlag(SlotFlags.NECK)) + return; + TranslatorEquipped(args.Equipee, uid, component); + } + + private void OnHandEquipTranslator(EntityUid uid, HandheldTranslatorComponent component, EquippedHandEvent args) + { + TranslatorEquipped(args.User, uid, component); + } + + private void OnUnequipTranslator(EntityUid uid, HandheldTranslatorComponent component, UnequippedEventBase args) + { + TranslatorUnequipped(args.Equipee, uid, component); + } + + private void OnHandUnequipTranslator(EntityUid uid, HandheldTranslatorComponent component, UnequippedHandEvent args) + { + TranslatorUnequipped(args.User, uid, component); + } + + private void TranslatorEquipped(EntityUid holder, EntityUid translator, HandheldTranslatorComponent component) + { + if (!EntityManager.HasComponent(holder)) + return; + + var intrinsic = EntityManager.EnsureComponent(holder); + UpdateBoundIntrinsicComp(component, intrinsic, component.Enabled); + } + + private void TranslatorUnequipped(EntityUid holder, EntityUid translator, HandheldTranslatorComponent component) + { + if (!EntityManager.TryGetComponent(holder, out var intrinsic)) + return; + + if (intrinsic.Issuer == component) + { + intrinsic.Enabled = false; + EntityManager.RemoveComponent(holder); + } + } + + private void OnTranslatorToggle(EntityUid uid, HandheldTranslatorComponent component, InteractEvent args) + { + if (!component.ToggleOnInteract) + return; + + if (Transform(uid).ParentUid is { Valid: true } holder && EntityManager.HasComponent(holder)) + { + // This translator is held by a language speaker and thus has an intrinsic counterpart bound to it. Make sure it's up-to-date. + var intrinsic = EntityManager.EnsureComponent(holder); + var isEnabled = component.Enabled; + if (intrinsic.Issuer != component) + { + // The intrinsic comp wasn't owned by this handheld component, so this comp wasn't the active translator. + // Thus it needs to be turned on regardless of its previous state. + intrinsic.Issuer = component; + isEnabled = true; + } + + UpdateBoundIntrinsicComp(component, intrinsic, isEnabled); + component.Enabled = isEnabled; + } + else + { + // This is a standalone translator (e.g. lying on the ground). Simply toggle its state. + component.Enabled = !component.Enabled; + } + + var message = Loc.GetString(component.Enabled ? "translator-component-turnon" : "translator-component-shutoff"); + _popup.PopupEntity(message, component.Owner, args.User); + } + + /// + /// Copies the state from the handheld [comp] to the [intrinsic] comp, using [isEnabled] as the enabled state. + /// + private void UpdateBoundIntrinsicComp(HandheldTranslatorComponent comp, HoldsTranslatorComponent intrinsic, bool isEnabled) + { + if (isEnabled) + { + intrinsic.SpokenLanguages = new List(comp.SpokenLanguages); + intrinsic.UnderstoodLanguages = new List(comp.UnderstoodLanguages); + intrinsic.CurrentSpeechLanguage = comp.CurrentSpeechLanguage; + } + else + { + intrinsic.SpokenLanguages.Clear(); + intrinsic.UnderstoodLanguages.Clear(); + intrinsic.CurrentSpeechLanguage = null; + } + + intrinsic.Enabled = isEnabled; + } + + private static void AddIfNotExists(List list, string item) + { + if (list.Contains(item)) + return; + list.Add(item); + } +} diff --git a/Content.Shared/Language/TranslatorComponent.cs b/Content.Shared/Language/TranslatorComponent.cs new file mode 100644 index 00000000000..66f40b22992 --- /dev/null +++ b/Content.Shared/Language/TranslatorComponent.cs @@ -0,0 +1,70 @@ +namespace Content.Shared.Language; + +public abstract partial class BaseTranslatorComponent : Component +{ + /// + /// The language this translator changes the speaker's language to when they don't specify one. + /// If null, does not modify the default language. + /// + [DataField("current-language")] + [ViewVariables(VVAccess.ReadWrite)] + public string? CurrentSpeechLanguage = null; + + /// + /// The list of additional languages this translator allows the wielder to speak. + /// + [DataField("speaks")] + [ViewVariables(VVAccess.ReadWrite)] + public List SpokenLanguages = new(); + + /// + /// The list of additional languages this translator allows the wielder to understand. + /// + [DataField("understands")] + [ViewVariables(VVAccess.ReadWrite)] + public List UnderstoodLanguages = new(); + + [DataField("enabled")] + public bool Enabled = true; +} + +/// +/// A translator that must be held in a hand or a pocket of an entity in order ot have effect. +/// +[RegisterComponent] +public sealed partial class HandheldTranslatorComponent : BaseTranslatorComponent +{ + /// + /// Whether or not interacting with this translator + /// toggles it on or off. + /// + [DataField("toggleOnInteract")] + public bool ToggleOnInteract = true; + + // TODO: not implemented + // /// + // /// Whether or not this translator requires a power cell to work. + // /// + // [DataField("requiresPower")] + // [ViewVariables(VVAccess.ReadWrite)] + // public bool RequiresPower = true; +} + +/// +/// A translator attached to an entity that translates its speech. +/// An example is a translator implant that allows the speaker to speak another language. +/// +[RegisterComponent, Virtual] +public partial class IntrinsicTranslatorComponent : BaseTranslatorComponent +{ +} + +/// +/// Applied internally to the holder of [HandheldTranslatorComponent]. +/// Do not use directly. Use [HandheldTranslatorComponent] instead. +/// +[RegisterComponent] +public sealed partial class HoldsTranslatorComponent : IntrinsicTranslatorComponent +{ + public Component? Issuer = null; +} diff --git a/Resources/Locale/en-US/language/translator.ftl b/Resources/Locale/en-US/language/translator.ftl new file mode 100644 index 00000000000..f320668fa1f --- /dev/null +++ b/Resources/Locale/en-US/language/translator.ftl @@ -0,0 +1,2 @@ +translator-component-shutoff = The {$translator} shuts off. +translator-component-turnon = The {$translator} turns on. From f65f7e47ba00a9985bd6711fd9f4596d9862be6a Mon Sep 17 00:00:00 2001 From: fox Date: Thu, 7 Dec 2023 20:13:40 +0300 Subject: [PATCH 17/91] Remove entity system dependencies from commands, and do not listen on BaseTranslatorComp --- .../Language/Commands/ListLanguagesCommand.cs | 6 +++--- .../Language/Commands/SayLanguageCommand.cs | 12 ++++++------ Content.Server/Language/TranslatorSystem.cs | 6 ------ 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/Content.Server/Language/Commands/ListLanguagesCommand.cs b/Content.Server/Language/Commands/ListLanguagesCommand.cs index ac7b403707b..d64573daeba 100644 --- a/Content.Server/Language/Commands/ListLanguagesCommand.cs +++ b/Content.Server/Language/Commands/ListLanguagesCommand.cs @@ -8,8 +8,6 @@ namespace Content.Server.Language.Commands; [AnyCommand] public sealed class ListLanguagesCommand : IConsoleCommand { - [Dependency] private readonly LanguageSystem _language = default!; - public string Command => "lslangs"; public string Description => "List languages your current entity can speak at the current moment."; public string Help => "lslangs"; @@ -31,7 +29,9 @@ public void Execute(IConsoleShell shell, string argStr, string[] args) return; } - var (spokenLangs, knownLangs) = _language.GetAllLanguages(playerEntity); + var languages = IoCManager.Resolve().GetEntitySystem(); + + var (spokenLangs, knownLangs) = languages.GetAllLanguages(playerEntity); shell.WriteLine("Spoken: " + string.Join(", ", spokenLangs)); shell.WriteLine("Understood: " + string.Join(", ", knownLangs)); diff --git a/Content.Server/Language/Commands/SayLanguageCommand.cs b/Content.Server/Language/Commands/SayLanguageCommand.cs index 949f55938b9..8acbe5c78c4 100644 --- a/Content.Server/Language/Commands/SayLanguageCommand.cs +++ b/Content.Server/Language/Commands/SayLanguageCommand.cs @@ -8,9 +8,6 @@ namespace Content.Server.Language.Commands; [AnyCommand] public sealed class SayLanguageCommand : IConsoleCommand { - [Dependency] private readonly LanguageSystem _language = default!; - [Dependency] private readonly ChatSystem _chat = default!; - public string Command => "lsay"; public string Description => "Send chat languages to the local channel or a specific chat channel, in a specific language."; public string Help => "lsay "; @@ -41,13 +38,16 @@ public void Execute(IConsoleShell shell, string argStr, string[] args) if (string.IsNullOrEmpty(message)) return; - var language = _language.GetLanguage(languageId); - if (language == null || !_language.CanSpeak(playerEntity, language.ID)) + var languages = IoCManager.Resolve().GetEntitySystem(); + var chats = IoCManager.Resolve().GetEntitySystem(); + + var language = languages.GetLanguage(languageId); + if (language == null || !languages.CanSpeak(playerEntity, language.ID)) { shell.WriteError($"Language {languageId} is invalid or you cannot speak it!"); return; } - _chat.TrySendInGameICMessage(playerEntity, message, InGameICChatType.Speak, ChatTransmitRange.Normal, false, shell, player, languageOverride: language); + chats.TrySendInGameICMessage(playerEntity, message, InGameICChatType.Speak, ChatTransmitRange.Normal, false, shell, player, languageOverride: language); } } diff --git a/Content.Server/Language/TranslatorSystem.cs b/Content.Server/Language/TranslatorSystem.cs index 6b9157e55ce..8696023db88 100644 --- a/Content.Server/Language/TranslatorSystem.cs +++ b/Content.Server/Language/TranslatorSystem.cs @@ -17,7 +17,6 @@ public sealed class TranslatorSystem : EntitySystem public override void Initialize() { - SubscribeLocalEvent(OnInitTranslator); SubscribeLocalEvent(ApplyTranslation); SubscribeLocalEvent(OnTranslatorToggle); @@ -27,11 +26,6 @@ public override void Initialize() SubscribeLocalEvent(OnHandUnequipTranslator); } - private void OnInitTranslator(EntityUid uid, BaseTranslatorComponent component, ComponentInit args) - { - // TODO: anything? - } - private void ApplyTranslation(EntityUid uid, IntrinsicTranslatorComponent component, DetermineEntityLanguagesEvent ev) { From 266c8384956af5fa06b78aa956cf62902b228d63 Mon Sep 17 00:00:00 2001 From: fox Date: Thu, 7 Dec 2023 20:52:13 +0300 Subject: [PATCH 18/91] I spent... 30 minutes... debugging this... only to change 1 line --- Content.Server/Language/LanguageSystem.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Content.Server/Language/LanguageSystem.cs b/Content.Server/Language/LanguageSystem.cs index ff3df907275..6f54c4b01c4 100644 --- a/Content.Server/Language/LanguageSystem.cs +++ b/Content.Server/Language/LanguageSystem.cs @@ -13,6 +13,7 @@ public sealed class LanguageSystem : SharedLanguageSystem public override void Initialize() { + base.Initialize(); SubscribeLocalEvent(OnInitLanguageSpeaker); } From b9ce39c77a94467fd595131bbb73e7f9690c851a Mon Sep 17 00:00:00 2001 From: fox Date: Thu, 7 Dec 2023 22:09:10 +0300 Subject: [PATCH 19/91] New obfuscation system. Still quite broken. --- Content.Server/Language/LanguageSystem.cs | 112 +++++++++++++------- Content.Server/Language/TranslatorSystem.cs | 2 +- 2 files changed, 72 insertions(+), 42 deletions(-) diff --git a/Content.Server/Language/LanguageSystem.cs b/Content.Server/Language/LanguageSystem.cs index 6f54c4b01c4..af2a124f394 100644 --- a/Content.Server/Language/LanguageSystem.cs +++ b/Content.Server/Language/LanguageSystem.cs @@ -39,60 +39,85 @@ public string ObfuscateSpeech(EntityUid? source, string message, LanguagePrototy { if (language == null) { - if (source == null) + if (source is not { Valid: true }) { throw new NullReferenceException("Either source or language must be set."); } language = GetLanguage(source.Value); } - // TODO: REWORK THIS! - // Should instead use random number of syllables/phrases per word/sentence depending on its length - // Also preferably should use a simple hash code of the word as the random seed to make obfuscation stable - // This will also allow people to learn certain phrases, e.g. how "yes" is spelled in canilunzt. var builder = new StringBuilder(); if (language.ObfuscateSyllables) { - // Go through every syllable in every sentence and replace it with "replacement", preserving spaces - // Effectively, the number of syllables in a word is equal to the number of vowels in it. - for (var i = 0; i < message.Length; i++) - { - var ch = char.ToLower(message[i]); - if (char.IsWhiteSpace(ch) || IsSentenceEnd(ch)) - { - builder.Append(ch); // This will preserve major punctuation and spaces - } - else if (IsVowel(ch)) - { - builder.Append(_random.Pick(language.Replacement)); - } - } + ObfuscateSyllables(builder, message, language); } else { - // Replace every sentence with "replacement" - for (int i = 0; i < message.Length; i++) + ObfuscatePhrases(builder, message, language); + } + + _sawmill.Info($"Got {message}, obfuscated to {builder}. Language: {language.ID}"); + + return builder.ToString(); + } + + private void ObfuscateSyllables(StringBuilder builder, string message, LanguagePrototype language) + { + // Go through each word. Calculate its hash sum and count the number of letters. + // Replicate it with pseudo-random syllables of pseudo-random (but similar) length. Use the hash code as the seed. + // This means that identical words will be obfuscated identically. Simple words like "hello" or "yes" in different langs can be memorized. + var wordBeginIndex = 0; + var hashCode = 0; + for (var i = 0; i < message.Length; i++) + { + var ch = char.ToLower(message[i]); + // A word ends when one of the following is found: a space, a sentence end, or EOM + if (ch == ' ' || IsSentenceEnd(ch) || i == message.Length - 1) { - var ch = char.ToLower(message[i]); - if (IsSentenceEnd(ch)) + var wordLength = i - wordBeginIndex - 1; + if (wordLength > 0) { - builder.Append(_random.Pick(language.Replacement)); - builder.Append(' '); + var newWordLength = PseudoRandomNumber(hashCode, 1, 4); + + for (var j = 0; j < newWordLength; j++) + { + var index = PseudoRandomNumber(hashCode + j, 0, language.Replacement.Count); + builder.Append(language.Replacement[index]); + } } - // Skip any consequent sentence ends to account for !.., ?.., ?!, and similar - while (i < message.Length - 1 && (IsSentenceEnd(message[i + 1]) || char.IsWhiteSpace(message[i + 1]))) - i++; + builder.Append(ch); + hashCode = 0; + wordBeginIndex = i + 1; + } + else + { + hashCode = hashCode * 31 + ch; } - - // Finally, add one more string unless the last character is a sentence end - if (IsSentenceEnd(message[^1])) - builder.Append(_random.Pick(language.Replacement)); } + } - _sawmill.Info($"Got {message}, obfuscated to {builder}. Language: {language.ID}"); + private void ObfuscatePhrases(StringBuilder builder, string message, LanguagePrototype language) + { + // In a similar manner, each phrase is obfuscated with a random number of conjoined obfuscation phrases. + // However, the number of phrases depends on the number of characters in the original phrase. + var sentenceBeginIndex = 0; + for (var i = 0; i < message.Length; i++) + { + var ch = char.ToLower(message[i]); + if (IsSentenceEnd(ch) || i == message.Length - 1) + { + var length = i - sentenceBeginIndex - 1; + var newLength = (int) Math.Clamp(Math.Cbrt(length) - 1, 1, 4); // 27+ chars for 2 phrases, 64+ for 3, 125+ for 4. - return builder.ToString(); + for (var j = 0; j < newLength; j++) + { + var phrase = _random.Pick(language.Replacement); + builder.Append(phrase); + } + sentenceBeginIndex = i + 1; + } + } } public bool CanUnderstand(EntityUid listener, @@ -130,13 +155,7 @@ public LanguagePrototype GetLanguage(EntityUid speaker, LanguageSpeakerComponent return proto ?? Universal; } - private bool IsVowel(char ch) - { - // This is not a language-agnostic approach and will totally break with non-latin languages. - return ch is 'a' or 'e' or 'i' or 'o' or 'u' or 'y'; - } - - private bool IsSentenceEnd(char ch) + private static bool IsSentenceEnd(char ch) { return ch is '.' or '!' or '?'; } @@ -181,6 +200,17 @@ private bool IsSentenceEnd(char ch) return ev; } + /// + /// Generates a stable pseudo-random number in the range [min, max) for the given seed. Each input seed corresponds to exactly one random number. + /// + private static int PseudoRandomNumber(int seed, int min, int max) + { + // This is not a uniform distribution, but it shouldn't matter: given there's 2^31 possible random numbers, + // The bias of this function should be so tiny it will never be noticed. + var random = ((seed * 1103515245) + 12345) & 0x7fffffff; // Source: http://cs.uccs.edu/~cs591/bufferOverflow/glibc-2.2.4/stdlib/random_r.c + return random % (max - min) + min; + } + /// /// Raised in order to determine the language an entity speaks at the current moment, /// as well as the list of all languages the entity may speak and understand. diff --git a/Content.Server/Language/TranslatorSystem.cs b/Content.Server/Language/TranslatorSystem.cs index 8696023db88..897a9172f48 100644 --- a/Content.Server/Language/TranslatorSystem.cs +++ b/Content.Server/Language/TranslatorSystem.cs @@ -88,7 +88,7 @@ private void TranslatorUnequipped(EntityUid holder, EntityUid translator, Handhe if (intrinsic.Issuer == component) { intrinsic.Enabled = false; - EntityManager.RemoveComponent(holder); + EntityManager.RemoveComponent(holder, intrinsic); } } From 53cc5adb9db9fd27deb35b309a5bc8666b99e9d7 Mon Sep 17 00:00:00 2001 From: fox Date: Thu, 7 Dec 2023 22:39:27 +0300 Subject: [PATCH 20/91] Hopefully fixed phrase obfuscation --- Content.Server/Language/LanguageSystem.cs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Content.Server/Language/LanguageSystem.cs b/Content.Server/Language/LanguageSystem.cs index af2a124f394..69491969b46 100644 --- a/Content.Server/Language/LanguageSystem.cs +++ b/Content.Server/Language/LanguageSystem.cs @@ -72,7 +72,7 @@ private void ObfuscateSyllables(StringBuilder builder, string message, LanguageP { var ch = char.ToLower(message[i]); // A word ends when one of the following is found: a space, a sentence end, or EOM - if (ch == ' ' || IsSentenceEnd(ch) || i == message.Length - 1) + if (ch is ' ' || IsSentenceEnd(ch) || i == message.Length - 1) { var wordLength = i - wordBeginIndex - 1; if (wordLength > 0) @@ -108,14 +108,20 @@ private void ObfuscatePhrases(StringBuilder builder, string message, LanguagePro if (IsSentenceEnd(ch) || i == message.Length - 1) { var length = i - sentenceBeginIndex - 1; - var newLength = (int) Math.Clamp(Math.Cbrt(length) - 1, 1, 4); // 27+ chars for 2 phrases, 64+ for 3, 125+ for 4. - - for (var j = 0; j < newLength; j++) + if (length > 0) { - var phrase = _random.Pick(language.Replacement); - builder.Append(phrase); + var newLength = (int) Math.Clamp(Math.Cbrt(length) - 1, 1, 4); // 27+ chars for 2 phrases, 64+ for 3, 125+ for 4. + + for (var j = 0; j < newLength; j++) + { + var phrase = _random.Pick(language.Replacement); + builder.Append(phrase); + } } sentenceBeginIndex = i + 1; + + if (IsSentenceEnd(ch)) + builder.Append(ch).Append(" "); } } } From 40284c36351e6b598a27418e232348f535fc214b Mon Sep 17 00:00:00 2001 From: fox Date: Thu, 7 Dec 2023 23:22:10 +0300 Subject: [PATCH 21/91] fix the damn LANGUAGE PROTOTYPES!!!!! --- Content.Shared/Language/LanguagePrototype.cs | 7 ++----- Resources/Prototypes/languages.yml | 4 +++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Content.Shared/Language/LanguagePrototype.cs b/Content.Shared/Language/LanguagePrototype.cs index ef3fe298e57..33a18711ae6 100644 --- a/Content.Shared/Language/LanguagePrototype.cs +++ b/Content.Shared/Language/LanguagePrototype.cs @@ -5,7 +5,6 @@ namespace Content.Shared.Language; [Prototype("language")] public sealed class LanguagePrototype : IPrototype { - [ViewVariables] [IdDataField] public string ID { get; private set; } = default!; @@ -13,15 +12,13 @@ public sealed class LanguagePrototype : IPrototype // If true, obfuscated phrases of creatures speaking this language will have their syllables replaced with "replacement" syllables. // Otherwise entire sentences will be replaced. // - [ViewVariables] - [DataField("name")] + [DataField("obfuscateSyllables", required: true)] public bool ObfuscateSyllables { get; private set; } = false; // // Lists all syllables that are used to obfuscate a message a listener cannot understand if obfuscateSyllables is true, // Otherwise uses all possible phrases the creature can make when trying to say anything. // - [ViewVariables] - [DataField("replacement")] + [DataField("replacement", required: true)] public List Replacement = new(); } diff --git a/Resources/Prototypes/languages.yml b/Resources/Prototypes/languages.yml index 6a62dd9e2ee..da70be2634d 100644 --- a/Resources/Prototypes/languages.yml +++ b/Resources/Prototypes/languages.yml @@ -2,7 +2,8 @@ # Do not use otherwise. Try to use the respective component instead of this language. - type: language id: Universal - replacements: + obfuscateSyllables: false + replacement: - "*incomprehensible*" # Spoken by most sentient creatures. @@ -105,6 +106,7 @@ # TODO: rest of the critters - type: language id: Mothroach + obfuscateSyllables: false replacement: - Chitter - Buzz From 6d0b4ad070575db958e1363287ea3410858ce161 Mon Sep 17 00:00:00 2001 From: fox Date: Thu, 7 Dec 2023 23:22:29 +0300 Subject: [PATCH 22/91] Make obfuscation different in each round --- Content.Server/Language/LanguageSystem.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/Content.Server/Language/LanguageSystem.cs b/Content.Server/Language/LanguageSystem.cs index 69491969b46..98a460ee249 100644 --- a/Content.Server/Language/LanguageSystem.cs +++ b/Content.Server/Language/LanguageSystem.cs @@ -1,6 +1,7 @@ using System.Linq; using System.Text; using Content.Server.Chat.Systems; +using Content.Shared.GameTicking; using Content.Shared.Language; using Content.Shared.Speech; using Robust.Shared.Prototypes; @@ -10,11 +11,16 @@ namespace Content.Server.Language; public sealed class LanguageSystem : SharedLanguageSystem { + /// + /// A random number added to each pseudo-random number's seed. Changes every round. + /// + public int RandomRoundSeed { get; private set; } public override void Initialize() { base.Initialize(); SubscribeLocalEvent(OnInitLanguageSpeaker); + SubscribeAllEvent(it => RandomRoundSeed = _random.Next()); } private void OnInitLanguageSpeaker(EntityUid uid, LanguageSpeakerComponent component, ComponentInit args) @@ -72,9 +78,9 @@ private void ObfuscateSyllables(StringBuilder builder, string message, LanguageP { var ch = char.ToLower(message[i]); // A word ends when one of the following is found: a space, a sentence end, or EOM - if (ch is ' ' || IsSentenceEnd(ch) || i == message.Length - 1) + if (char.IsWhiteSpace(ch) || IsSentenceEnd(ch) || i == message.Length - 1) { - var wordLength = i - wordBeginIndex - 1; + var wordLength = i - wordBeginIndex; if (wordLength > 0) { var newWordLength = PseudoRandomNumber(hashCode, 1, 4); @@ -107,7 +113,7 @@ private void ObfuscatePhrases(StringBuilder builder, string message, LanguagePro var ch = char.ToLower(message[i]); if (IsSentenceEnd(ch) || i == message.Length - 1) { - var length = i - sentenceBeginIndex - 1; + var length = i - sentenceBeginIndex; if (length > 0) { var newLength = (int) Math.Clamp(Math.Cbrt(length) - 1, 1, 4); // 27+ chars for 2 phrases, 64+ for 3, 125+ for 4. @@ -209,10 +215,11 @@ private static bool IsSentenceEnd(char ch) /// /// Generates a stable pseudo-random number in the range [min, max) for the given seed. Each input seed corresponds to exactly one random number. /// - private static int PseudoRandomNumber(int seed, int min, int max) + private int PseudoRandomNumber(int seed, int min, int max) { // This is not a uniform distribution, but it shouldn't matter: given there's 2^31 possible random numbers, // The bias of this function should be so tiny it will never be noticed. + seed += RandomRoundSeed; var random = ((seed * 1103515245) + 12345) & 0x7fffffff; // Source: http://cs.uccs.edu/~cs591/bufferOverflow/glibc-2.2.4/stdlib/random_r.c return random % (max - min) + min; } From 185818254838ab90598d33fda41637a9633a8adb Mon Sep 17 00:00:00 2001 From: FoxxoTrystan Date: Thu, 7 Dec 2023 23:24:58 +0100 Subject: [PATCH 23/91] SolCommon (And test commit) --- .../Entities/Mobs/Species/dwarf.yml | 7 + .../Entities/Mobs/Species/human.yml | 7 + .../_Nyano/Entities/Mobs/Species/felinid.yml | 7 + Resources/Prototypes/languages.yml | 125 +++++++++++++++++- 4 files changed, 145 insertions(+), 1 deletion(-) diff --git a/Resources/Prototypes/Entities/Mobs/Species/dwarf.yml b/Resources/Prototypes/Entities/Mobs/Species/dwarf.yml index f37ffb51f19..443c8068f0e 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/dwarf.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/dwarf.yml @@ -57,6 +57,13 @@ accent: dwarf - type: Speech speechSounds: Bass + - type: LanguageSpeaker + speaks: + - GalacticCommon + - SolCommon + understands: + - GalacticCommon + - SolCommon - type: entity parent: BaseSpeciesDummy diff --git a/Resources/Prototypes/Entities/Mobs/Species/human.yml b/Resources/Prototypes/Entities/Mobs/Species/human.yml index 7b92fca2926..479438e956c 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/human.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/human.yml @@ -21,6 +21,13 @@ spawned: - id: FoodMeatHuman amount: 5 + - type: LanguageSpeaker + speaks: + - GalacticCommon + - SolCommon + understands: + - GalacticCommon + - SolCommon - type: entity parent: BaseSpeciesDummy diff --git a/Resources/Prototypes/_Nyano/Entities/Mobs/Species/felinid.yml b/Resources/Prototypes/_Nyano/Entities/Mobs/Species/felinid.yml index b7d28c521e5..ae87627c1c3 100644 --- a/Resources/Prototypes/_Nyano/Entities/Mobs/Species/felinid.yml +++ b/Resources/Prototypes/_Nyano/Entities/Mobs/Species/felinid.yml @@ -58,6 +58,13 @@ factions: - NanoTrasen - Felinid + - type: LanguageSpeaker + speaks: + - GalacticCommon + - SolCommon + understands: + - GalacticCommon + - SolCommon - type: entity save: false diff --git a/Resources/Prototypes/languages.yml b/Resources/Prototypes/languages.yml index da70be2634d..9afc3815f74 100644 --- a/Resources/Prototypes/languages.yml +++ b/Resources/Prototypes/languages.yml @@ -82,7 +82,37 @@ - azunein - ghzth +# The common language of the Sol system. +- type: language + id: SolCommon + obfuscateSyllables: true + replacement: + - tao + - shi + - tzu + - yi + - com + - be + - is + - i + - op + - vi + - ed + - lec + - mo + - cle + - te + - dis + - e + # Languages spoken by various critters. +# THIS LANGAUGE IS ONLY HERE TO BE UNDERSTOOD, NOT SPOKEN. +- type: language + id: Animal + obfuscateSyllables: false + replacement: + - Weh! + - type: language id: Cat obfuscateSyllables: true @@ -103,7 +133,6 @@ - raff - garr -# TODO: rest of the critters - type: language id: Mothroach obfuscateSyllables: false @@ -111,3 +140,97 @@ - Chitter - Buzz - Chirp + - Squeak + - Peep + - Eeee + - Eep + +- type: language + id: Xeno + obfuscateSyllables: true + replacement: + - sss + - sSs + - SSS + +- type: language + id: RobotTalk + obfuscateSyllables: true + replacement: + - 0 + - 1 + - 2 + +- type: language + id: Monkey + obfuscateSyllables: true + replacement: + - ok + - ook + - oook + - ooook + - oooook + +- type: language + id: Bee + obfuscateSyllables: false + replacement: + - Buz + - Buuz + - Buzz + - Buzzz + - Buuzz + +- type: language + id: Mouse + obfuscateSyllables: false + replacement: + - Squeak + - Piep + - Chuu + - Eeee + - Pip + - Fwiep + - Heep + +- type: language + id: Chicken + obfuscateSyllables: false + replacement: + - Coo + - Coot + - Cooot + +- type: language + id: Duck + obfuscateSyllables: false + replacement: + - Quack + +- type: language + id: Cow + obfuscateSyllables: false + replacement: + - Moo + - Mooo + +- type: language + id: Sheep + obfuscateSyllables: false + replacement: + - Ba + - Baa + - Baaa + +- type: language + id: Kangaroo + obfuscateSyllables: false + replacement: + - Shreak + - Chuu + +- type: language + id: Pig + obfuscateSyllables: false + replacement: + - Oink From fde54d91cd1fa892979282ce4d9f0b65624cf6ad Mon Sep 17 00:00:00 2001 From: FoxxoTrystan Date: Fri, 8 Dec 2023 03:11:28 +0100 Subject: [PATCH 24/91] Language Select Command A Command and Function to change a Langauge, Ent need to know the langauge. --- .../Commands/SelectLanguageCommand.cs | 48 +++++++++++++++++++ Content.Server/Language/LanguageSystem.cs | 17 +++++++ 2 files changed, 65 insertions(+) create mode 100644 Content.Server/Language/Commands/SelectLanguageCommand.cs diff --git a/Content.Server/Language/Commands/SelectLanguageCommand.cs b/Content.Server/Language/Commands/SelectLanguageCommand.cs new file mode 100644 index 00000000000..c43794d6f2f --- /dev/null +++ b/Content.Server/Language/Commands/SelectLanguageCommand.cs @@ -0,0 +1,48 @@ +using System.Linq; +using Content.Shared.Administration; +using Robust.Shared.Console; +using Robust.Shared.Enums; + +namespace Content.Server.Language.Commands; + +[AnyCommand] +public sealed class SelectLanguageCommand : IConsoleCommand +{ + public string Command => "lsselectlang"; + public string Description => "Open a menu to select a langauge to speak."; + public string Help => "lsselectlang"; + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (shell.Player is not { } player) + { + shell.WriteError("This command cannot be run from the server."); + return; + } + + if (player.Status != SessionStatus.InGame) + return; + + if (player.AttachedEntity is not { } playerEntity) + { + shell.WriteError("You don't have an entity!"); + return; + } + + if (args.Length < 1) + return; + + var languageId = args[0]; + + var languages = IoCManager.Resolve().GetEntitySystem(); + + var language = languages.GetLanguage(languageId); + if (language == null || !languages.CanSpeak(playerEntity, language.ID)) + { + shell.WriteError($"Language {languageId} is invalid or you cannot speak it!"); + return; + } + + languages.SetLanguage(playerEntity, language.ID); + } +} diff --git a/Content.Server/Language/LanguageSystem.cs b/Content.Server/Language/LanguageSystem.cs index 98a460ee249..4342161df54 100644 --- a/Content.Server/Language/LanguageSystem.cs +++ b/Content.Server/Language/LanguageSystem.cs @@ -167,6 +167,23 @@ public LanguagePrototype GetLanguage(EntityUid speaker, LanguageSpeakerComponent return proto ?? Universal; } + // + // Set the CurrentLangauge of the given entity. + // + public void SetLanguage(EntityUid speaker, string language, LanguageSpeakerComponent? languageComp = null) + { + if (!CanSpeak(speaker, language)) + return; + + if (languageComp == null && !TryComp(speaker, out languageComp)) + return; + + if (languageComp.CurrentLanguage == language) + return; + + languageComp.CurrentLanguage = language; + } + private static bool IsSentenceEnd(char ch) { return ch is '.' or '!' or '?'; From c1185e3557cc1da59b0ab3ea5cac54d173c852ba Mon Sep 17 00:00:00 2001 From: FoxxoTrystan Date: Fri, 8 Dec 2023 05:08:58 +0100 Subject: [PATCH 25/91] MORE LANGUAGES! --- .../Entities/Mobs/Species/reptilian.yml | 7 + .../Entities/Mobs/Species/slime.yml | 7 + .../_Nyano/Entities/Mobs/Species/felinid.yml | 2 + .../_Nyano/Entities/Mobs/Species/oni.yml | 7 + Resources/Prototypes/languages.yml | 327 +++++++++++++++++- 5 files changed, 332 insertions(+), 18 deletions(-) diff --git a/Resources/Prototypes/Entities/Mobs/Species/reptilian.yml b/Resources/Prototypes/Entities/Mobs/Species/reptilian.yml index f9fa619b37d..ec13c283012 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/reptilian.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/reptilian.yml @@ -66,6 +66,13 @@ heatDamage: types: Heat : 0.1 #per second, scales with temperature & other constants + - type: LanguageSpeaker + speaks: + - GalacticCommon + - Draconic + understands: + - GalacticCommon + - Draconic - type: entity parent: BaseSpeciesDummy diff --git a/Resources/Prototypes/Entities/Mobs/Species/slime.yml b/Resources/Prototypes/Entities/Mobs/Species/slime.yml index 60fbf410e8e..3412783c6a5 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/slime.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/slime.yml @@ -75,6 +75,13 @@ types: Asphyxiation: -1.0 maxSaturation: 15 + - type: LanguageSpeaker + speaks: + - GalacticCommon + - Bubblish + understands: + - GalacticCommon + - Bubblish - type: entity parent: MobHumanDummy diff --git a/Resources/Prototypes/_Nyano/Entities/Mobs/Species/felinid.yml b/Resources/Prototypes/_Nyano/Entities/Mobs/Species/felinid.yml index ae87627c1c3..d899767d10d 100644 --- a/Resources/Prototypes/_Nyano/Entities/Mobs/Species/felinid.yml +++ b/Resources/Prototypes/_Nyano/Entities/Mobs/Species/felinid.yml @@ -62,9 +62,11 @@ speaks: - GalacticCommon - SolCommon + - Nekomimetic understands: - GalacticCommon - SolCommon + - Nekomimetic - type: entity save: false diff --git a/Resources/Prototypes/_Nyano/Entities/Mobs/Species/oni.yml b/Resources/Prototypes/_Nyano/Entities/Mobs/Species/oni.yml index c84c737ca65..5fbb62d454f 100644 --- a/Resources/Prototypes/_Nyano/Entities/Mobs/Species/oni.yml +++ b/Resources/Prototypes/_Nyano/Entities/Mobs/Species/oni.yml @@ -37,6 +37,13 @@ - type: NpcFactionMember factions: - NanoTrasen + - type: LanguageSpeaker + speaks: + - GalacticCommon + - Nekomimetic + understands: + - GalacticCommon + - Nekomimetic - type: entity save: false diff --git a/Resources/Prototypes/languages.yml b/Resources/Prototypes/languages.yml index 9afc3815f74..49a92d423c3 100644 --- a/Resources/Prototypes/languages.yml +++ b/Resources/Prototypes/languages.yml @@ -6,17 +6,316 @@ replacement: - "*incomprehensible*" -# Spoken by most sentient creatures. +# The common galactic tongue. - type: language id: GalacticCommon - obfuscateSyllables: false + obfuscateSyllables: true + replacement: + - Blah + - Blah + - Blah + - dingle-doingle + - dingle + - dangle + - jibber-jabber + - jubber + - bleh + - zippity + - zoop + - wibble + - wobble + - wiggle + - yada + - meh + - neh + - nah + - wah + +# Second most spoken langauge after Galatic Common, those langauges were using mostly on uplifted species. +- type: language + id: Babel + obfuscateSyllables: true + replacement: + - lorem + - ipsum + - dolor + - sit + - amet + - consectetur + - adipiscing + - elit + - sed + - do + - eiusmod + - tempor + - incididunt + - ut + - labore + - et + - dolore + - magna + - aliqua + - ut + - enim + - ad + - minim + - veniam + - quis + - nostrud + - exercitation + - ullamco + - laboris + - nisi + - ut + - aliquip + - ex + - ea + - commodo + - consequat + - duis + - aute + - irure + - dolor + - in + - reprehenderit + - voluptate + - velit + - esse + - cillum + - dolore + - eu + - fugiat + - nulla + - pariatur + - excepteur + - sint + - occaecat + - cupidatat + - non + - proident + - sunt + - in + - culpa + - qui + - officia + - deserunt + - mollit + - anim + - id + - est + - laborum + +# Spoken by slimes. +- type: language + id: Bubblish + obfuscateSyllables: true + replacement: + - blob + - plop + - pop + - bop + - boop + +# Syndicate operatives can use a series of codewords to convey complex information. +- type: language + id: Codespeak + obfuscateSyllables: true + replacement: + - WhiteRussian + - Station Representative + - Sherrif + - Deputy + - Vault + - Frontier + - Station + - Security + - Coffee + - Cola + - Water + - Engineering + - Captain + - Calling a friend + - Atmos + - Robotic + - Medical + - AI + - Human + - Vulpkanin + - Lizard + - Moth + - Tea + - Chair + - Sofa + - Ship + - Shuttle + - Weapon + - Weapons + - Laser + - Carp + - Space Carp + - Xeno + - Xenomorph + - Biohazard + - Money + - Space + - Danger + - Monkey + - Pun pun + - Yes + - No + - In + - Running + - Killing + - Kill + - Save + - Life + - Dragon + - Ninja + - Secret + +# A mess of broken Japanese, spoken by Felinds and Oni +- type: language + id: Nekomimetic + obfuscateSyllables: true replacement: - - Blah blah blah - - Dingle-doingle dingle dangle - - Jibber-jabber jubber - - Zippity zappity zoop - - Wibble wobble wiggle - - Yada yada yada + - neko + - nyan + - mimi + - moe + - mofu + - fuwa + - kyaa + - kawaii + - poka + - munya + - puni + - munyu + - ufufu + - icha + - doki + - kyun + - kusu + - nya + - nyaa + - desu + - kis + - ama + - chuu + - baka + - hewo + - boop + - gato + - kit + t sune + - yori + - sou + - baka + - chan + - san + - kun + - mahou + - yatta + - suki + - usagi + - domo + - ori + - uwa + - zaazaa + - shiku + - puru + - ira + - heto + - etto + +# Spoken by the Lizard race. +- type: language + id: Draconic + obfuscateSyllables: true + replacement: + - za + - az + - ze + - ez + - zi + - iz + - zo + - oz + - zu + - uz + - zs + - sz + - ha + - ah + - he + - eh + - hi + - ih + - ho + - oh + - hu + - uh + - hs + - sh + - la + - al + - le + - el + - li + - il + - lo + - ol + - lu + - ul + - ls + - sl + - ka + - ak + - ke + - ek + - ki + - ik + - ko + - ok + - ku + - uk + - ks + - sk + - sa + - as + - se + - es + - si + - is + - so + - os + - su + - us + - ss + - ss + - ra + - ar + - re + - er + - ri + - ir + - ro + - or + - ru + - ur + - rs + - sr + - a + - a + - e + - e + - i + - i + - o + - o + - u + - u + - s + - s # Spoken by the Vulpkanin race. - type: language @@ -106,13 +405,6 @@ - e # Languages spoken by various critters. -# THIS LANGAUGE IS ONLY HERE TO BE UNDERSTOOD, NOT SPOKEN. -- type: language - id: Animal - obfuscateSyllables: false - replacement: - - Weh! - - type: language id: Cat obfuscateSyllables: true @@ -157,9 +449,8 @@ id: RobotTalk obfuscateSyllables: true replacement: - - 0 - - 1 - - 2 + - beep + - boop - type: language id: Monkey From cec0d7f93645bd430d007866bb2aa1f5fa88fb12 Mon Sep 17 00:00:00 2001 From: FoxxoTrystan Date: Fri, 8 Dec 2023 06:00:38 +0100 Subject: [PATCH 26/91] Fixes --- .../_Nyano/Entities/Mobs/Species/oni.yml | 2 +- Resources/Prototypes/languages.yml | 78 +------------------ 2 files changed, 2 insertions(+), 78 deletions(-) diff --git a/Resources/Prototypes/_Nyano/Entities/Mobs/Species/oni.yml b/Resources/Prototypes/_Nyano/Entities/Mobs/Species/oni.yml index 5fbb62d454f..9a6cbc6fa7c 100644 --- a/Resources/Prototypes/_Nyano/Entities/Mobs/Species/oni.yml +++ b/Resources/Prototypes/_Nyano/Entities/Mobs/Species/oni.yml @@ -37,7 +37,7 @@ - type: NpcFactionMember factions: - NanoTrasen - - type: LanguageSpeaker + - type: LanguageSpeaker speaks: - GalacticCommon - Nekomimetic diff --git a/Resources/Prototypes/languages.yml b/Resources/Prototypes/languages.yml index 49a92d423c3..7b3d9549a50 100644 --- a/Resources/Prototypes/languages.yml +++ b/Resources/Prototypes/languages.yml @@ -31,80 +31,6 @@ - nah - wah -# Second most spoken langauge after Galatic Common, those langauges were using mostly on uplifted species. -- type: language - id: Babel - obfuscateSyllables: true - replacement: - - lorem - - ipsum - - dolor - - sit - - amet - - consectetur - - adipiscing - - elit - - sed - - do - - eiusmod - - tempor - - incididunt - - ut - - labore - - et - - dolore - - magna - - aliqua - - ut - - enim - - ad - - minim - - veniam - - quis - - nostrud - - exercitation - - ullamco - - laboris - - nisi - - ut - - aliquip - - ex - - ea - - commodo - - consequat - - duis - - aute - - irure - - dolor - - in - - reprehenderit - - voluptate - - velit - - esse - - cillum - - dolore - - eu - - fugiat - - nulla - - pariatur - - excepteur - - sint - - occaecat - - cupidatat - - non - - proident - - sunt - - in - - culpa - - qui - - officia - - deserunt - - mollit - - anim - - id - - est - - laborum - # Spoken by slimes. - type: language id: Bubblish @@ -161,8 +87,6 @@ - Danger - Monkey - Pun pun - - Yes - - No - In - Running - Killing @@ -206,7 +130,7 @@ - boop - gato - kit - t sune + - sune - yori - sou - baka From 31bf9c36bcd189bdff1afca491cd49e86dce6d16 Mon Sep 17 00:00:00 2001 From: fox Date: Sat, 9 Dec 2023 01:32:40 +0300 Subject: [PATCH 27/91] Remove unused files --- .../_NF/VulpLanguage/VulpTranslatorComponent.cs | 7 ------- .../Entities/Objects/Devices/vulptranslator.yml | 14 -------------- 2 files changed, 21 deletions(-) delete mode 100644 Content.Server/_NF/VulpLanguage/VulpTranslatorComponent.cs delete mode 100644 Resources/Prototypes/DeltaV/Entities/Objects/Devices/vulptranslator.yml diff --git a/Content.Server/_NF/VulpLanguage/VulpTranslatorComponent.cs b/Content.Server/_NF/VulpLanguage/VulpTranslatorComponent.cs deleted file mode 100644 index 0ce8b145f84..00000000000 --- a/Content.Server/_NF/VulpLanguage/VulpTranslatorComponent.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Content.Server.VulpLangauge -{ - [RegisterComponent] - public partial class VulpTranslatorComponent : Component - { - } -} diff --git a/Resources/Prototypes/DeltaV/Entities/Objects/Devices/vulptranslator.yml b/Resources/Prototypes/DeltaV/Entities/Objects/Devices/vulptranslator.yml deleted file mode 100644 index cc6e3a49424..00000000000 --- a/Resources/Prototypes/DeltaV/Entities/Objects/Devices/vulptranslator.yml +++ /dev/null @@ -1,14 +0,0 @@ -- type: entity - id: VulpTranslator - parent: [ BaseItem, PowerCellSlotMediumItem ] - name: Canilunzt translator - description: "Used by Vulpkanins to translate their speech." - components: - - type: Sprite - sprite: /Textures/Objects/Devices/vulp_translator.rsi - layers: - - state: icon - - type: PowerCellDraw - drawRate: 0 - useRate: 1 - - type: VulpTranslator From 50c97e59d7e79a48ff1cf2a0c15cd2072821bc7d Mon Sep 17 00:00:00 2001 From: fox Date: Sat, 9 Dec 2023 01:32:54 +0300 Subject: [PATCH 28/91] Rework translators in a way that *seems* to work --- Content.Server/Language/LanguageSystem.cs | 17 +++ Content.Server/Language/TranslatorSystem.cs | 120 ++++++++++++------ .../Language/TranslatorComponent.cs | 28 ++-- .../Language/devices/base_translator.yml | 13 ++ .../Language/devices/vulp_translator.yml | 22 ++++ 5 files changed, 151 insertions(+), 49 deletions(-) create mode 100644 Resources/Prototypes/Language/devices/base_translator.yml create mode 100644 Resources/Prototypes/Language/devices/vulp_translator.yml diff --git a/Content.Server/Language/LanguageSystem.cs b/Content.Server/Language/LanguageSystem.cs index 4342161df54..c5a18f6761a 100644 --- a/Content.Server/Language/LanguageSystem.cs +++ b/Content.Server/Language/LanguageSystem.cs @@ -241,6 +241,23 @@ private int PseudoRandomNumber(int seed, int min, int max) return random % (max - min) + min; } + /// + /// Ensures the given entity has a valid language as its current language. + /// If not, sets it to the first entry of its SpokenLanguages list, or universal if it's empty. + /// + public void EnsureValidLanguage(EntityUid entity, LanguageSpeakerComponent? comp = null) + { + if (comp == null && !TryComp(entity, out comp)) + return; + + var langs = GetLanguages(entity, comp); + + if (langs != null && !langs.SpokenLanguages.Contains(comp!.CurrentLanguage, StringComparer.Ordinal)) + { + comp.CurrentLanguage = langs.SpokenLanguages.FirstOrDefault(Universal.ID); + } + } + /// /// Raised in order to determine the language an entity speaks at the current moment, /// as well as the list of all languages the entity may speak and understand. diff --git a/Content.Server/Language/TranslatorSystem.cs b/Content.Server/Language/TranslatorSystem.cs index 897a9172f48..36fd66591fe 100644 --- a/Content.Server/Language/TranslatorSystem.cs +++ b/Content.Server/Language/TranslatorSystem.cs @@ -1,7 +1,9 @@ using System.Linq; using Content.Server.Popups; +using Content.Server.PowerCell; using Content.Shared.Hands; using Content.Shared.Interaction; +using Content.Shared.Interaction.Events; using Content.Shared.Inventory; using Content.Shared.Inventory.Events; using Content.Shared.Language; @@ -14,16 +16,27 @@ namespace Content.Server.Language; public sealed class TranslatorSystem : EntitySystem { [Dependency] private readonly PopupSystem _popup = default!; + [Dependency] private readonly LanguageSystem _language = default!; + [Dependency] private readonly PowerCellSystem _powerCell = default!; + + private ISawmill _sawmill = default!; public override void Initialize() { + _sawmill = Logger.GetSawmill("translator"); + + // I wanna die. But my death won't help us discover polymorphism. SubscribeLocalEvent(ApplyTranslation); + SubscribeLocalEvent(ApplyTranslation); + // TODO: make this thing draw power + // SubscribeLocalEvent(...); - SubscribeLocalEvent(OnTranslatorToggle); - SubscribeLocalEvent(OnEquipTranslator); - SubscribeLocalEvent(OnHandEquipTranslator); - SubscribeLocalEvent(OnUnequipTranslator); - SubscribeLocalEvent(OnHandUnequipTranslator); + SubscribeLocalEvent(OnTranslatorToggle); + + SubscribeLocalEvent( + (uid, component, args) => TranslatorEquipped(args.User, uid, component)); + SubscribeLocalEvent( + (uid, component, args) => TranslatorUnequipped(args.User, uid, component)); } private void ApplyTranslation(EntityUid uid, IntrinsicTranslatorComponent component, @@ -32,45 +45,60 @@ private void ApplyTranslation(EntityUid uid, IntrinsicTranslatorComponent compon if (!component.Enabled) return; - foreach (var language in component.SpokenLanguages) + var addUnderstood = true; + var addSpoken = true; + if (component.RequiredLanguages.Count > 0) { - AddIfNotExists(ev.SpokenLanguages, language); + if (component.RequiresAllLanguages) + { + // Add langs when the wielder has all of the required languages + foreach (var language in component.RequiredLanguages) + { + if (!ev.SpokenLanguages.Contains(language, StringComparer.Ordinal)) + addSpoken = false; + + if (!ev.UnderstoodLanguages.Contains(language, StringComparer.Ordinal)) + addUnderstood = false; + } + } + else + { + // Add langs when the wielder has at least one of the required languages + addUnderstood = false; + addSpoken = false; + foreach (var language in component.RequiredLanguages) + { + if (ev.SpokenLanguages.Contains(language, StringComparer.Ordinal)) + addSpoken = true; + + if (ev.UnderstoodLanguages.Contains(language, StringComparer.Ordinal)) + addUnderstood = true; + } + } } - foreach (var language in component.UnderstoodLanguages) + if (addSpoken) { - AddIfNotExists(ev.UnderstoodLanguages, language); + foreach (var language in component.SpokenLanguages) + { + AddIfNotExists(ev.SpokenLanguages, language); + } + + if (component.CurrentSpeechLanguage != null && ev.CurrentLanguage.Length == 0) + { + ev.CurrentLanguage = component.CurrentSpeechLanguage; + } } - if (component.CurrentSpeechLanguage != null && ev.CurrentLanguage.Length == 0) + if (addUnderstood) { - ev.CurrentLanguage = component.CurrentSpeechLanguage; + foreach (var language in component.UnderstoodLanguages) + { + AddIfNotExists(ev.UnderstoodLanguages, language); + } } } - private void OnEquipTranslator(EntityUid uid, HandheldTranslatorComponent component, EquippedEventBase args) - { - // TODO: make this customizable? - if (args.SlotFlags.HasFlag(SlotFlags.POCKET) || args.SlotFlags.HasFlag(SlotFlags.NECK)) - return; - TranslatorEquipped(args.Equipee, uid, component); - } - - private void OnHandEquipTranslator(EntityUid uid, HandheldTranslatorComponent component, EquippedHandEvent args) - { - TranslatorEquipped(args.User, uid, component); - } - - private void OnUnequipTranslator(EntityUid uid, HandheldTranslatorComponent component, UnequippedEventBase args) - { - TranslatorUnequipped(args.Equipee, uid, component); - } - - private void OnHandUnequipTranslator(EntityUid uid, HandheldTranslatorComponent component, UnequippedHandEvent args) - { - TranslatorUnequipped(args.User, uid, component); - } - private void TranslatorEquipped(EntityUid holder, EntityUid translator, HandheldTranslatorComponent component) { if (!EntityManager.HasComponent(holder)) @@ -87,21 +115,26 @@ private void TranslatorUnequipped(EntityUid holder, EntityUid translator, Handhe if (intrinsic.Issuer == component) { + intrinsic.Enabled = false; EntityManager.RemoveComponent(holder, intrinsic); } + + _language.EnsureValidLanguage(holder); } - private void OnTranslatorToggle(EntityUid uid, HandheldTranslatorComponent component, InteractEvent args) + private void OnTranslatorToggle(EntityUid translator, HandheldTranslatorComponent component, ActivateInWorldEvent args) { if (!component.ToggleOnInteract) return; - if (Transform(uid).ParentUid is { Valid: true } holder && EntityManager.HasComponent(holder)) + var hasPower = _powerCell.HasActivatableCharge(translator, user: args.User); + + if (Transform(args.Target).ParentUid is { Valid: true } holder && EntityManager.HasComponent(holder)) { // This translator is held by a language speaker and thus has an intrinsic counterpart bound to it. Make sure it's up-to-date. var intrinsic = EntityManager.EnsureComponent(holder); - var isEnabled = component.Enabled; + var isEnabled = !component.Enabled; if (intrinsic.Issuer != component) { // The intrinsic comp wasn't owned by this handheld component, so this comp wasn't the active translator. @@ -110,17 +143,23 @@ private void OnTranslatorToggle(EntityUid uid, HandheldTranslatorComponent compo isEnabled = true; } + isEnabled &= hasPower; UpdateBoundIntrinsicComp(component, intrinsic, isEnabled); component.Enabled = isEnabled; } else { // This is a standalone translator (e.g. lying on the ground). Simply toggle its state. - component.Enabled = !component.Enabled; + component.Enabled = !component.Enabled && hasPower; } - var message = Loc.GetString(component.Enabled ? "translator-component-turnon" : "translator-component-shutoff"); - _popup.PopupEntity(message, component.Owner, args.User); + // HasPower shows a popup when there's no power, so we do not proceed in that case + if (hasPower) + { + var message = + Loc.GetString(component.Enabled ? "translator-component-turnon" : "translator-component-shutoff"); + _popup.PopupEntity(message, component.Owner, args.User); + } } /// @@ -142,6 +181,7 @@ private void UpdateBoundIntrinsicComp(HandheldTranslatorComponent comp, HoldsTra } intrinsic.Enabled = isEnabled; + intrinsic.Issuer = comp; } private static void AddIfNotExists(List list, string item) diff --git a/Content.Shared/Language/TranslatorComponent.cs b/Content.Shared/Language/TranslatorComponent.cs index 66f40b22992..31dc12b41f8 100644 --- a/Content.Shared/Language/TranslatorComponent.cs +++ b/Content.Shared/Language/TranslatorComponent.cs @@ -6,7 +6,7 @@ public abstract partial class BaseTranslatorComponent : Component /// The language this translator changes the speaker's language to when they don't specify one. /// If null, does not modify the default language. /// - [DataField("current-language")] + [DataField("default-language")] [ViewVariables(VVAccess.ReadWrite)] public string? CurrentSpeechLanguage = null; @@ -24,6 +24,24 @@ public abstract partial class BaseTranslatorComponent : Component [ViewVariables(VVAccess.ReadWrite)] public List UnderstoodLanguages = new(); + /// + /// The languages the wielding MUST know in order for this translator to have effect. + /// The field [RequiresAllLanguages] indicates whether all of them are required, or just one. + /// + [DataField("requires")] + [ViewVariables(VVAccess.ReadWrite)] + public List RequiredLanguages = new(); + + /// + /// If true, the wielder must understand all languages in [RequiredLanguages] to speak [SpokenLanguages], + /// and understand all languages in [RequiredLanguages] to understand [UnderstoodLanguages]. + /// + /// Otherwise, at least one language must be known (or the list must be empty). + /// + [DataField("requires-all")] + [ViewVariables(VVAccess.ReadWrite)] + public bool RequiresAllLanguages = false; + [DataField("enabled")] public bool Enabled = true; } @@ -40,14 +58,6 @@ public sealed partial class HandheldTranslatorComponent : BaseTranslatorComponen /// [DataField("toggleOnInteract")] public bool ToggleOnInteract = true; - - // TODO: not implemented - // /// - // /// Whether or not this translator requires a power cell to work. - // /// - // [DataField("requiresPower")] - // [ViewVariables(VVAccess.ReadWrite)] - // public bool RequiresPower = true; } /// diff --git a/Resources/Prototypes/Language/devices/base_translator.yml b/Resources/Prototypes/Language/devices/base_translator.yml new file mode 100644 index 00000000000..c25a815ea93 --- /dev/null +++ b/Resources/Prototypes/Language/devices/base_translator.yml @@ -0,0 +1,13 @@ +- type: entity + id: BaseTranslator + parent: [ BaseItem, PowerCellSlotMediumItem ] + name: Translator + description: "Translates speech." + components: +# - type: Sprite +# sprite: /Textures/Objects/Devices/vulp_translator.rsi +# layers: +# - state: icon + - type: PowerCellDraw + drawRate: 0 + useRate: 1 diff --git a/Resources/Prototypes/Language/devices/vulp_translator.yml b/Resources/Prototypes/Language/devices/vulp_translator.yml new file mode 100644 index 00000000000..569fc59e156 --- /dev/null +++ b/Resources/Prototypes/Language/devices/vulp_translator.yml @@ -0,0 +1,22 @@ +- type: entity + id: VulpTranslator + parent: [BaseTranslator] + name: Canilunzt translator + description: "Translates speech between Canilunzt and Galactic Common. Commonly used by Vulpkanin to communicate with galactic common speakers" + components: + - type: Sprite + sprite: /Textures/Objects/Devices/vulp_translator.rsi + layers: + - state: icon + - type: HandheldTranslator + default-language: GalacticCommon + speaks: + - GalacticCommon + - Canilunzt # To allow galactic common speakers to speak canilunzt + understands: + - GalacticCommon + - Canilunzt + requires: + - Canilunzt + - GalacticCommon + requires-all: false From 22c561a79f232fdbbd196e212ff9384125af2a5f Mon Sep 17 00:00:00 2001 From: FoxxoTrystan Date: Sat, 9 Dec 2023 06:55:08 +0100 Subject: [PATCH 29/91] Menu (WIP) --- .../LanguageMenuBoundUserInterface.cs | 39 ++++++++++++++++++ .../Language/LanguageMenuWindow.xaml | 5 +++ .../Language/LanguageMenuWindow.xaml.cs | 16 +++++++ Content.Server/Language/LanguageSystem.cs | 25 +++++++++-- .../Language/LanguageSpeakerComponent.cs | 17 ++++++++ .../Language/SharedLanguageSystem.cs | 14 +++++-- Resources/Prototypes/Actions/language.yml | 10 +++++ .../Textures/Interface/Actions/language.png | Bin 0 -> 681 bytes 8 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 Content.Client/Language/LanguageMenuBoundUserInterface.cs create mode 100644 Content.Client/Language/LanguageMenuWindow.xaml create mode 100644 Content.Client/Language/LanguageMenuWindow.xaml.cs create mode 100644 Resources/Prototypes/Actions/language.yml create mode 100644 Resources/Textures/Interface/Actions/language.png diff --git a/Content.Client/Language/LanguageMenuBoundUserInterface.cs b/Content.Client/Language/LanguageMenuBoundUserInterface.cs new file mode 100644 index 00000000000..31169164fdb --- /dev/null +++ b/Content.Client/Language/LanguageMenuBoundUserInterface.cs @@ -0,0 +1,39 @@ +using Content.Shared.Language; +using JetBrains.Annotations; +using Robust.Client.GameObjects; + +namespace Content.Client.Language +{ + [UsedImplicitly] + public sealed class LanguageMenuUserInterface : BoundUserInterface + { + private LanguageMenuWindow? _window; + + public LanguageMenuUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) + { + } + + protected override void Open() + { + base.Open(); + + _window = new LanguageMenuWindow(this); + _window.OnClose += Close; + _window.OpenCentered(); + } + protected override void UpdateState(BoundUserInterfaceState state) + { + base.UpdateState(state); + } + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + _window?.Dispose(); + } + } + } +} + diff --git a/Content.Client/Language/LanguageMenuWindow.xaml b/Content.Client/Language/LanguageMenuWindow.xaml new file mode 100644 index 00000000000..19f436b9fdc --- /dev/null +++ b/Content.Client/Language/LanguageMenuWindow.xaml @@ -0,0 +1,5 @@ + + + public int RandomRoundSeed { get; private set; } + [Dependency] private readonly UserInterfaceSystem _uiSystem = default!; + public override void Initialize() { base.Initialize(); SubscribeLocalEvent(OnInitLanguageSpeaker); SubscribeAllEvent(it => RandomRoundSeed = _random.Next()); + SubscribeLocalEvent(MenuEvent); + } + + private void MenuEvent(EntityUid uid, LanguageSpeakerComponent component, LanguageMenuActionEvent args) + { + if (!TryComp(uid, out ActorComponent? actor)) + return; + + _uiSystem.TryOpen(uid, LanguageMenuUiKey.Key, actor.PlayerSession); + + UpdateUserInterface(uid, component, args); + } + + private void UpdateUserInterface(EntityUid uid, LanguageSpeakerComponent component, EntityEventArgs args) + { + } private void OnInitLanguageSpeaker(EntityUid uid, LanguageSpeakerComponent component, ComponentInit args) @@ -168,7 +185,7 @@ public LanguagePrototype GetLanguage(EntityUid speaker, LanguageSpeakerComponent } // - // Set the CurrentLangauge of the given entity. + // Set the CurrentLanguage of the given entity. // public void SetLanguage(EntityUid speaker, string language, LanguageSpeakerComponent? languageComp = null) { diff --git a/Content.Shared/Language/LanguageSpeakerComponent.cs b/Content.Shared/Language/LanguageSpeakerComponent.cs index ec32e032858..13742ac3d71 100644 --- a/Content.Shared/Language/LanguageSpeakerComponent.cs +++ b/Content.Shared/Language/LanguageSpeakerComponent.cs @@ -1,3 +1,6 @@ +using Content.Shared.Actions; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List; @@ -27,4 +30,18 @@ public sealed partial class LanguageSpeakerComponent : Component [ViewVariables] [DataField("understands", customTypeSerializer: typeof(PrototypeIdListSerializer), required: true)] public List UnderstoodLanguages = new(); + + [ViewVariables(VVAccess.ReadWrite)] + [DataField("languageMenuAction", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string LanguageMenuAction = "ActionLanguageMenu"; + + [DataField] public EntityUid? Action; } + +[Serializable, NetSerializable] +public enum LanguageMenuUiKey +{ + Key +} + +public sealed partial class LanguageMenuActionEvent : InstantActionEvent { } diff --git a/Content.Shared/Language/SharedLanguageSystem.cs b/Content.Shared/Language/SharedLanguageSystem.cs index 9349a6e4ab9..2c32528319a 100644 --- a/Content.Shared/Language/SharedLanguageSystem.cs +++ b/Content.Shared/Language/SharedLanguageSystem.cs @@ -1,17 +1,16 @@ -using Content.Shared.Language; +using Content.Shared.Actions; using Robust.Shared.Prototypes; using Robust.Shared.Random; -using Robust.Shared.Serialization; namespace Content.Shared.Language; public abstract class SharedLanguageSystem : EntitySystem { + [Dependency] private readonly SharedActionsSystem _action = default!; private static LanguagePrototype? _galacticCommon; private static LanguagePrototype? _universal; public static LanguagePrototype GalacticCommon { get => _galacticCommon!; } public static LanguagePrototype Universal { get => _universal!; } - [Dependency] protected readonly IPrototypeManager _prototype = default!; [Dependency] protected readonly IRobustRandom _random = default!; protected ISawmill _sawmill = default!; @@ -23,6 +22,10 @@ public override void Initialize() _galacticCommon = gc; _universal = universal; _sawmill = Logger.GetSawmill("language"); + + base.Initialize(); + + SubscribeLocalEvent(OnInit); } public LanguagePrototype? GetLanguage(string id) @@ -30,4 +33,9 @@ public override void Initialize() _prototype.TryIndex(id, out var proto); return proto; } + + private void OnInit(EntityUid uid, LanguageSpeakerComponent component, MapInitEvent args) + { + _action.AddAction(uid, ref component.Action, component.LanguageMenuAction, uid); + } } diff --git a/Resources/Prototypes/Actions/language.yml b/Resources/Prototypes/Actions/language.yml new file mode 100644 index 00000000000..ffc7068396c --- /dev/null +++ b/Resources/Prototypes/Actions/language.yml @@ -0,0 +1,10 @@ +- type: entity + id: ActionLanguageMenu + name: Language Menu + description: Show the language menu. + noSpawn: true + components: + - type: InstantAction + icon: Interface/Actions/language.png + event: !type:LanguageMenuActionEvent + useDelay: 1 diff --git a/Resources/Textures/Interface/Actions/language.png b/Resources/Textures/Interface/Actions/language.png new file mode 100644 index 0000000000000000000000000000000000000000..4ad3b16d425f34fd89893b50596b17a78903bbcc GIT binary patch literal 681 zcmV;a0#^NrP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGU>i_@%>j5il*TMh*0zOGZK~z{r?Uu1? z6hRb+Z;gqK8fzQ1vQtB_P=gXoTETxMrJ{(~TBH)ONn-*Df`yGl6A%p;63vOC*ce2E zScn=FG??}Ky?M81c6Tm!hO2TPd^fxAy`7!A*_qo)hYs7G#M_KCO}l7@@htAgZrloM zxPlLGI!Tgs1xw-P$MGe$ix=4Mx2r>NAAVLvt9TbtDCxmIoKn5>346W7iY{CDKkzP^ zg7UF&m2uI-mUtdw1=evs9G2r<6d-lN4>O4M;#XYsxoh5zS~Q1pF-XtiH_Y}Mj+_1ko_Kr8+c|Gr;Uqrd7mN-xHh@1k z#~}C8YID+VSQ}77sJ)FdxRcW!9JfS-*Z`_N@7hMsAg9R(+V{8_Z*dTB;cJ}4#xUAE zqn_FT@rjVGfN~X?iS8vJM@#A(hHsoP@&Y6#77J P00000NkvXXu0mjfjBq%; literal 0 HcmV?d00001 From 73067956deb47d35a0dbbd6bcc6db7c74907d073 Mon Sep 17 00:00:00 2001 From: FoxxoTrystan Date: Sat, 9 Dec 2023 07:43:08 +0100 Subject: [PATCH 30/91] Translator File VulpTranslator should only be used by vulps and not the other way, but in the future we may add with "Science" more translator where its makes it possible, but the base thats vulps spawns should be kept only for them. --- .../Language/devices/base_translator.yml | 13 ----------- .../{vulp_translator.yml => translators.yml} | 21 ++++++++++++------ .../icon.png | Bin .../meta.json | 0 4 files changed, 14 insertions(+), 20 deletions(-) delete mode 100644 Resources/Prototypes/Language/devices/base_translator.yml rename Resources/Prototypes/Language/devices/{vulp_translator.yml => translators.yml} (59%) rename Resources/Textures/Objects/Devices/{vulp_translator.rsi => translator.rsi}/icon.png (100%) rename Resources/Textures/Objects/Devices/{vulp_translator.rsi => translator.rsi}/meta.json (100%) diff --git a/Resources/Prototypes/Language/devices/base_translator.yml b/Resources/Prototypes/Language/devices/base_translator.yml deleted file mode 100644 index c25a815ea93..00000000000 --- a/Resources/Prototypes/Language/devices/base_translator.yml +++ /dev/null @@ -1,13 +0,0 @@ -- type: entity - id: BaseTranslator - parent: [ BaseItem, PowerCellSlotMediumItem ] - name: Translator - description: "Translates speech." - components: -# - type: Sprite -# sprite: /Textures/Objects/Devices/vulp_translator.rsi -# layers: -# - state: icon - - type: PowerCellDraw - drawRate: 0 - useRate: 1 diff --git a/Resources/Prototypes/Language/devices/vulp_translator.yml b/Resources/Prototypes/Language/devices/translators.yml similarity index 59% rename from Resources/Prototypes/Language/devices/vulp_translator.yml rename to Resources/Prototypes/Language/devices/translators.yml index 569fc59e156..056a546b540 100644 --- a/Resources/Prototypes/Language/devices/vulp_translator.yml +++ b/Resources/Prototypes/Language/devices/translators.yml @@ -1,22 +1,29 @@ +- type: entity + id: BaseTranslator + parent: [ BaseItem, PowerCellSlotMediumItem ] + name: Translator + description: "Translates speech." + components: + - type: Sprite + sprite: /Textures/Objects/Devices/translator.rsi + layers: + - state: icon + - type: PowerCellDraw + drawRate: 0 + useRate: 1 + - type: entity id: VulpTranslator parent: [BaseTranslator] name: Canilunzt translator description: "Translates speech between Canilunzt and Galactic Common. Commonly used by Vulpkanin to communicate with galactic common speakers" components: - - type: Sprite - sprite: /Textures/Objects/Devices/vulp_translator.rsi - layers: - - state: icon - type: HandheldTranslator default-language: GalacticCommon speaks: - GalacticCommon - - Canilunzt # To allow galactic common speakers to speak canilunzt understands: - GalacticCommon - - Canilunzt requires: - Canilunzt - - GalacticCommon requires-all: false diff --git a/Resources/Textures/Objects/Devices/vulp_translator.rsi/icon.png b/Resources/Textures/Objects/Devices/translator.rsi/icon.png similarity index 100% rename from Resources/Textures/Objects/Devices/vulp_translator.rsi/icon.png rename to Resources/Textures/Objects/Devices/translator.rsi/icon.png diff --git a/Resources/Textures/Objects/Devices/vulp_translator.rsi/meta.json b/Resources/Textures/Objects/Devices/translator.rsi/meta.json similarity index 100% rename from Resources/Textures/Objects/Devices/vulp_translator.rsi/meta.json rename to Resources/Textures/Objects/Devices/translator.rsi/meta.json From 9a9a8425f77d73404d3fad9c35a7146449374f3b Mon Sep 17 00:00:00 2001 From: fox Date: Sat, 9 Dec 2023 17:45:54 +0300 Subject: [PATCH 31/91] Some template ui code for the language switcher --- .../LanguageMenuBoundUserInterface.cs | 51 +++++++------- .../Language/LanguageMenuWindow.xaml | 6 +- .../Language/LanguageMenuWindow.xaml.cs | 67 +++++++++++++++++-- .../Language/SharedLanguageSystem.cs | 12 ++++ 4 files changed, 105 insertions(+), 31 deletions(-) diff --git a/Content.Client/Language/LanguageMenuBoundUserInterface.cs b/Content.Client/Language/LanguageMenuBoundUserInterface.cs index 31169164fdb..15ca341a8d7 100644 --- a/Content.Client/Language/LanguageMenuBoundUserInterface.cs +++ b/Content.Client/Language/LanguageMenuBoundUserInterface.cs @@ -2,38 +2,41 @@ using JetBrains.Annotations; using Robust.Client.GameObjects; -namespace Content.Client.Language +namespace Content.Client.Language; + +[UsedImplicitly] +public sealed class LanguageMenuUserInterface : BoundUserInterface { - [UsedImplicitly] - public sealed class LanguageMenuUserInterface : BoundUserInterface + private LanguageMenuWindow? _window; + + public LanguageMenuUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) { - private LanguageMenuWindow? _window; + } - public LanguageMenuUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) - { - } + protected override void Open() + { + base.Open(); - protected override void Open() - { - base.Open(); + _window = new LanguageMenuWindow(this); + _window.OnClose += Close; + _window.OpenCentered(); + } + protected override void UpdateState(BoundUserInterfaceState state) + { + base.UpdateState(state); - _window = new LanguageMenuWindow(this); - _window.OnClose += Close; - _window.OpenCentered(); - } - protected override void UpdateState(BoundUserInterfaceState state) + if (state is SharedLanguageSystem.LanguageMenuState menuState) { - base.UpdateState(state); + _window?.UpdateState(menuState); } - protected override void Dispose(bool disposing) - { - base.Dispose(disposing); + } + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); - if (disposing) - { - _window?.Dispose(); - } + if (disposing) + { + _window?.Dispose(); } } } - diff --git a/Content.Client/Language/LanguageMenuWindow.xaml b/Content.Client/Language/LanguageMenuWindow.xaml index 19f436b9fdc..4d335774c38 100644 --- a/Content.Client/Language/LanguageMenuWindow.xaml +++ b/Content.Client/Language/LanguageMenuWindow.xaml @@ -1,5 +1,9 @@ -