diff --git a/Content.Server/Chat/Commands/SlangSanitizationCommand.cs b/Content.Server/Chat/Commands/SlangSanitizationCommand.cs new file mode 100644 index 0000000000..52dc04b9d2 --- /dev/null +++ b/Content.Server/Chat/Commands/SlangSanitizationCommand.cs @@ -0,0 +1,36 @@ +using Content.Server.Administration; +using Content.Server.Chat.Managers; +using Content.Shared.Administration; +using Content.Shared.CCVar; +using Content.Shared.White; +using Robust.Shared.Configuration; +using Robust.Shared.Console; + +namespace Content.Server.Chat.Commands; + +[AdminCommand(AdminFlags.Admin)] +public sealed class SlangSanitizationCommand : IConsoleCommand +{ + public string Command => "enableSlangSanitization"; + public string Description => "Toggles the slang sanitization."; + public string Help => "enableSlangSanitization "; + + [Dependency] private readonly IConfigurationManager _cfg = default!; + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length != 1 || !bool.TryParse(args[0], out bool value)) + { + shell.WriteError($"{args[0]} is not a valid boolean."); + return; + } + + _cfg.SetCVar(WhiteCVars.ChatSlangFilter, value); + + var announce = Loc.GetString("chatsan-announce-slang-sanitization", + ("admin", $"{shell.Player?.Name}"), + ("value", $"{value}")); + + IoCManager.Resolve().DispatchServerAnnouncement(announce, Color.Red); + } +} diff --git a/Content.Server/Chat/Managers/ChatSanitizationManager.cs b/Content.Server/Chat/Managers/ChatSanitizationManager.cs index 77119eefc0..4609219b2d 100644 --- a/Content.Server/Chat/Managers/ChatSanitizationManager.cs +++ b/Content.Server/Chat/Managers/ChatSanitizationManager.cs @@ -1,13 +1,20 @@ using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Text.Json; +using System.Text.RegularExpressions; using Content.Shared.CCVar; using Robust.Shared.Configuration; +using Robust.Shared.ContentPack; +using Robust.Shared.Utility; namespace Content.Server.Chat.Managers; public sealed class ChatSanitizationManager : IChatSanitizationManager { [Dependency] private readonly IConfigurationManager _configurationManager = default!; + [Dependency] private readonly IResourceManager _resources = default!; + + private Dictionary _slangToNormal = new(); private static readonly Dictionary SmileyToEmote = new() { @@ -49,6 +56,16 @@ public sealed class ChatSanitizationManager : IChatSanitizationManager { ":p", "chatsan-stick-out-tongue" }, { ":b", "chatsan-stick-out-tongue" }, { "0-0", "chatsan-wide-eyed" }, + //WD-EDIT + { "о-о", "chatsan-wide-eyed" }, // cyrillic о + { "о.о", "chatsan-wide-eyed" }, // cyrillic о + { "0_o", "chatsan-wide-eyed" }, + { "0_о", "chatsan-wide-eyed" }, // cyrillic о + { "о/", "chatsan-waves" }, // cyrillic о + { "лол", "chatsan-laughs" }, + { "о7", "chatsan-salutes" }, // cyrillic о + { "хд", "chatsan-laughs" }, + //WD-EDIT { "o-o", "chatsan-wide-eyed" }, { "o.o", "chatsan-wide-eyed" }, { "._.", "chatsan-surprised" }, @@ -79,6 +96,18 @@ public sealed class ChatSanitizationManager : IChatSanitizationManager public void Initialize() { _configurationManager.OnValueChanged(CCVars.ChatSanitizerEnabled, x => _doSanitize = x, true); + + //WD-EDIT + try + { + var filterData = _resources.ContentFileReadAllText(new ResPath("/White/ChatFilters/slang.json")); + _slangToNormal = JsonSerializer.Deserialize>(filterData)!; + } + catch (Exception e) + { + Logger.ErrorS("chat", "Failed to load slang.json: {0}", e); + } + //WD-EDIT } public bool TrySanitizeOutSmilies(string input, EntityUid speaker, out string sanitized, [NotNullWhen(true)] out string? emote) @@ -106,4 +135,16 @@ public sealed class ChatSanitizationManager : IChatSanitizationManager emote = null; return false; } + + //WD-EDIT + public string SanitizeOutSlang(string input) + { + var pattern = @"\b(?\w+)\b"; + + var newMessage = Regex.Replace(input, pattern , + match => _slangToNormal.ContainsKey(match.Groups[1].Value.ToLower()) ? _slangToNormal[match.Groups[1].Value.ToLower()] : match.Value, RegexOptions.IgnoreCase); + + return newMessage; + } + //WD-EDIT } diff --git a/Content.Server/Chat/Managers/IChatSanitizationManager.cs b/Content.Server/Chat/Managers/IChatSanitizationManager.cs index c067cf02ee..28e746be6b 100644 --- a/Content.Server/Chat/Managers/IChatSanitizationManager.cs +++ b/Content.Server/Chat/Managers/IChatSanitizationManager.cs @@ -7,4 +7,8 @@ public interface IChatSanitizationManager public void Initialize(); public bool TrySanitizeOutSmilies(string input, EntityUid speaker, out string sanitized, [NotNullWhen(true)] out string? emote); + + //WD-EDIT + public string SanitizeOutSlang(string input); + //WD-EDIT } diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs index 2ca1404bc5..2520f77f51 100644 --- a/Content.Server/Chat/Systems/ChatSystem.cs +++ b/Content.Server/Chat/Systems/ChatSystem.cs @@ -1,4 +1,3 @@ -using System.Globalization; using System.Linq; using System.Text; using Content.Server.Administration.Logs; @@ -19,6 +18,7 @@ using Content.Shared.Interaction; using Content.Shared.Mobs.Systems; using Content.Shared.Players; using Content.Shared.Radio; +using Content.Shared.White; using Robust.Server.Player; using Robust.Shared.Audio; using Robust.Shared.Audio.Systems; @@ -64,7 +64,7 @@ public sealed partial class ChatSystem : SharedChatSystem private bool _loocEnabled = true; private bool _deadLoocEnabled; private bool _critLoocEnabled; - private readonly bool _adminLoocEnabled = true; + private const bool AdminLoocEnabled = true; public override void Initialize() { @@ -217,13 +217,11 @@ public sealed partial class ChatSystem : SharedChatSystem message = message[1..]; } - bool shouldCapitalize = (desiredType != InGameICChatType.Emote); - bool shouldPunctuate = _configurationManager.GetCVar(CCVars.ChatPunctuation); - // Capitalizing the word I only happens in English, so we check language here - bool shouldCapitalizeTheWordI = (!CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Parent.Name == "en") - || (CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Name == "en"); + var shouldCapitalize = (desiredType != InGameICChatType.Emote); + var shouldPunctuate = _configurationManager.GetCVar(CCVars.ChatPunctuation); + var sanitizeSlang = _configurationManager.GetCVar(WhiteCVars.ChatSlangFilter); - message = SanitizeInGameICMessage(source, message, out var emoteStr, shouldCapitalize, shouldPunctuate, shouldCapitalizeTheWordI); + message = SanitizeInGameICMessage(source, message, out var emoteStr, shouldCapitalize, shouldPunctuate, sanitizeSlang); // Was there an emote in the message? If so, send it. if (player != null && emoteStr != message && emoteStr != null) @@ -418,7 +416,7 @@ public sealed partial class ChatSystem : SharedChatSystem // 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) @@ -431,11 +429,15 @@ public sealed partial class ChatSystem : SharedChatSystem else { if (name != Name(source)) + { _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Say from {ToPrettyString(source):user} as {name}, original: {originalMessage}, transformed: {message}."); + } else + { _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Say from {ToPrettyString(source):user}, original: {originalMessage}, transformed: {message}."); + } } } @@ -459,7 +461,7 @@ public sealed partial class ChatSystem : SharedChatSystem 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)); + var nameIdentity = FormattedMessage.EscapeText(nameOverride ?? Identity.Name(source, EntityManager)); // get the entity's name by voice (if no override provided). string name; if (nameOverride != null) @@ -486,47 +488,51 @@ public sealed partial class ChatSystem : SharedChatSystem 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 }) continue; - listener = session.AttachedEntity.Value; + var listener = session.AttachedEntity.Value; if (MessageRangeCheck(session, data, range) != MessageRangeCheckResult.Full) continue; // Won't get logged to chat, and ghosts are too far away to see the pop-up, so we just won't send it to them. if (data.Range <= WhisperClearRange) - _chatManager.ChatMessageToOne(ChatChannel.Whisper, message, wrappedMessage, source, false, session.ConnectedClient); + _chatManager.ChatMessageToOne(ChatChannel.Whisper, message, wrappedMessage, source, false, session.Channel); //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); + _chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedobfuscatedMessage, source, false, session.Channel); //If listener is too far and has no line of sight, they can't identify the whisperer's identity else - _chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedUnknownMessage, source, false, session.ConnectedClient); + _chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedUnknownMessage, source, false, session.Channel); } _replay.RecordServerMessage(new ChatMessage(ChatChannel.Whisper, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range))); var ev = new EntitySpokeEvent(source, message, channel, obfuscatedMessage); RaiseLocalEvent(source, ev, true); - if (!hideLog) - if (originalMessage == message) + if (hideLog) + return; + + if (originalMessage == message) + { + if (name != Name(source)) + _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user} as {name}: {originalMessage}."); + else + _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user}: {originalMessage}."); + } + else + { + if (name != Name(source)) { - if (name != Name(source)) - _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user} as {name}: {originalMessage}."); - else - _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user}: {originalMessage}."); + _adminLogger.Add(LogType.Chat, LogImpact.Low, + $"Whisper from {ToPrettyString(source):user} as {name}, original: {originalMessage}, transformed: {message}."); } else { - if (name != Name(source)) - _adminLogger.Add(LogType.Chat, LogImpact.Low, - $"Whisper from {ToPrettyString(source):user} as {name}, original: {originalMessage}, transformed: {message}."); - else - _adminLogger.Add(LogType.Chat, LogImpact.Low, + _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Whisper from {ToPrettyString(source):user}, original: {originalMessage}, transformed: {message}."); } + } } private void SendEntityEmote( @@ -545,7 +551,7 @@ public sealed partial class ChatSystem : SharedChatSystem // get the entity's apparent name (if no override provided). var ent = Identity.Entity(source, EntityManager); - string name = FormattedMessage.EscapeText(nameOverride ?? Name(ent)); + var name = FormattedMessage.EscapeText(nameOverride ?? Name(ent)); // Emotes use Identity.Name, since it doesn't actually involve your voice at all. var wrappedMessage = Loc.GetString("chat-manager-entity-me-wrap-message", @@ -556,11 +562,13 @@ public sealed partial class ChatSystem : SharedChatSystem if (checkEmote) TryEmoteChatInput(source, action); 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}"); - else - _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user}: {action}"); + if (hideLog) + return; + + if (name != Name(source)) + _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user} as {name}: {action}"); + else + _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user}: {action}"); } // ReSharper disable once InconsistentNaming @@ -570,9 +578,13 @@ public sealed partial class ChatSystem : SharedChatSystem if (_adminManager.IsAdmin(player)) { - if (!_adminLoocEnabled) return; + if (!AdminLoocEnabled) + return; + } + else if (!_loocEnabled) + { + return; } - else if (!_loocEnabled) return; // If crit player LOOC is disabled, don't send the message at all. if (!_critLoocEnabled && _mobStateSystem.IsCritical(source)) @@ -595,7 +607,7 @@ public sealed partial class ChatSystem : SharedChatSystem { wrappedMessage = Loc.GetString("chat-manager-send-admin-dead-chat-wrap-message", ("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")), - ("userName", player.ConnectedClient.UserName), + ("userName", player.Channel.UserName), ("message", FormattedMessage.EscapeText(message))); _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Admin dead chat from {player:Player}: {message}"); } @@ -704,15 +716,13 @@ public sealed partial class ChatSystem : SharedChatSystem } // ReSharper disable once InconsistentNaming - private string SanitizeInGameICMessage(EntityUid source, string message, out string? emoteStr, bool capitalize = true, bool punctuate = false, bool capitalizeTheWordI = true) + private string SanitizeInGameICMessage(EntityUid source, string message, out string? emoteStr, bool capitalize = true, bool punctuate = false, bool sanitizeSlang = true) { var newMessage = message.Trim(); - newMessage = SanitizeMessageReplaceWords(newMessage); - + if(sanitizeSlang) + newMessage = _sanitizer.SanitizeOutSlang(newMessage); if (capitalize) newMessage = SanitizeMessageCapital(newMessage); - if (capitalizeTheWordI) - newMessage = SanitizeMessageCapitalizeTheWordI(newMessage, "i"); if (punctuate) newMessage = SanitizeMessagePeriod(newMessage); @@ -768,15 +778,16 @@ public sealed partial class ChatSystem : SharedChatSystem } [ValidatePrototypeId] - public const string ChatSanitize_Accent = "chatsanitize"; + public const string ChatSanitizeAccent = "chatsanitize"; public string SanitizeMessageReplaceWords(string message) { - if (string.IsNullOrEmpty(message)) return message; + if (string.IsNullOrEmpty(message)) + return message; var msg = message; - msg = _wordreplacement.ApplyReplacements(msg, ChatSanitize_Accent); + msg = _wordreplacement.ApplyReplacements(msg, ChatSanitizeAccent); return msg; } @@ -823,9 +834,7 @@ public sealed partial class ChatSystem : SharedChatSystem return recipients; } - public readonly record struct ICChatRecipientData(float Range, bool Observer, bool? HideChatOverride = null) - { - } + public readonly record struct ICChatRecipientData(float Range, bool Observer, bool? HideChatOverride = null); private string ObfuscateMessageReadability(string message, float chance) { @@ -854,9 +863,7 @@ public sealed partial class ChatSystem : SharedChatSystem /// This event is raised before chat messages are sent out to clients. This enables some systems to send the chat /// messages to otherwise out-of view entities (e.g. for multiple viewports from cameras). /// -public record ExpandICChatRecipientstEvent(EntityUid Source, float VoiceRange, Dictionary Recipients) -{ -} +public record ExpandICChatRecipientstEvent(EntityUid Source, float VoiceRange, Dictionary Recipients); public sealed class TransformSpeakerNameEvent : EntityEventArgs { diff --git a/Content.Shared/White/WhiteCVars.cs b/Content.Shared/White/WhiteCVars.cs new file mode 100644 index 0000000000..e4f2497127 --- /dev/null +++ b/Content.Shared/White/WhiteCVars.cs @@ -0,0 +1,21 @@ +using Robust.Shared.Configuration; + +namespace Content.Shared.White; + +/* + * PUT YOUR CUSTOM VARS HERE + * DO IT OR I WILL KILL YOU + * with love, by Hail-Rakes + */ + + +[CVarDefs] +public sealed class WhiteCVars +{ + /* + * Slang + */ + + public static readonly CVarDef ChatSlangFilter = + CVarDef.Create("ic.slang_filter", true, CVar.SERVER | CVar.REPLICATED | CVar.ARCHIVE); +} diff --git a/Resources/Locale/ru-RU/escape-menu/ui/options-menu.ftl b/Resources/Locale/ru-RU/escape-menu/ui/options-menu.ftl index fd038181d0..7851462884 100644 --- a/Resources/Locale/ru-RU/escape-menu/ui/options-menu.ftl +++ b/Resources/Locale/ru-RU/escape-menu/ui/options-menu.ftl @@ -48,15 +48,12 @@ ui-options-scale-150 = 150% ui-options-scale-175 = 175% ui-options-scale-200 = 200% ui-options-hud-theme = Тема HUD: -ui-options-hud-theme-default = По умолчанию -ui-options-hud-theme-modernized = Устаревший -ui-options-hud-theme-classic = Стандартный ui-options-obsolete-interface-warning = ВНИМАНИЕ: Вы используете устаревшую версию интерфейса! ui-options-vp-stretch = Растянуть изображение для соответствия окну игры ui-options-vp-scale = Фиксированный масштаб окна игры: x{ $scale } ui-options-vp-integer-scaling = Предпочитать целочисленное масштабирование (может привести к появлению черных полос / отсечению) ui-options-vp-integer-scaling-tooltip = - Если эта опция включена, видовой экран будет масштабироваться с использованием + Если эта опция включена, видовой экран будет масштабироваться с использованием целочисленного значения в конкретных разрешениях экрана. Хотя в результате получается четкая текстура, также часто черные полосы могут появиться в верхней/нижней части экрана. diff --git a/Resources/Locale/ru-RU/nuke/nuke-component.ftl b/Resources/Locale/ru-RU/nuke/nuke-component.ftl index e33f0f00df..770b3fd5b0 100644 --- a/Resources/Locale/ru-RU/nuke/nuke-component.ftl +++ b/Resources/Locale/ru-RU/nuke/nuke-component.ftl @@ -32,6 +32,6 @@ nuke-label-syndicate = SYN-{ $serial } # Codes -nuke-codes-message = [color=red]СОВЕРШЕННО СЕКРЕТНО![/color] Код активации ядерной боеголовки: { $name } - { $code } +nuke-codes-message = [color=red]СОВЕРШЕННО СЕКРЕТНО![/color] nuke-codes-fax-paper-name = коды ядерной аутентификации nuke-codes-list = код от {$name}: {$code} diff --git a/Resources/White/ChatFilters/slang.json b/Resources/White/ChatFilters/slang.json new file mode 100644 index 0000000000..c2e1b3bbae --- /dev/null +++ b/Resources/White/ChatFilters/slang.json @@ -0,0 +1,139 @@ +{ + "срп": "стандартные рабочие процедуры", + "дек": "детектив", + "дэк": "детектив", + "инжинер": "инженер", + "инжи": "инженеры", + "мш": "имплант защиты разума", + "лкм": "левая рука", + "пкм": "правая рука", + "бан": "МОЯ ЖОПА ГОРИТ", + "нон рп": "МОЯ ЖОПА ГОРИТ", + "рдм": "МОЯ ЖОПА ГОРИТ", + "дм": "МОЯ ЖОПА ГОРИТ", + "фрикил": "МОЯ ЖОПА ГОРИТ", + "фрикилл": "МОЯ ЖОПА ГОРИТ", + "админ": "МОЯ ЖОПА ГОРИТ", + "админы": "МОЯ ЖОПА ГОРИТ", + "админов": "МОЯ ЖОПА ГОРИТ", + "хз": "я не знаю", + "амогус": "я аутист", + "го": "пошли", + "рофл": "смешно", + "ясн": "ясно", + "пон": "понял", + "всм": "всмысле", + "чзх": "что за херня?", + "чел": "мужик", + "нюк": "оперативник", + "збс": "заебись", + "нюка": "оперативник", + "поридж": "молодой", + "бб": "пока", + "быро": "быстро", + "непон": "не понял", + "заробастил": "победил", + "анробаст": "слабый", + "кек": "смешно", + "лол": "смешно", + "лмао": "смешно", + "КК": "ладно", + "чип и дейл": "космобурундуки", + "корвакс": "я аутист", + "пермач": "наказание", + "перм": "наказание", + "перма": "наказание", + "запермили": "наказали", + "запермят": "накажут", + "нрп": "я даун", + "нонрп": "я даун", + "дискорд": "я аутист", + "кринж": "стыд", + "нипон": "не понял", + "разлокать": "разблокировать", + "юзать": "использовать", + "юзай": "используй", + "юзнул": "использовал", + "кста": "кстати", + "нинада": "не надо", + "гг": "хорошо сработано", + "хилл": "лечение", + "подхиль": "полечи", + "хильни": "полечи", + "када": "когда", + "шо": "что", + "бебра": "форма жизни", + "изи": "легко", + "пруф": "доказательство", + "пруфани": "докажи", + "пруфанул": "доказал", + "чилить": "отдыхать", + "чилл": "отдых", + "чиллит": "отдыхает", + "чиллим": "отдыхаем", + "жиза": "жизненно", + "синди": "синдикат", + "топ": "круто", + "бургер кинг": "говно", + "ревенант": "что-то непонятное", + "нюкеры": "оперативники", + "нюкер": "оперативник", + "трейтор": "предатель", + "брух": "братан...", + "имба": "нечестно", + "кеп": "капитан", + "ку": "привет", + "хелп": "помоги", + "хелпани": "помоги", + "хелпанул": "помог", + "прив": "привет", + "срочник": "неумеха", + "райан гослинг": "знаменитый актер", + "вв": "высоковольтные", + "св": "средневольтные", + "нв": "низковольтные", + "мем": "смешно", + "аирлок": "шлюз", + "негр": "чернокожий", + "негры": "чернокожие", + "негра": "чернокожего", + "негров": "чернокожих", + "нигер": "чернокожий", + "нигеры": "чернокожие", + "нигера": "чернокожего", + "нигеров": "чернокожих", + "ниггер": "чернокожий", + "ниггеры": "чернокожие", + "ниггера": "чернокожего", + "ниггеров": "чернокожих", + "nigger": "чернокожий", + "niggers": "чернокожие", + "niger": "чернокожий", + "nigers": "чернокожие", + "пидор": "гей", + "пидоры": "геи", + "пидора": "гея", + "пидоров": "геев", + "пидорский": "гейский", + "пидар": "гей", + "пидары": "геи", + "пидара": "гея", + "пидаров": "геев", + "пидарский": "гейский", + "faggot": "гей", + "fagot": "гей", + "fag": "гей", + "дебил": "глупый", + "дебилы": "глупые", + "дебила": "глупого", + "дебилов": "глупых", + "дебильный": "глупый", + "петух": "цыпленок", + "петухи": "цыпленки", + "петуха": "цыпленка", + "петухов": "цыпленков", + "куколд": "олень", + "куколды": "олени", + "куколда": "оленя", + "куколдов": "оленей" +}