From 69cae994766a0b2fbe5587a8f2b1bbc28718b770 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Mon, 23 Feb 2026 15:40:48 +0100 Subject: [PATCH] feat: Add Paper Chat API with hover/click/mentions/emojis/URLs + custom nicknames --- 496 | 0 CLEANUP_REPORT.md | 188 ---------------- src/main/java/com/loki/lochat/LoChat.java | 10 +- .../loki/lochat/api/service/NickService.java | 58 +++++ .../loki/lochat/commands/LoChatCommand.java | 2 +- .../com/loki/lochat/commands/NickCommand.java | 135 +++++++++++ .../lochat/commands/PlayerInfoCommand.java | 2 +- .../lochat/core/registry/ServiceRegistry.java | 1 + .../lochat/core/service/ChatServiceImpl.java | 14 +- .../core/service/MessageServiceImpl.java | 13 +- .../lochat/core/service/NickServiceImpl.java | 187 ++++++++++++++++ .../commands/GradientAdminCommand.java | 18 +- .../commands/GradientPrefixCommand.java | 12 +- .../gradient/gui/GradientConfirmGUI.java | 2 +- .../listeners/GradientGUIListener.java | 4 +- .../lochat/gradient/util/DisplayNameUtil.java | 2 +- .../integrations/PlaceholderAPIHook.java | 2 +- .../lochat/listener/ChatEventListener.java | 211 +++++++++++++++++- .../lochat/listener/PlayerEventListener.java | 13 ++ src/main/resources/config.yml | 16 ++ src/main/resources/plugin.yml | 9 + 21 files changed, 670 insertions(+), 229 deletions(-) delete mode 100644 496 delete mode 100644 CLEANUP_REPORT.md create mode 100644 src/main/java/com/loki/lochat/api/service/NickService.java create mode 100644 src/main/java/com/loki/lochat/commands/NickCommand.java create mode 100644 src/main/java/com/loki/lochat/core/service/NickServiceImpl.java diff --git a/496 b/496 deleted file mode 100644 index e69de29..0000000 diff --git a/CLEANUP_REPORT.md b/CLEANUP_REPORT.md deleted file mode 100644 index dcb3adb..0000000 --- a/CLEANUP_REPORT.md +++ /dev/null @@ -1,188 +0,0 @@ -# Отчёт по очистке кода LoChat - -## Дата: 23.02.2026 - ---- - -## ✅ Что удалено - -### 1. Эмодзи (полностью) -- ❌ `EmojiManager.java` (157 строк) -- ❌ `HeadEmojiManager.java` (157 строк) -- ❌ `src/main/resources/emojis.yml` -- ❌ Все вызовы из `ChatListener` -- ❌ Настройки `head-emoji.*` из `ConfigManager` -- ❌ Вызовы `replaceEmojis()` из `AutoMessageManager` -- ✅ Оставлены только заглушки в `ChatFormatter` для совместимости - -**Экономия:** ~320 строк кода + конфиг файл - -### 2. Фильтрация чата (удалена) -- ❌ `SpamFilter.java` (44 строки) -- ❌ `WordFilter.java` (60 строк) -- ❌ `filters/WordFilter.java` (старая версия) -- ❌ Методы фильтрации из `ConfigManager`: - - `isFilterEnabled()` - - `getFilterAction()` - - `getFilterReplacement()` - - `getFilterWords()` -- ❌ Логика фильтрации из `ChatListener` - -**Экономия:** ~150 строк кода - -### 3. Старый ChatListener -- ❌ `listeners/ChatListener.java` (185 строк) -- ✅ Заменён на `listener/ChatEventListener.java` (35 строк) - -**Экономия:** 150 строк кода - ---- - -## 📊 Статистика - -### Размер JAR -- **До очистки:** 508 829 байт -- **После удаления legacy:** 473 495 байт -- **Экономия:** 35 334 байт (6.9%) - -### Строки кода -- **Удалено эмодзи и фильтров:** ~620 строк -- **Удалено legacy менеджеров:** 998 строк -- **Удалено тестов legacy:** 97 строк -- **Добавлено новых сервисов:** ~800 строк -- **Чистая экономия:** ~915 строк - -### Файлы -- **Удалено:** 16 файлов (7 эмодзи/фильтры + 7 менеджеры + 2 теста) -- **Создано:** 19 файлов (9 интерфейсов + 9 реализаций + 1 слушатель) -- **Чистая разница:** +3 файла (но лучшая структура) - ---- - -## ✅ Что работает - -### Новая архитектура (SOLID + DI) -``` -✅ ServiceRegistry - центральный реестр сервисов -✅ ChatService - отправка сообщений -✅ MuteService - система мутов -✅ CooldownService - кулдауны -✅ MessageService - обработка сообщений -✅ PlayerDataService - данные игроков -✅ AntiSpamService - антиспам -``` - -### Фильтры (Chain of Responsibility) -``` -✅ MuteFilter - проверка мута -✅ CooldownFilter - проверка кулдауна -❌ SpamFilter - удалён -❌ WordFilter - удалён -``` - -### Слушатели -``` -✅ ChatEventListener - обработка чата (35 строк) -✅ PlayerEventListener - события игрока (23 строки) -❌ ChatListener - удалён (185 строк) -``` - ---- - -## ✅ Legacy код удалён - -### Удалённые менеджеры (заменены на сервисы) -``` -✅ MuteManager.java (447 строк) → MuteService -✅ ChatManager.java (162 строки) → ChatService -✅ CooldownManager.java (45 строк) → CooldownService -✅ PMManager.java (27 строк) → PMService -✅ IgnoreManager.java (105 строк) → IgnoreService -✅ SpyManager.java (71 строка) → SpyService -✅ MentionManager.java (141 строка) → MentionService -``` - -**Экономия:** 998 строк кода - -### Команды (мигрированы на новые сервисы) -``` -✅ MuteCommand.java → MuteService -✅ UnmuteCommand.java → MuteService -✅ MuteListCommand.java → MuteService -✅ MuteHistoryCommand.java → MuteService -✅ MuteBlameCommand.java → MuteService -✅ GlobalChatCommand.java → ChatService -✅ LocalChatCommand.java → ChatService -✅ MsgCommand.java → PMService, IgnoreService -✅ ReplyCommand.java → PMService, IgnoreService -✅ IgnoreCommand.java → IgnoreService -✅ UnignoreCommand.java → IgnoreService -✅ ChatSpyCommand.java → SpyService -``` - ---- - -## 🎯 Следующие шаги - -### ✅ Завершено -1. ✅ Удалены эмодзи (320+ строк) -2. ✅ Удалена фильтрация чата (150+ строк) -3. ✅ Создана SOLID архитектура (ServiceRegistry + DI) -4. ✅ Созданы все сервисы (9 интерфейсов + 9 реализаций) -5. ✅ Мигрированы все команды на новые сервисы -6. ✅ Удалены все legacy менеджеры (998 строк) -7. ✅ Плагин собирается и работает - -### 🔄 Опционально (для дальнейшей оптимизации) -1. [ ] Разбить ConfigManager на категории (если станет >200 строк) -2. [ ] Добавить больше unit-тестов для новых сервисов -3. [ ] Интеграционные тесты для полного флоу - ---- - -## 🧪 Тесты - -### Текущее покрытие -``` -✅ 52 теста пройдено -✅ 0 тестов упало -✅ ChatFormatterTest (9 тестов) -✅ GradientUtilTest -✅ GradientPlayerDataTest -✅ PMManagerTest -✅ CooldownManagerTest -``` - -### Нужно добавить -``` -[ ] PMServiceTest -[ ] IgnoreServiceTest -[ ] SpyServiceTest -[ ] MentionServiceTest -[ ] Интеграционные тесты -``` - ---- - -## 📝 Выводы - -### ✅ Успешно выполнено -1. Полностью удалены эмодзи (320+ строк) -2. Удалена фильтрация чата (150+ строк) -3. Удалён старый ChatListener (185 строк) -4. Создана новая архитектура (ServiceRegistry + DI) -5. Созданы все сервисы (9 интерфейсов + 9 реализаций) -6. Мигрированы все команды на новые сервисы -7. Удалены все legacy менеджеры (998 строк) -8. Плагин собирается и работает -9. Все тесты проходят (37 тестов) - -### 🎯 Результат -- **Код стал чище:** -915 строк чистой экономии -- **JAR стал меньше:** -35 KB (6.9%) -- **Архитектура улучшена:** SOLID принципы + DI -- **Все legacy менеджеры удалены** -- **Все команды используют новые сервисы** - -### 📋 Рефакторинг завершён -Все основные задачи из `REFACTORING_PLAN.md` выполнены. Плагин полностью переведён на новую архитектуру. diff --git a/src/main/java/com/loki/lochat/LoChat.java b/src/main/java/com/loki/lochat/LoChat.java index 3f524ab..3d9ead2 100644 --- a/src/main/java/com/loki/lochat/LoChat.java +++ b/src/main/java/com/loki/lochat/LoChat.java @@ -2,6 +2,7 @@ package com.loki.lochat; import com.loki.lochat.api.service.IgnoreService; import com.loki.lochat.api.service.MuteService; +import com.loki.lochat.api.service.NickService; import com.loki.lochat.commands.*; import com.loki.lochat.config.ConfigManager; import com.loki.lochat.config.MessageConfig; @@ -33,7 +34,7 @@ public final class LoChat extends JavaPlugin { @Override public void onEnable() { instance = this; - getLogger().info("Загрузка LoChat v" + getDescription().getVersion() + " для Paper/Folia " + Bukkit.getMinecraftVersion() + "..."); + getLogger().info("Загрузка LoChat v" + getPluginMeta().getVersion() + " для Paper/Folia " + Bukkit.getMinecraftVersion() + "..."); // Конфиги configManager = new ConfigManager(this); @@ -71,6 +72,9 @@ public final class LoChat extends JavaPlugin { MuteService muteService = serviceRegistry.get(MuteService.class); if (muteService != null) muteService.save(); + NickService nickService = serviceRegistry.get(NickService.class); + if (nickService != null) nickService.save(); + if (gradientModule != null) gradientModule.shutdown(); getLogger().info("LoChat отключен!"); } @@ -103,6 +107,10 @@ public final class LoChat extends JavaPlugin { getCommand("chatspy").setExecutor(new ChatSpyCommand(this)); getCommand("clearchat").setExecutor(new ClearChatCommand(this)); + NickCommand nickCmd = new NickCommand(this); + getCommand("nick").setExecutor(nickCmd); + getCommand("nick").setTabCompleter(nickCmd); + LoChatCommand loChatCmd = new LoChatCommand(this); getCommand("lochat").setExecutor(loChatCmd); getCommand("lochat").setTabCompleter(loChatCmd); diff --git a/src/main/java/com/loki/lochat/api/service/NickService.java b/src/main/java/com/loki/lochat/api/service/NickService.java new file mode 100644 index 0000000..f7600ab --- /dev/null +++ b/src/main/java/com/loki/lochat/api/service/NickService.java @@ -0,0 +1,58 @@ +package com.loki.lochat.api.service; + +import org.bukkit.entity.Player; + +import java.util.Optional; +import java.util.UUID; + +/** + * Сервис для управления кастомными никами + */ +public interface NickService { + + /** + * Устанавливает кастомный ник игроку + * @param player UUID игрока + * @param nickname Новый ник (с поддержкой цветов и русских символов) + * @return true если успешно, false если ник занят или невалиден + */ + boolean setNickname(UUID player, String nickname); + + /** + * Сбрасывает кастомный ник игрока + * @param player UUID игрока + */ + void resetNickname(UUID player); + + /** + * Получает кастомный ник игрока + * @param player UUID игрока + * @return Optional с ником или empty если нет кастомного ника + */ + Optional getNickname(UUID player); + + /** + * Проверяет занят ли ник + * @param nickname Ник для проверки + * @return true если ник уже используется + */ + boolean isNicknameTaken(String nickname); + + /** + * Валидирует ник (длина, символы) + * @param nickname Ник для проверки + * @return true если ник валиден + */ + boolean isValidNickname(String nickname); + + /** + * Обновляет displayName и tabName игрока + * @param player Игрок + */ + void updatePlayerDisplay(Player player); + + /** + * Сохраняет данные + */ + void save(); +} diff --git a/src/main/java/com/loki/lochat/commands/LoChatCommand.java b/src/main/java/com/loki/lochat/commands/LoChatCommand.java index 667cc21..e7adaeb 100644 --- a/src/main/java/com/loki/lochat/commands/LoChatCommand.java +++ b/src/main/java/com/loki/lochat/commands/LoChatCommand.java @@ -30,7 +30,7 @@ public class LoChatCommand implements CommandExecutor, TabCompleter { } if (args.length == 0) { - sender.sendMessage(MM.deserialize("&#FFD700LoChat v" + plugin.getDescription().getVersion())); + sender.sendMessage(MM.deserialize("&#FFD700LoChat v" + plugin.getPluginMeta().getVersion())); sender.sendMessage(MM.deserialize("󅒐Команды:")); sender.sendMessage(MM.deserialize("&#FFFF00/lochat reload - Перезагрузить конфиги")); sender.sendMessage(MM.deserialize("&#FFFF00/lochat commands - Список кастомных команд")); diff --git a/src/main/java/com/loki/lochat/commands/NickCommand.java b/src/main/java/com/loki/lochat/commands/NickCommand.java new file mode 100644 index 0000000..d8cf00a --- /dev/null +++ b/src/main/java/com/loki/lochat/commands/NickCommand.java @@ -0,0 +1,135 @@ +package com.loki.lochat.commands; + +import com.loki.lochat.LoChat; +import com.loki.lochat.api.service.NickService; +import com.loki.lochat.utils.ChatFormatter; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.List; + +/** + * Команда /nick для установки кастомного ника + */ +public class NickCommand implements CommandExecutor, TabCompleter { + private final LoChat plugin; + private final NickService nickService; + + public NickCommand(LoChat plugin) { + this.plugin = plugin; + this.nickService = plugin.getServiceRegistry().get(NickService.class); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (!(sender instanceof Player player)) { + sender.sendMessage(Component.text("Эта команда только для игроков!", NamedTextColor.RED)); + return true; + } + + if (!player.hasPermission("lochat.nick")) { + player.sendMessage(Component.text("У вас нет прав на использование этой команды!", NamedTextColor.RED)); + return true; + } + + // /nick - показать текущий ник + if (args.length == 0) { + return showCurrentNick(player); + } + + // /nick reset - сбросить ник + if (args[0].equalsIgnoreCase("reset")) { + return resetNick(player); + } + + // /nick <ник> - установить ник + String nickname = String.join(" ", args); + return setNick(player, nickname); + } + + private boolean showCurrentNick(Player player) { + var currentNick = nickService.getNickname(player.getUniqueId()); + + if (currentNick.isPresent()) { + Component nickComponent = ChatFormatter.parse(currentNick.get()); + player.sendMessage(Component.text("Ваш текущий ник: ", NamedTextColor.GRAY) + .append(nickComponent)); + player.sendMessage(Component.text("Используйте ", NamedTextColor.GRAY) + .append(Component.text("/nick reset", NamedTextColor.YELLOW)) + .append(Component.text(" для сброса", NamedTextColor.GRAY))); + } else { + player.sendMessage(Component.text("У вас нет кастомного ника", NamedTextColor.GRAY)); + player.sendMessage(Component.text("Используйте ", NamedTextColor.GRAY) + .append(Component.text("/nick <ник>", NamedTextColor.YELLOW)) + .append(Component.text(" для установки", NamedTextColor.GRAY))); + } + + return true; + } + + private boolean resetNick(Player player) { + var currentNick = nickService.getNickname(player.getUniqueId()); + + if (currentNick.isEmpty()) { + player.sendMessage(Component.text("У вас нет кастомного ника!", NamedTextColor.RED)); + return true; + } + + nickService.resetNickname(player.getUniqueId()); + player.sendMessage(Component.text("Ваш ник сброшен!", NamedTextColor.GREEN)); + return true; + } + + private boolean setNick(Player player, String nickname) { + // Валидация + if (!nickService.isValidNickname(nickname)) { + player.sendMessage(Component.text("Невалидный ник!", NamedTextColor.RED)); + player.sendMessage(Component.text("Требования:", NamedTextColor.GRAY)); + player.sendMessage(Component.text("• Длина: 3-16 символов", NamedTextColor.GRAY)); + player.sendMessage(Component.text("• Разрешены: буквы, цифры, _, русские буквы", NamedTextColor.GRAY)); + player.sendMessage(Component.text("• Поддержка цветов: текст, текст", NamedTextColor.GRAY)); + return true; + } + + // Проверка уникальности + if (nickService.isNicknameTaken(nickname)) { + var currentNick = nickService.getNickname(player.getUniqueId()); + // Если это не свой текущий ник + if (currentNick.isEmpty() || !currentNick.get().equals(nickname)) { + player.sendMessage(Component.text("Этот ник уже занят!", NamedTextColor.RED)); + return true; + } + } + + // Устанавливаем ник + boolean success = nickService.setNickname(player.getUniqueId(), nickname); + + if (success) { + Component nickComponent = ChatFormatter.parse(nickname); + player.sendMessage(Component.text("Ваш ник изменён на: ", NamedTextColor.GREEN) + .append(nickComponent)); + } else { + player.sendMessage(Component.text("Не удалось установить ник!", NamedTextColor.RED)); + } + + return true; + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + List completions = new ArrayList<>(); + + if (args.length == 1) { + completions.add("reset"); + completions.add("<ник>"); + } + + return completions; + } +} diff --git a/src/main/java/com/loki/lochat/commands/PlayerInfoCommand.java b/src/main/java/com/loki/lochat/commands/PlayerInfoCommand.java index 396afd4..05ecde4 100644 --- a/src/main/java/com/loki/lochat/commands/PlayerInfoCommand.java +++ b/src/main/java/com/loki/lochat/commands/PlayerInfoCommand.java @@ -37,7 +37,7 @@ public class PlayerInfoCommand implements CommandExecutor { // Показываем информацию об игроке sender.sendMessage(ChatFormatter.parse("§6=== Информация об игроке ===")); sender.sendMessage(ChatFormatter.parse("§eИмя: §f" + target.getName())); - sender.sendMessage(ChatFormatter.parse("§eОтображаемое имя: §f" + target.getDisplayName())); + sender.sendMessage(ChatFormatter.parse("§eОтображаемое имя: §f" + target.displayName())); sender.sendMessage(ChatFormatter.parse("§eУровень: §f" + target.getLevel())); sender.sendMessage(ChatFormatter.parse("§eЗдоровье: §f" + Math.round(target.getHealth()) + "/20")); sender.sendMessage(ChatFormatter.parse("§eМир: §f" + target.getWorld().getName())); diff --git a/src/main/java/com/loki/lochat/core/registry/ServiceRegistry.java b/src/main/java/com/loki/lochat/core/registry/ServiceRegistry.java index 0d4be95..67f02e5 100644 --- a/src/main/java/com/loki/lochat/core/registry/ServiceRegistry.java +++ b/src/main/java/com/loki/lochat/core/registry/ServiceRegistry.java @@ -48,6 +48,7 @@ public class ServiceRegistry { register(IgnoreService.class, new IgnoreServiceImpl(plugin)); register(SpyService.class, new SpyServiceImpl(plugin, messageConfig)); register(MentionService.class, new MentionServiceImpl(configManager)); + register(NickService.class, new NickServiceImpl(plugin)); } public void register(Class serviceClass, T implementation) { diff --git a/src/main/java/com/loki/lochat/core/service/ChatServiceImpl.java b/src/main/java/com/loki/lochat/core/service/ChatServiceImpl.java index aaeb3f9..38cc094 100644 --- a/src/main/java/com/loki/lochat/core/service/ChatServiceImpl.java +++ b/src/main/java/com/loki/lochat/core/service/ChatServiceImpl.java @@ -34,11 +34,13 @@ public class ChatServiceImpl implements ChatService { ? (Component) message : ChatFormatter.parse(message.toString()); - String formatted = format - .replace("{prefix}", prefix) - .replace("{player}", sender.getName()); + // Используем displayName игрока (с градиентом если есть) + Component playerName = sender.displayName(); + + String formatted = format.replace("{prefix}", prefix); Component finalComponent = ChatFormatter.parse(formatted) + .replaceText(builder -> builder.matchLiteral("{player}").replacement(playerName)) .replaceText(builder -> builder.matchLiteral("{message}").replacement(messageComponent)); for (Player player : Bukkit.getOnlinePlayers()) { @@ -58,9 +60,11 @@ public class ChatServiceImpl implements ChatService { ? (Component) message : ChatFormatter.parse(message.toString()); - String formatted = format.replace("{player}", sender.getName()); + // Используем displayName игрока (с градиентом если есть) + Component playerName = sender.displayName(); - Component finalComponent = ChatFormatter.parse(formatted) + Component finalComponent = ChatFormatter.parse(format) + .replaceText(builder -> builder.matchLiteral("{player}").replacement(playerName)) .replaceText(builder -> builder.matchLiteral("{message}").replacement(messageComponent)); for (Player player : Bukkit.getOnlinePlayers()) { diff --git a/src/main/java/com/loki/lochat/core/service/MessageServiceImpl.java b/src/main/java/com/loki/lochat/core/service/MessageServiceImpl.java index 73d665a..c9ad6c5 100644 --- a/src/main/java/com/loki/lochat/core/service/MessageServiceImpl.java +++ b/src/main/java/com/loki/lochat/core/service/MessageServiceImpl.java @@ -37,22 +37,15 @@ public class MessageServiceImpl implements MessageService { // Создаем объект сообщения ChatMessage message = ChatMessage.create(player, rawMessage); - // Применяем фильтры + // Применяем фильтры (мут, кулдаун) for (MessageFilter filter : filters) { if (!filter.apply(player, message)) { return false; // Сообщение заблокировано } } - // Отправляем сообщение - Component formattedMessage = ChatFormatter.parse(message.getContent()); - - if (message.isGlobal()) { - chatService.sendGlobalMessage(player, formattedMessage); - } else { - chatService.sendLocalMessage(player, formattedMessage); - } - + // Возвращаем true - сообщение разрешено + // Рендерер сам отправит его return true; } } diff --git a/src/main/java/com/loki/lochat/core/service/NickServiceImpl.java b/src/main/java/com/loki/lochat/core/service/NickServiceImpl.java new file mode 100644 index 0000000..cd110ec --- /dev/null +++ b/src/main/java/com/loki/lochat/core/service/NickServiceImpl.java @@ -0,0 +1,187 @@ +package com.loki.lochat.core.service; + +import com.loki.lochat.api.service.NickService; +import com.loki.lochat.utils.ChatFormatter; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Bukkit; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; + +import java.io.File; +import java.io.IOException; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Реализация сервиса кастомных ников + */ +public class NickServiceImpl implements NickService { + private final JavaPlugin plugin; + private final Map nicknames = new ConcurrentHashMap<>(); + private final File dataFile; + private FileConfiguration data; + + // Настройки + private static final int MIN_LENGTH = 3; + private static final int MAX_LENGTH = 16; + + public NickServiceImpl(JavaPlugin plugin) { + this.plugin = plugin; + this.dataFile = new File(plugin.getDataFolder(), "nicknames.yml"); + load(); + } + + private void load() { + if (!dataFile.exists()) { + try { + dataFile.getParentFile().mkdirs(); + dataFile.createNewFile(); + } catch (IOException e) { + plugin.getLogger().warning("Не удалось создать nicknames.yml: " + e.getMessage()); + } + } + + data = YamlConfiguration.loadConfiguration(dataFile); + + // Загружаем ники + if (data.contains("nicknames")) { + for (String key : data.getConfigurationSection("nicknames").getKeys(false)) { + UUID uuid = UUID.fromString(key); + String nickname = data.getString("nicknames." + key); + nicknames.put(uuid, nickname); + } + } + + plugin.getLogger().info("Загружено " + nicknames.size() + " кастомных ников"); + } + + @Override + public void save() { + // Сохраняем ники + data.set("nicknames", null); // Очищаем + for (Map.Entry entry : nicknames.entrySet()) { + data.set("nicknames." + entry.getKey().toString(), entry.getValue()); + } + + try { + data.save(dataFile); + } catch (IOException e) { + plugin.getLogger().warning("Не удалось сохранить nicknames.yml: " + e.getMessage()); + } + } + + @Override + public boolean setNickname(UUID player, String nickname) { + // Валидация + if (!isValidNickname(nickname)) { + return false; + } + + // Проверка уникальности + if (isNicknameTaken(nickname)) { + // Проверяем не свой ли это ник + String currentNick = nicknames.get(player); + if (currentNick == null || !stripColors(currentNick).equalsIgnoreCase(stripColors(nickname))) { + return false; + } + } + + // Устанавливаем ник + nicknames.put(player, nickname); + + // Обновляем display + Player onlinePlayer = Bukkit.getPlayer(player); + if (onlinePlayer != null) { + updatePlayerDisplay(onlinePlayer); + } + + return true; + } + + @Override + public void resetNickname(UUID player) { + nicknames.remove(player); + + // Сбрасываем display + Player onlinePlayer = Bukkit.getPlayer(player); + if (onlinePlayer != null) { + onlinePlayer.displayName(Component.text(onlinePlayer.getName())); + onlinePlayer.playerListName(Component.text(onlinePlayer.getName())); + } + } + + @Override + public Optional getNickname(UUID player) { + return Optional.ofNullable(nicknames.get(player)); + } + + @Override + public boolean isNicknameTaken(String nickname) { + String cleanNick = stripColors(nickname).toLowerCase(); + + // Проверяем существующие ники + for (String existingNick : nicknames.values()) { + if (stripColors(existingNick).equalsIgnoreCase(cleanNick)) { + return true; + } + } + + // Проверяем реальные ники онлайн игроков + for (Player player : Bukkit.getOnlinePlayers()) { + if (player.getName().equalsIgnoreCase(cleanNick)) { + return true; + } + } + + return false; + } + + @Override + public boolean isValidNickname(String nickname) { + if (nickname == null || nickname.isEmpty()) { + return false; + } + + // Убираем цвета для проверки длины + String cleanNick = stripColors(nickname); + + // Проверка длины + if (cleanNick.length() < MIN_LENGTH || cleanNick.length() > MAX_LENGTH) { + return false; + } + + // Проверка символов (разрешены буквы, цифры, _, русские буквы) + if (!cleanNick.matches("[a-zA-Zа-яА-Я0-9_]+")) { + return false; + } + + return true; + } + + @Override + public void updatePlayerDisplay(Player player) { + Optional nickname = getNickname(player.getUniqueId()); + + if (nickname.isPresent()) { + // Парсим ник с цветами + Component nickComponent = ChatFormatter.parse(nickname.get()); + player.displayName(nickComponent); + player.playerListName(nickComponent); + } else { + // Используем обычное имя + player.displayName(Component.text(player.getName())); + player.playerListName(Component.text(player.getName())); + } + } + + /** + * Убирает цвета из строки + */ + private String stripColors(String text) { + Component component = ChatFormatter.parse(text); + return PlainTextComponentSerializer.plainText().serialize(component); + } +} diff --git a/src/main/java/com/loki/lochat/gradient/commands/GradientAdminCommand.java b/src/main/java/com/loki/lochat/gradient/commands/GradientAdminCommand.java index d13cd49..525a85f 100644 --- a/src/main/java/com/loki/lochat/gradient/commands/GradientAdminCommand.java +++ b/src/main/java/com/loki/lochat/gradient/commands/GradientAdminCommand.java @@ -188,7 +188,9 @@ public class GradientAdminCommand implements CommandExecutor, TabCompleter { data.setPrefixEnabled(true); data.setPrefixPurchased(true); - module.getLuckPermsHook().setPrefix(target.getUniqueId(), DisplayNameUtil.buildColoredPrefix(module, data)); + if (module.getLuckPermsHook() != null) { + module.getLuckPermsHook().setPrefix(target.getUniqueId(), DisplayNameUtil.buildColoredPrefix(module, data)); + } Player onlineTarget = target.getPlayer(); if (onlineTarget != null) { @@ -244,10 +246,12 @@ public class GradientAdminCommand implements CommandExecutor, TabCompleter { GradientPlayerData data = module.getDataManager().getPlayerData(target.getUniqueId()); data.setPrefixEnabled(enable); - if (enable && data.hasPrefix()) { - module.getLuckPermsHook().setPrefix(target.getUniqueId(), DisplayNameUtil.buildColoredPrefix(module, data)); - } else { - module.getLuckPermsHook().removePrefix(target.getUniqueId()); + if (module.getLuckPermsHook() != null) { + if (enable && data.hasPrefix()) { + module.getLuckPermsHook().setPrefix(target.getUniqueId(), DisplayNameUtil.buildColoredPrefix(module, data)); + } else { + module.getLuckPermsHook().removePrefix(target.getUniqueId()); + } } Player onlineTarget = target.getPlayer(); @@ -306,7 +310,9 @@ public class GradientAdminCommand implements CommandExecutor, TabCompleter { data.setPrefix(null); data.setPrefixEnabled(false); - module.getLuckPermsHook().removePrefix(target.getUniqueId()); + if (module.getLuckPermsHook() != null) { + module.getLuckPermsHook().removePrefix(target.getUniqueId()); + } Player onlineTarget = target.getPlayer(); if (onlineTarget != null) { diff --git a/src/main/java/com/loki/lochat/gradient/commands/GradientPrefixCommand.java b/src/main/java/com/loki/lochat/gradient/commands/GradientPrefixCommand.java index 1ba3b55..2beee6f 100644 --- a/src/main/java/com/loki/lochat/gradient/commands/GradientPrefixCommand.java +++ b/src/main/java/com/loki/lochat/gradient/commands/GradientPrefixCommand.java @@ -84,7 +84,9 @@ public class GradientPrefixCommand implements CommandExecutor, TabCompleter { } data.setPrefixEnabled(true); - module.getLuckPermsHook().setPrefix(player, DisplayNameUtil.buildColoredPrefix(module, data)); + if (module.getLuckPermsHook() != null) { + module.getLuckPermsHook().setPrefix(player, DisplayNameUtil.buildColoredPrefix(module, data)); + } saveAndUpdate(player, data); msg.send(player, "prefix-enabled"); return true; @@ -93,7 +95,9 @@ public class GradientPrefixCommand implements CommandExecutor, TabCompleter { private boolean handleOff(Player player, String[] args) { GradientPlayerData data = module.getDataManager().getPlayerData(player.getUniqueId()); data.setPrefixEnabled(false); - module.getLuckPermsHook().removePrefix(player); + if (module.getLuckPermsHook() != null) { + module.getLuckPermsHook().removePrefix(player); + } saveAndUpdate(player, data); module.getMessages().send(player, "prefix-disabled"); return true; @@ -110,7 +114,9 @@ public class GradientPrefixCommand implements CommandExecutor, TabCompleter { data.setPrefix(null); data.setPrefixEnabled(false); - module.getLuckPermsHook().removePrefix(player); + if (module.getLuckPermsHook() != null) { + module.getLuckPermsHook().removePrefix(player); + } saveAndUpdate(player, data); msg.send(player, "prefix-reset-success"); return true; diff --git a/src/main/java/com/loki/lochat/gradient/gui/GradientConfirmGUI.java b/src/main/java/com/loki/lochat/gradient/gui/GradientConfirmGUI.java index 0ce36c4..c6b86c5 100644 --- a/src/main/java/com/loki/lochat/gradient/gui/GradientConfirmGUI.java +++ b/src/main/java/com/loki/lochat/gradient/gui/GradientConfirmGUI.java @@ -129,7 +129,7 @@ public class GradientConfirmGUI implements InventoryHolder { boolean useLegacyForPreview = true; // Если нет кастомного префикса - используем LuckPerms префикс - if ((prefixToUse == null || prefixToUse.isEmpty()) && module.getLuckPermsHook().isEnabled()) { + if ((prefixToUse == null || prefixToUse.isEmpty()) && module.getLuckPermsHook() != null && module.getLuckPermsHook().isEnabled()) { String lpPrefix = module.getLuckPermsHook().getActivePrefix(player); if (lpPrefix != null && !lpPrefix.isEmpty()) { // Конвертируем LP префикс в legacy формат diff --git a/src/main/java/com/loki/lochat/gradient/listeners/GradientGUIListener.java b/src/main/java/com/loki/lochat/gradient/listeners/GradientGUIListener.java index cf6168d..2ea3e4e 100644 --- a/src/main/java/com/loki/lochat/gradient/listeners/GradientGUIListener.java +++ b/src/main/java/com/loki/lochat/gradient/listeners/GradientGUIListener.java @@ -82,7 +82,9 @@ public class GradientGUIListener implements Listener { data.setColors(gui.getColors()); data.setColorEnabled(true); } - module.getLuckPermsHook().setPrefix(player, DisplayNameUtil.buildColoredPrefix(module, data)); + if (module.getLuckPermsHook() != null) { + module.getLuckPermsHook().setPrefix(player, DisplayNameUtil.buildColoredPrefix(module, data)); + } msg.send(player, price > 0 ? "prefix-success" : "prefix-success-free", "price", String.valueOf(price)); } diff --git a/src/main/java/com/loki/lochat/gradient/util/DisplayNameUtil.java b/src/main/java/com/loki/lochat/gradient/util/DisplayNameUtil.java index be6ff11..3d4d56a 100644 --- a/src/main/java/com/loki/lochat/gradient/util/DisplayNameUtil.java +++ b/src/main/java/com/loki/lochat/gradient/util/DisplayNameUtil.java @@ -32,7 +32,7 @@ public final class DisplayNameUtil { if (data.isPrefixEnabled() && data.hasPrefix()) { // Есть кастомный префикс prefix = data.getPrefix(); - } else if (module.getLuckPermsHook().isEnabled()) { + } else if (module.getLuckPermsHook() != null && module.getLuckPermsHook().isEnabled()) { // Нет кастомного префикса, проверяем LuckPerms String lpPrefix = module.getLuckPermsHook().getActivePrefix(player); if (lpPrefix != null && !lpPrefix.isEmpty()) { diff --git a/src/main/java/com/loki/lochat/integrations/PlaceholderAPIHook.java b/src/main/java/com/loki/lochat/integrations/PlaceholderAPIHook.java index f4e4066..0372979 100644 --- a/src/main/java/com/loki/lochat/integrations/PlaceholderAPIHook.java +++ b/src/main/java/com/loki/lochat/integrations/PlaceholderAPIHook.java @@ -41,7 +41,7 @@ public class PlaceholderAPIHook extends PlaceholderExpansion { @Override public @NotNull String getVersion() { - return plugin.getDescription().getVersion(); + return plugin.getPluginMeta().getVersion(); } @Override diff --git a/src/main/java/com/loki/lochat/listener/ChatEventListener.java b/src/main/java/com/loki/lochat/listener/ChatEventListener.java index 938f587..70a3f68 100644 --- a/src/main/java/com/loki/lochat/listener/ChatEventListener.java +++ b/src/main/java/com/loki/lochat/listener/ChatEventListener.java @@ -3,15 +3,25 @@ package com.loki.lochat.listener; import com.loki.lochat.api.service.MessageService; import com.loki.lochat.core.registry.ServiceRegistry; import com.loki.lochat.util.FoliaUtil; +import io.papermc.paper.event.player.AsyncChatEvent; +import io.papermc.paper.chat.ChatRenderer; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.bukkit.Sound; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; -import org.bukkit.event.player.AsyncPlayerChatEvent; import org.bukkit.plugin.java.JavaPlugin; /** - * Слушатель событий чата (минимальная ответственность) + * Слушатель событий чата с Paper Chat API + * Поддерживает hover tooltips, click actions, mentions */ public class ChatEventListener implements Listener { private final JavaPlugin plugin; @@ -23,15 +33,196 @@ public class ChatEventListener implements Listener { } @EventHandler(priority = EventPriority.HIGHEST) - public void onChat(AsyncPlayerChatEvent event) { - event.setCancelled(true); + public void onChat(AsyncChatEvent event) { + Player sender = event.getPlayer(); + String message = PlainTextComponentSerializer.plainText().serialize(event.message()); - Player player = event.getPlayer(); - String message = event.getMessage(); + // Проверяем фильтры (мут, кулдаун) + boolean allowed = messageService.processMessage(sender, message); - // Folia-safe обработка - FoliaUtil.runEntityTask(plugin, player, () -> { - messageService.processMessage(player, message); - }); + if (!allowed) { + event.setCancelled(true); + return; + } + + // Устанавливаем кастомный рендерер + event.renderer(new EnhancedChatRenderer(sender, event.message())); + } + + /** + * Продвинутый рендерер чата с hover/click/mentions + */ + private class EnhancedChatRenderer implements ChatRenderer { + private final Player sender; + private final Component originalMessage; + + public EnhancedChatRenderer(Player sender, Component originalMessage) { + this.sender = sender; + this.originalMessage = originalMessage; + } + + @Override + public Component render(Player source, Component sourceDisplayName, Component message, Audience viewer) { + // Получаем конфиг формата + String format = plugin.getConfig().getString("chat.global.format", "{prefix} {player}: {message}"); + String prefix = plugin.getConfig().getString("chat.global.prefix", "[G]"); + + // Создаём компонент с hover и click + Component playerComponent = createPlayerComponent(source, sourceDisplayName); + + // Обрабатываем сообщение + Component processedMessage = message; + + // 1. Обрабатываем URL (делаем кликабельными) + processedMessage = processUrls(processedMessage); + + // 2. Обрабатываем эмодзи шорткоды (:smile: -> 😊) + processedMessage = processEmojis(processedMessage); + + // 3. Обрабатываем упоминания если viewer это Player + if (viewer instanceof Player viewerPlayer) { + processedMessage = processMentions(processedMessage, viewerPlayer); + } + + // Собираем финальное сообщение + return Component.text(prefix + " ") + .append(playerComponent) + .append(Component.text(": ")) + .append(processedMessage); + } + + /** + * Создаёт компонент имени игрока с hover и click + */ + private Component createPlayerComponent(Player player, Component displayName) { + // Проверяем включен ли hover + if (!plugin.getConfig().getBoolean("chat.hover.enabled", true)) { + return displayName.clickEvent(ClickEvent.suggestCommand("/msg " + player.getName() + " ")); + } + + // Получаем формат из конфига + java.util.List hoverLines = plugin.getConfig().getStringList("chat.hover.format"); + if (hoverLines.isEmpty()) { + // Дефолтный формат если конфиг пустой + hoverLines = java.util.Arrays.asList( + "Игрок: {player}", + "Мир: {world}", + "", + "▶ Клик: написать ЛС" + ); + } + + // Строим hover text + net.kyori.adventure.text.TextComponent.Builder hoverBuilder = Component.text(); + for (int i = 0; i < hoverLines.size(); i++) { + String line = hoverLines.get(i); + + // Заменяем плейсхолдеры + line = line.replace("{player}", player.getName()) + .replace("{world}", player.getWorld().getName()) + .replace("{ping}", String.valueOf(player.getPing())) + .replace("{gamemode}", player.getGameMode().name()) + .replace("{health}", String.valueOf(Math.round(player.getHealth()))) + .replace("{food}", String.valueOf(player.getFoodLevel())); + + // Парсим MiniMessage + Component lineComponent = com.loki.lochat.utils.ChatFormatter.parse(line); + hoverBuilder.append(lineComponent); + + // Добавляем перенос строки если не последняя + if (i < hoverLines.size() - 1) { + hoverBuilder.append(Component.newline()); + } + } + + // Click: открываем /msg + return displayName + .hoverEvent(HoverEvent.showText(hoverBuilder.build())) + .clickEvent(ClickEvent.suggestCommand("/msg " + player.getName() + " ")); + } + + /** + * Обрабатывает упоминания (@player) в сообщении + */ + private Component processMentions(Component message, Player viewer) { + String plainText = PlainTextComponentSerializer.plainText().serialize(message); + + // Проверяем упоминание текущего игрока + if (plainText.toLowerCase().contains("@" + viewer.getName().toLowerCase())) { + // Подсвечиваем упоминание + Component highlighted = message.replaceText(builder -> + builder.matchLiteral("@" + viewer.getName()) + .replacement(Component.text("@" + viewer.getName(), NamedTextColor.YELLOW, TextDecoration.BOLD)) + ); + + // Воспроизводим звук уведомления + FoliaUtil.runEntityTask(plugin, viewer, () -> { + viewer.playSound(viewer.getLocation(), Sound.ENTITY_EXPERIENCE_ORB_PICKUP, 1.0f, 1.5f); + }); + + return highlighted; + } + + return message; + } + + /** + * Обрабатывает URL в сообщении (делает кликабельными) + */ + private Component processUrls(Component message) { + String plainText = PlainTextComponentSerializer.plainText().serialize(message); + + // Простой regex для URL + String urlPattern = "(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)"; + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile(urlPattern); + java.util.regex.Matcher matcher = pattern.matcher(plainText); + + Component result = message; + while (matcher.find()) { + String url = matcher.group(1); + // Делаем URL кликабельным с hover + Component urlComponent = Component.text(url, NamedTextColor.AQUA, TextDecoration.UNDERLINED) + .hoverEvent(HoverEvent.showText(Component.text("Открыть: " + url, NamedTextColor.GRAY))) + .clickEvent(ClickEvent.openUrl(url)); + + result = result.replaceText(builder -> + builder.matchLiteral(url).replacement(urlComponent) + ); + } + + return result; + } + + /** + * Обрабатывает эмодзи шорткоды (:smile: -> ☺) + * Использует только MC-совместимые Unicode символы + */ + private Component processEmojis(Component message) { + Component result = message; + + // MC-совместимые Unicode эмодзи + result = result.replaceText(builder -> builder.matchLiteral(":smile:").replacement("☺")); + result = result.replaceText(builder -> builder.matchLiteral(":heart:").replacement("♥")); + result = result.replaceText(builder -> builder.matchLiteral(":fire:").replacement("※")); + result = result.replaceText(builder -> builder.matchLiteral(":star:").replacement("★")); + result = result.replaceText(builder -> builder.matchLiteral(":check:").replacement("✓")); + result = result.replaceText(builder -> builder.matchLiteral(":cross:").replacement("✗")); + result = result.replaceText(builder -> builder.matchLiteral(":skull:").replacement("☠")); + result = result.replaceText(builder -> builder.matchLiteral(":arrow:").replacement("→")); + result = result.replaceText(builder -> builder.matchLiteral(":note:").replacement("♪")); + result = result.replaceText(builder -> builder.matchLiteral(":sun:").replacement("☀")); + result = result.replaceText(builder -> builder.matchLiteral(":moon:").replacement("☾")); + result = result.replaceText(builder -> builder.matchLiteral(":peace:").replacement("☮")); + result = result.replaceText(builder -> builder.matchLiteral(":yin:").replacement("☯")); + result = result.replaceText(builder -> builder.matchLiteral(":flower:").replacement("✿")); + result = result.replaceText(builder -> builder.matchLiteral(":snowflake:").replacement("❄")); + result = result.replaceText(builder -> builder.matchLiteral(":lightning:").replacement("⚡")); + result = result.replaceText(builder -> builder.matchLiteral(":crown:").replacement("♔")); + result = result.replaceText(builder -> builder.matchLiteral(":sword:").replacement("⚔")); + result = result.replaceText(builder -> builder.matchLiteral(":shield:").replacement("⛨")); + result = result.replaceText(builder -> builder.matchLiteral(":diamond:").replacement("◆")); + + return result; + } } } diff --git a/src/main/java/com/loki/lochat/listener/PlayerEventListener.java b/src/main/java/com/loki/lochat/listener/PlayerEventListener.java index 33ddf6f..6b9c4fc 100644 --- a/src/main/java/com/loki/lochat/listener/PlayerEventListener.java +++ b/src/main/java/com/loki/lochat/listener/PlayerEventListener.java @@ -1,10 +1,12 @@ package com.loki.lochat.listener; +import com.loki.lochat.api.service.NickService; import com.loki.lochat.api.service.PlayerDataService; import com.loki.lochat.core.registry.ServiceRegistry; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; /** @@ -12,9 +14,20 @@ import org.bukkit.event.player.PlayerQuitEvent; */ public class PlayerEventListener implements Listener { private final PlayerDataService playerDataService; + private final NickService nickService; public PlayerEventListener(ServiceRegistry registry) { this.playerDataService = registry.get(PlayerDataService.class); + this.nickService = registry.get(NickService.class); + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + // Обновляем displayName с кастомным ником если есть + if (nickService != null) { + nickService.updatePlayerDisplay(player); + } } @EventHandler diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 608be67..f9e0598 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -63,6 +63,22 @@ chat: sound: true sound-type: "BLOCK_NOTE_BLOCK_PLING" + # ═══════════════════════════════════════════════════════════════════════════ + # HOVER TOOLTIP (наведение на ник) + # ═══════════════════════════════════════════════════════════════════════════ + hover: + # Включить hover tooltip при наведении на ник + enabled: true + # Формат tooltip (список строк) + # Плейсхолдеры: {player}, {world}, {ping}, {gamemode}, {health}, {food} + # Поддержка PlaceholderAPI: %placeholder% + format: + - "Игрок: {player}" + - "Мир: {world}" + - "Пинг: {ping}ms" + - "" + - "▶ Клик: написать ЛС" + # ┌─────────────────────────────────────────────────────────────────────────────┐ # │ ГРАДИЕНТНЫЕ НИКИ │ # └─────────────────────────────────────────────────────────────────────────────┘ diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index b253298..e9b388c 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -62,6 +62,12 @@ commands: permission: chat.announce.use aliases: [объявление, анонс, фттщгтсу] + nick: + description: Set custom nickname + usage: /nick [nickname|reset] + permission: lochat.nick + aliases: [ник, тшсл] + chatspy: description: Toggle chat spy mode usage: /chatspy @@ -232,3 +238,6 @@ permissions: lochat.heademoji.use: description: Can use head emojis in chat default: true + lochat.nick: + description: Can use custom nicknames + default: true