From a822c91637f9a647a391ef554a8dccdcdca7a6d9 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Sun, 9 Aug 2026 21:31:11 +0200 Subject: [PATCH] feat: chat channel system (/join /leave /channel) - config-driven channels in config/channels.yml (global, local, staff, trade) with emoji, colors, prefix, radius, cooldowns, permissions and aliases - Channel record + ChannelConfig loader wired into ConfigManager (reload supported) - ChannelService (api) + ConcurrentHashMap-backed ChannelServiceImpl, joined in ServiceRegistry; default channel join treated as 'not in channel' - JoinChannelCommand / LeaveChannelCommand / ChannelCommand (+ tab completion), registered in CommandManager; plugin.yml commands + permissions - ChatEventListener routes AsyncChatEvent to the active channel: per-channel cooldown, local-radius viewer filtering, channel-aware renderer - EnhancedChatRenderer supports Channel overload (emojiFor/prefixText/channelColor) - ChannelServiceImplTest covering join/leave/permissions/aliases --- TODO.md | 11 +- .../lochat/api/service/ChannelService.java | 40 ++++++ .../java/com/loki/lochat/channel/Channel.java | 33 +++++ .../loki/lochat/commands/CommandManager.java | 8 ++ .../lochat/commands/chat/ChannelCommand.java | 120 ++++++++++++++++++ .../commands/chat/JoinChannelCommand.java | 80 ++++++++++++ .../commands/chat/LeaveChannelCommand.java | 37 ++++++ .../com/loki/lochat/config/ChannelConfig.java | 99 +++++++++++++++ .../com/loki/lochat/config/ConfigManager.java | 8 ++ .../loki/lochat/core/PluginInitializer.java | 3 +- .../lochat/core/registry/ServiceRegistry.java | 4 + .../core/service/ChannelServiceImpl.java | 85 +++++++++++++ .../lochat/listener/ChatEventListener.java | 59 ++++++++- .../lochat/renderer/EnhancedChatRenderer.java | 45 ++++++- src/main/resources/config/channels.yml | 57 +++++++++ src/main/resources/plugin.yml | 33 +++++ .../core/service/ChannelServiceImplTest.java | 111 ++++++++++++++++ 17 files changed, 821 insertions(+), 12 deletions(-) create mode 100644 src/main/java/com/loki/lochat/api/service/ChannelService.java create mode 100644 src/main/java/com/loki/lochat/channel/Channel.java create mode 100644 src/main/java/com/loki/lochat/commands/chat/ChannelCommand.java create mode 100644 src/main/java/com/loki/lochat/commands/chat/JoinChannelCommand.java create mode 100644 src/main/java/com/loki/lochat/commands/chat/LeaveChannelCommand.java create mode 100644 src/main/java/com/loki/lochat/config/ChannelConfig.java create mode 100644 src/main/java/com/loki/lochat/core/service/ChannelServiceImpl.java create mode 100644 src/main/resources/config/channels.yml create mode 100644 src/test/java/com/loki/lochat/core/service/ChannelServiceImplTest.java diff --git a/TODO.md b/TODO.md index 72c19f4..a377982 100644 --- a/TODO.md +++ b/TODO.md @@ -66,11 +66,12 @@ ## 🟡 NEXT: NEW FEATURES -### 1. Channels (like VentureChat / Carbon) -- `/join`, `/leave`, `/channel` — full channel system: global, local, staff, trade, party. -- Per-channel colors, aliases, permissions, cooldowns, spy mode. -- Party chat: private groups `/party`. -- Network channels (Bungee/Velocity) sync. +### 1. Channels (like VentureChat / Carbon) ✅ +- `/join`, `/leave`, `/channel` — full channel system: global, local, staff, trade. +- Config-driven channels via `config/channels.yml` (emoji, colors, prefix, radius, cooldowns, permissions, aliases). +- Chat routing: `ChatEventListener` sends to the joined channel; local radius filtering; per-channel cooldown; channel-aware `EnhancedChatRenderer`. +- Party chat: private groups `/party` (future). +- Network channels (Bungee/Velocity) sync (future). ### 2. Anti-swear hardening + filter statistics ✅ - Obfuscation bypass: `SwearNormalizer` (q→р / leet-подстановки, повторы букв «сууука», разделители внутри слова «с.у.к.а» / «с у к а») + tolerant-маскирование в replace-режиме. Single source of truth для Aho-Corasick. diff --git a/src/main/java/com/loki/lochat/api/service/ChannelService.java b/src/main/java/com/loki/lochat/api/service/ChannelService.java new file mode 100644 index 0000000..cd9ae94 --- /dev/null +++ b/src/main/java/com/loki/lochat/api/service/ChannelService.java @@ -0,0 +1,40 @@ +package com.loki.lochat.api.service; + +import com.loki.lochat.channel.Channel; + +import org.bukkit.entity.Player; + +import java.util.Collection; +import java.util.Optional; + +/** + * Сервис каналов чата: состояние игроков (в каком канале они состоят). + */ +public interface ChannelService { + + boolean isEnabled(); + + Channel getDefault(); + + Collection getChannels(); + + Channel find(String nameOrAlias); + + /** + * Активный (не-дефолтный) канал игрока, если он его присоединил через /join. + */ + Optional getActive(Player player); + + boolean isInChannel(Player player); + + boolean canUse(Player player, Channel channel); + + /** + * Переводит игрока в канал. Дефолтный канал трактуется как «не присоединён». + * + * @return true если присоединение выполнено, false если нет канала или нет прав + */ + boolean join(Player player, Channel channel); + + void leave(Player player); +} \ No newline at end of file diff --git a/src/main/java/com/loki/lochat/channel/Channel.java b/src/main/java/com/loki/lochat/channel/Channel.java new file mode 100644 index 0000000..1b112a7 --- /dev/null +++ b/src/main/java/com/loki/lochat/channel/Channel.java @@ -0,0 +1,33 @@ +package com.loki.lochat.channel; + +import java.util.List; + +/** + * Канал чата (global, local, staff, trade...). + * Иммутабельное описание канала из config/channels.yml. + */ +public record Channel( + String id, + String display, + String prefix, + String color, + int radius, + int cooldown, + String permission, + List aliases) { + + /** radius < 1 — глобальный канал (видны всем игрокам) */ + public boolean isGlobalScope() { + return radius < 1; + } + + /** radius > 0 — локальный канал с радиусом действия */ + public boolean isLocalScope() { + return radius > 0; + } + + /** Есть ли требование по праву для входа/использования */ + public boolean hasPermission() { + return permission != null && !permission.isBlank(); + } +} \ No newline at end of file diff --git a/src/main/java/com/loki/lochat/commands/CommandManager.java b/src/main/java/com/loki/lochat/commands/CommandManager.java index cffed59..998bdfd 100644 --- a/src/main/java/com/loki/lochat/commands/CommandManager.java +++ b/src/main/java/com/loki/lochat/commands/CommandManager.java @@ -8,7 +8,10 @@ import com.loki.lochat.commands.admin.chat.ClearChatCommand; import com.loki.lochat.commands.admin.chat.ClearChatConfigCommand; import com.loki.lochat.commands.admin.system.LoChatCommand; import com.loki.lochat.commands.admin.system.ReloadConfigCommand; +import com.loki.lochat.commands.chat.ChannelCommand; import com.loki.lochat.commands.chat.GlobalChatCommand; +import com.loki.lochat.commands.chat.JoinChannelCommand; +import com.loki.lochat.commands.chat.LeaveChannelCommand; import com.loki.lochat.commands.chat.LocalChatCommand; import com.loki.lochat.commands.messaging.IgnoreCommand; import com.loki.lochat.commands.messaging.IgnoreListCommand; @@ -54,6 +57,11 @@ public class CommandManager { reg("g", new GlobalChatCommand(plugin)); reg("l", new LocalChatCommand(plugin)); + // ── Каналы ───────────────────────────────────────────────────────────── + regTab("join", new JoinChannelCommand(plugin)); + reg("leave", new LeaveChannelCommand(plugin)); + regTab("channel", new ChannelCommand(plugin)); + // ── Личные сообщения / игнор ────────────────────────────────────────── reg("msg", new MsgCommand(plugin)); reg("reply", new ReplyCommand(plugin)); diff --git a/src/main/java/com/loki/lochat/commands/chat/ChannelCommand.java b/src/main/java/com/loki/lochat/commands/chat/ChannelCommand.java new file mode 100644 index 0000000..90982d9 --- /dev/null +++ b/src/main/java/com/loki/lochat/commands/chat/ChannelCommand.java @@ -0,0 +1,120 @@ +package com.loki.lochat.commands.chat; + +import com.loki.lochat.LoChat; +import com.loki.lochat.api.service.ChannelService; +import com.loki.lochat.channel.Channel; +import com.loki.lochat.commands.base.PlayerCommand; +import com.loki.lochat.utils.format.ChatFormatter; + +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Locale; + +/** + * /channel — инфо о текущем канале со списком доступных. + * /channel <канал> — переключиться. /channel off — выйти. + * /channel list — список каналов. + */ +public class ChannelCommand extends PlayerCommand { + + public ChannelCommand(LoChat plugin) { + super(plugin); + } + + @Override + protected boolean executePlayerCommand(@NotNull Player player, @NotNull Command command, + @NotNull String label, @NotNull String[] args) { + ChannelService channels = plugin.getServiceRegistry().get(ChannelService.class); + if (!channels.isEnabled()) { + player.sendMessage(ChatFormatter.parse("&#CF6679Система каналов отключена")); + return true; + } + + if (args.length == 0) { + showCurrent(player, channels); + return true; + } + + switch (args[0].toLowerCase(Locale.ROOT)) { + case "off", "leave" -> { + if (channels.isInChannel(player)) { + channels.leave(player); + player.sendMessage(ChatFormatter.parse("⚖C9Вы вышли из канала")); + } else { + player.sendMessage(ChatFormatter.parse("&#B798A8Вы не находитесь в канале")); + } + } + case "list" -> listChannels(player, channels); + default -> joinChannel(player, channels, args[0]); + } + return true; + } + + private void showCurrent(Player player, ChannelService channels) { + Channel active = channels.getActive(player).orElse(null); + if (active == null) { + Channel def = channels.getDefault(); + player.sendMessage(ChatFormatter.parse("&#B798A8Вы в дефолтном канале [ẲE9" + + (def == null ? "?" : def.display() + "&8 " + def.prefix()) + "&#B798A8]")); + } else { + player.sendMessage(ChatFormatter.parse("⚖C9Вы в канале [ẲE9" + + active.display() + "&8 " + active.prefix() + "⚖C9]")); + } + player.sendMessage(ChatFormatter.parse("&#B798A8/join <канал> ⚖C9— войти, " + + "&#B798A8/channel list ⚖C9— все каналы")); + } + + private void listChannels(Player player, ChannelService channels) { + player.sendMessage(ChatFormatter.parse("&#B798A8Доступные каналы:")); + for (Channel c : channels.getChannels()) { + boolean canUse = channels.canUse(player, c); + boolean active = c.equals(channels.getActive(player).orElse(null)); + player.sendMessage(ChatFormatter.parse((canUse ? "⚖C9" : "&#CF6679") + + (active ? "\u25B8 " : "\u25AA ") + c.display() + "&8 " + c.prefix() + + (canUse ? "" : " &#CF6679(нет прав)") + + " &#B798A8— " + (c.isGlobalScope() ? "глобальный" : "локальный, " + c.radius() + "б"))); + } + } + + private void joinChannel(Player player, ChannelService channels, String name) { + Channel channel = channels.find(name); + if (channel == null) { + player.sendMessage(ChatFormatter.parse("&#CF6679Канал ẲE9" + name + " &#CF6679не найден")); + return; + } + if (!channels.canUse(player, channel)) { + player.sendMessage(ChatFormatter.parse( + plugin.getConfigManager().getMessagesConfig().getNoPermission())); + return; + } + Channel active = channels.getActive(player).orElse(null); + channels.join(player, channel); + if (channel.equals(active)) { + player.sendMessage(ChatFormatter.parse("&#B798A8Вы уже в канале [ẲE9" + + channel.display() + "&8 " + channel.prefix() + "&#B798A8]")); + } else { + player.sendMessage(ChatFormatter.parse("⚖C9Вы вошли в канал [ẲE9" + + channel.display() + "&8 " + channel.prefix() + "⚖C9]")); + } + } + + @Override + protected @Nullable List getTabCompletions(@NotNull CommandSender sender, @NotNull Command command, + @NotNull String alias, @NotNull String[] args) { + if (args.length != 1) { + return null; + } + ChannelService channels = plugin.getServiceRegistry().get(ChannelService.class); + String input = args[0].toLowerCase(Locale.ROOT); + return java.util.stream.Stream.concat( + java.util.stream.Stream.of("list", "off"), + channels.getChannels().stream().map(Channel::id)) + .filter(s -> s.startsWith(input)) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/loki/lochat/commands/chat/JoinChannelCommand.java b/src/main/java/com/loki/lochat/commands/chat/JoinChannelCommand.java new file mode 100644 index 0000000..bd2c3af --- /dev/null +++ b/src/main/java/com/loki/lochat/commands/chat/JoinChannelCommand.java @@ -0,0 +1,80 @@ +package com.loki.lochat.commands.chat; + +import com.loki.lochat.LoChat; +import com.loki.lochat.api.service.ChannelService; +import com.loki.lochat.channel.Channel; +import com.loki.lochat.commands.base.PlayerCommand; +import com.loki.lochat.utils.format.ChatFormatter; + +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; +import java.util.Locale; + +/** + * /join <канал|алиас> — войти в канал чата. + */ +public class JoinChannelCommand extends PlayerCommand { + + public JoinChannelCommand(LoChat plugin) { + super(plugin); + } + + @Override + protected boolean executePlayerCommand(@NotNull Player player, @NotNull Command command, + @NotNull String label, @NotNull String[] args) { + if (!plugin.getConfigManager().getChannelConfig().isEnabled()) { + player.sendMessage(ChatFormatter.parse("&#CF6679Система каналов отключена")); + return true; + } + if (args.length == 0) { + player.sendMessage(ChatFormatter.parse("&#CF6679Использование: ẲE9/join <канал>")); + return true; + } + + ChannelService channels = plugin.getServiceRegistry().get(ChannelService.class); + Channel channel = channels.find(args[0]); + if (channel == null) { + player.sendMessage(ChatFormatter.parse("&#CF6679Канал ẲE9" + args[0] + " &#CF6679не найден")); + return true; + } + if (!channels.canUse(player, channel)) { + player.sendMessage(ChatFormatter.parse( + plugin.getConfigManager().getMessagesConfig().getNoPermission())); + return true; + } + + Channel active = channels.getActive(player).orElse(null); + channels.join(player, channel); + if (channel.equals(channels.getDefault())) { + player.sendMessage(ChatFormatter.parse("⚖C9Вы в канале [ẲE9" + + channel.display() + "&8 " + channel.prefix() + "⚖C9] (дефолтный)")); + } else if (channel.equals(active)) { + player.sendMessage(ChatFormatter.parse("&#B798A8Вы уже в канале [ẲE9" + + channel.display() + "&8 " + channel.prefix() + "&#B798A8]")); + } else { + player.sendMessage(ChatFormatter.parse("⚖C9Вы вошли в канал [ẲE9" + + channel.display() + "&8 " + channel.prefix() + "⚖C9]")); + } + return true; + } + + @Override + protected @Nullable List getTabCompletions(@NotNull CommandSender sender, @NotNull Command command, + @NotNull String alias, @NotNull String[] args) { + if (args.length != 1) { + return null; + } + ChannelService channels = plugin.getServiceRegistry().get(ChannelService.class); + String input = args[0].toLowerCase(Locale.ROOT); + return channels.getChannels().stream() + .filter(c -> !(sender instanceof Player p) || channels.canUse(p, c)) + .map(Channel::id) + .filter(id -> id.toLowerCase(Locale.ROOT).startsWith(input)) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/loki/lochat/commands/chat/LeaveChannelCommand.java b/src/main/java/com/loki/lochat/commands/chat/LeaveChannelCommand.java new file mode 100644 index 0000000..83942f5 --- /dev/null +++ b/src/main/java/com/loki/lochat/commands/chat/LeaveChannelCommand.java @@ -0,0 +1,37 @@ +package com.loki.lochat.commands.chat; + +import com.loki.lochat.LoChat; +import com.loki.lochat.api.service.ChannelService; +import com.loki.lochat.commands.base.PlayerCommand; +import com.loki.lochat.utils.format.ChatFormatter; + +import org.bukkit.command.Command; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +/** + * /leave — выйти из текущего канала (вернуться к дефолтному чату). + */ +public class LeaveChannelCommand extends PlayerCommand { + + public LeaveChannelCommand(LoChat plugin) { + super(plugin); + } + + @Override + protected boolean executePlayerCommand(@NotNull Player player, @NotNull Command command, + @NotNull String label, @NotNull String[] args) { + ChannelService channels = plugin.getServiceRegistry().get(ChannelService.class); + if (!channels.isEnabled()) { + player.sendMessage(ChatFormatter.parse("&#CF6679Система каналов отключена")); + return true; + } + if (!channels.isInChannel(player)) { + player.sendMessage(ChatFormatter.parse("&#B798A8Вы не находитесь в канале")); + return true; + } + channels.leave(player); + player.sendMessage(ChatFormatter.parse("⚖C9Вы вышли из канала")); + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/loki/lochat/config/ChannelConfig.java b/src/main/java/com/loki/lochat/config/ChannelConfig.java new file mode 100644 index 0000000..682eee3 --- /dev/null +++ b/src/main/java/com/loki/lochat/config/ChannelConfig.java @@ -0,0 +1,99 @@ +package com.loki.lochat.config; + +import com.loki.lochat.channel.Channel; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.plugin.java.JavaPlugin; + +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Конфигурация каналов чата (config/channels.yml). + */ +public class ChannelConfig extends BaseConfig { + + private volatile Map channels = new LinkedHashMap<>(); + private volatile Map byAlias = new LinkedHashMap<>(); + private volatile Channel defaultChannel; + + public ChannelConfig(JavaPlugin plugin) { + super(plugin, "channels.yml", true); + } + + @Override + protected void onLoad() { + loadChannels(); + buildAliasIndex(); + } + + private void loadChannels() { + Map result = new LinkedHashMap<>(); + ConfigurationSection section = getConfig().getConfigurationSection("channels.list"); + if (section != null) { + for (String id : section.getKeys(false)) { + String path = "channels.list." + id; + result.put(id, new Channel( + id, + getConfig().getString(path + ".display", ""), + getConfig().getString(path + ".prefix", id.toUpperCase(Locale.ROOT)), + getConfig().getString(path + ".color", "&7"), + getConfig().getInt(path + ".radius", -1), + getConfig().getInt(path + ".cooldown", 0), + getConfig().getString(path + ".permission", ""), + getConfig().getStringList(path + ".aliases"))); + } + } + this.channels = result; + } + + private void buildAliasIndex() { + Map index = new LinkedHashMap<>(); + for (Channel channel : channels.values()) { + index.put(channel.id().toLowerCase(Locale.ROOT), channel); + for (String alias : channel.aliases()) { + if (alias == null || alias.isBlank()) { + continue; + } + index.putIfAbsent(alias.toLowerCase(Locale.ROOT), channel); + } + } + this.byAlias = index; + + String defaultId = getConfig().getString("channels.default", "global"); + this.defaultChannel = channels.get(defaultId); + if (defaultChannel == null) { + this.defaultChannel = channels.isEmpty() ? null : channels.values().iterator().next(); + } + } + + public boolean isEnabled() { + return getConfig().getBoolean("channels.enabled", true); + } + + public Channel getDefault() { + return defaultChannel; + } + + public Channel get(String id) { + return id == null ? null : channels.get(id.toLowerCase(Locale.ROOT)); + } + + /** + * Поиск канала по имени или алиасу (без учёта регистра). + */ + public Channel find(String name) { + return name == null ? null : byAlias.get(name.toLowerCase(Locale.ROOT)); + } + + public Collection getChannels() { + return channels.values(); + } + + public List getChannelList() { + return List.copyOf(channels.values()); + } +} \ No newline at end of file diff --git a/src/main/java/com/loki/lochat/config/ConfigManager.java b/src/main/java/com/loki/lochat/config/ConfigManager.java index b69083b..3454ecb 100644 --- a/src/main/java/com/loki/lochat/config/ConfigManager.java +++ b/src/main/java/com/loki/lochat/config/ConfigManager.java @@ -21,6 +21,7 @@ public class ConfigManager { private volatile MuteConfig muteConfig; private volatile FiltersConfig filtersConfig; private volatile SoundsConfig soundsConfig; + private volatile ChannelConfig channelConfig; public ConfigManager(LoChat plugin) { this.plugin = plugin; @@ -33,6 +34,7 @@ public class ConfigManager { this.muteConfig = new MuteConfig(plugin); this.filtersConfig = new FiltersConfig(plugin); this.soundsConfig = new SoundsConfig(plugin); + this.channelConfig = new ChannelConfig(plugin); // Инициализируем все конфиги this.appearanceConfig.init(); @@ -40,6 +42,7 @@ public class ConfigManager { this.muteConfig.init(); this.filtersConfig.init(); this.soundsConfig.init(); + this.channelConfig.init(); loader.updateConfig(); } @@ -66,6 +69,10 @@ public class ConfigManager { return soundsConfig; } + public ChannelConfig getChannelConfig() { + return channelConfig; + } + // ========== Обновление и перезагрузка ========== public synchronized void reload() { @@ -77,6 +84,7 @@ public class ConfigManager { this.muteConfig.reload(); this.filtersConfig.reload(); this.soundsConfig.reload(); + this.channelConfig.reload(); plugin.getLogger().info("Все конфигурации успешно перезагружены"); } diff --git a/src/main/java/com/loki/lochat/core/PluginInitializer.java b/src/main/java/com/loki/lochat/core/PluginInitializer.java index 0d24852..81d275f 100644 --- a/src/main/java/com/loki/lochat/core/PluginInitializer.java +++ b/src/main/java/com/loki/lochat/core/PluginInitializer.java @@ -109,7 +109,8 @@ public class PluginInitializer { plugin.setChatFilter(filter); plugin.getServer().getPluginManager().registerEvents( - new ChatEventListener(plugin, messageService, playerService, filter), plugin + new ChatEventListener(plugin, messageService, playerService, filter, + serviceRegistry.get(com.loki.lochat.api.service.ChannelService.class)), plugin ); plugin.getServer().getPluginManager().registerEvents( new PlayerEventListener(plugin, playerService, messagingService, nickService, filter), plugin 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 882a8c3..7f3ca47 100644 --- a/src/main/java/com/loki/lochat/core/registry/ServiceRegistry.java +++ b/src/main/java/com/loki/lochat/core/registry/ServiceRegistry.java @@ -1,6 +1,7 @@ package com.loki.lochat.core.registry; import com.loki.lochat.LoChat; +import com.loki.lochat.api.service.ChannelService; import com.loki.lochat.api.service.ChatService; import com.loki.lochat.api.service.MentionService; import com.loki.lochat.api.service.MessageService; @@ -14,6 +15,7 @@ import com.loki.lochat.config.ConfigManager; import com.loki.lochat.config.MessageConfig; import com.loki.lochat.core.factory.ServiceFactory; import com.loki.lochat.core.service.ChatServiceImpl; +import com.loki.lochat.core.service.ChannelServiceImpl; import com.loki.lochat.core.service.MentionServiceImpl; import com.loki.lochat.core.service.MessageServiceImpl; import com.loki.lochat.core.service.NickServiceImpl; @@ -47,6 +49,8 @@ public class ServiceRegistry { PlayerService playerService = new PlayerServiceImpl(plugin); register(PlayerService.class, playerService); + register(ChannelService.class, new ChannelServiceImpl(configManager.getChannelConfig())); + MessageService messageService = new MessageServiceImpl(plugin, muteService, playerService); register(MessageService.class, messageService); diff --git a/src/main/java/com/loki/lochat/core/service/ChannelServiceImpl.java b/src/main/java/com/loki/lochat/core/service/ChannelServiceImpl.java new file mode 100644 index 0000000..5dec150 --- /dev/null +++ b/src/main/java/com/loki/lochat/core/service/ChannelServiceImpl.java @@ -0,0 +1,85 @@ +package com.loki.lochat.core.service; + +import com.loki.lochat.api.service.ChannelService; +import com.loki.lochat.channel.Channel; +import com.loki.lochat.config.ChannelConfig; + +import org.bukkit.entity.Player; + +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Реализация сервиса каналов чата. + * Хранит активный (присоединённый) канал каждого игрока в памяти. + */ +public class ChannelServiceImpl implements ChannelService { + + private final ChannelConfig config; + private final Map activeChannels = new ConcurrentHashMap<>(); + + public ChannelServiceImpl(ChannelConfig config) { + this.config = config; + } + + @Override + public boolean isEnabled() { + return config.isEnabled(); + } + + @Override + public Channel getDefault() { + return config.getDefault(); + } + + @Override + public Collection getChannels() { + return config.getChannels(); + } + + @Override + public Channel find(String nameOrAlias) { + return config.find(nameOrAlias); + } + + @Override + public Optional getActive(Player player) { + String id = activeChannels.get(player.getUniqueId()); + if (id == null) { + return Optional.empty(); + } + return Optional.ofNullable(config.get(id)); + } + + @Override + public boolean isInChannel(Player player) { + return activeChannels.containsKey(player.getUniqueId()); + } + + @Override + public boolean canUse(Player player, Channel channel) { + return !channel.hasPermission() || player.hasPermission(channel.permission()); + } + + @Override + public boolean join(Player player, Channel channel) { + if (channel == null || !canUse(player, channel)) { + return false; + } + Channel defaultChannel = config.getDefault(); + if (defaultChannel != null && channel.id().equals(defaultChannel.id())) { + activeChannels.remove(player.getUniqueId()); + return true; + } + activeChannels.put(player.getUniqueId(), channel.id()); + return true; + } + + @Override + public void leave(Player player) { + activeChannels.remove(player.getUniqueId()); + } +} \ No newline at end of file diff --git a/src/main/java/com/loki/lochat/listener/ChatEventListener.java b/src/main/java/com/loki/lochat/listener/ChatEventListener.java index 23e4483..95df000 100644 --- a/src/main/java/com/loki/lochat/listener/ChatEventListener.java +++ b/src/main/java/com/loki/lochat/listener/ChatEventListener.java @@ -1,8 +1,10 @@ package com.loki.lochat.listener; import com.loki.lochat.LoChat; +import com.loki.lochat.api.service.ChannelService; import com.loki.lochat.api.service.MessageService; import com.loki.lochat.api.service.PlayerService; +import com.loki.lochat.channel.Channel; import com.loki.lochat.core.filter.AdvancedMessageFilter; import com.loki.lochat.core.filter.FilterResult; import com.loki.lochat.renderer.EnhancedChatRenderer; @@ -24,13 +26,15 @@ public class ChatEventListener implements Listener { private final MessageService messageService; private final PlayerService playerService; private final AdvancedMessageFilter advancedFilter; + private final ChannelService channelService; public ChatEventListener(JavaPlugin plugin, MessageService messageService, PlayerService playerService, - AdvancedMessageFilter filter) { + AdvancedMessageFilter filter, ChannelService channelService) { this.plugin = plugin; this.messageService = messageService; this.playerService = playerService; this.advancedFilter = filter; + this.channelService = channelService; } @EventHandler(priority = EventPriority.HIGHEST) @@ -39,6 +43,13 @@ public class ChatEventListener implements Listener { String rawMessage = PlainTextComponentSerializer.plainText().serialize(event.message()); LoChat loChat = (LoChat) plugin; + // Система каналов: если игрок в присоединённом канале — маршрутизируем туда + Channel active = channelService.isEnabled() ? channelService.getActive(sender).orElse(null) : null; + if (active != null) { + handleChannelChat(event, sender, active, rawMessage); + return; + } + // Определяем режим чата (глобальный/локальный) ChatMode mode = determineChatMode(loChat, rawMessage); if (mode == null) { @@ -66,6 +77,52 @@ public class ChatEventListener implements Listener { recordStatistics(loChat, sender, isGlobal); } + /** + * Маршрутизация сообщения в присоединённый (не-дефолтный) канал. + */ + private void handleChannelChat(AsyncChatEvent event, Player sender, Channel channel, String rawMessage) { + String plainMessage = rawMessage.stripLeading(); + + // Кулдаун канала + int cooldown = channel.cooldown(); + if (cooldown > 0 && !sender.hasPermission("chat.bypass.cooldown")) { + String cooldownType = "channel_" + channel.id(); + if (playerService.isOnCooldown(sender.getUniqueId(), cooldownType, cooldown)) { + int remaining = playerService.getRemainingCooldown(sender.getUniqueId(), cooldownType, cooldown); + String msg = ((LoChat) plugin).getConfigManager().getMessagesConfig().getCooldownMessage() + .replace("{remaining}", String.valueOf(remaining)); + event.setCancelled(true); + sender.sendMessage(ChatFormatter.parse(msg)); + return; + } + playerService.setCooldown(sender.getUniqueId(), cooldownType); + } + + if (!processFilters(event, sender, plainMessage, channel.isGlobalScope())) { + return; + } + + // Радиус локального канала + if (channel.isLocalScope()) { + int radius = channel.radius(); + event.viewers().removeIf(v -> { + if (!(v instanceof Player p)) { + return false; + } + try { + return !com.loki.lochat.utils.player.DistanceUtil.isInRange(sender, p, radius); + } catch (Exception e) { + return false; + } + }); + } + + event.renderer(new EnhancedChatRenderer(plugin, channel)); + LoChat loChat = (LoChat) plugin; + loChat.getDiscordIntegration().sendChatMessage(sender, plainMessage, channel.isGlobalScope()); + playerService.recordMessage(sender.getUniqueId(), "channel_" + channel.id()); + } + private ChatMode determineChatMode(LoChat loChat, String rawMessage) { boolean localEnabled = loChat.getConfigManager().isLocalEnabled(); boolean isGlobal; diff --git a/src/main/java/com/loki/lochat/renderer/EnhancedChatRenderer.java b/src/main/java/com/loki/lochat/renderer/EnhancedChatRenderer.java index 9dd0ce2..bd2b2df 100644 --- a/src/main/java/com/loki/lochat/renderer/EnhancedChatRenderer.java +++ b/src/main/java/com/loki/lochat/renderer/EnhancedChatRenderer.java @@ -1,9 +1,11 @@ package com.loki.lochat.renderer; +import com.loki.lochat.channel.Channel; import com.loki.lochat.renderer.components.EmojiComponent; import com.loki.lochat.renderer.components.GradientBuilder; import com.loki.lochat.renderer.components.UrlProcessor; import com.loki.lochat.translate.TranslationService; +import com.loki.lochat.utils.format.ChatFormatter; import com.loki.lochat.utils.format.TextFormatter; import com.loki.lochat.utils.player.MentionHandler; @@ -33,12 +35,22 @@ public class EnhancedChatRenderer implements ChatRenderer { private final JavaPlugin plugin; private final boolean isGlobal; + private final Channel channel; private final MentionHandler mentionHandler; private final com.loki.lochat.config.ConfigManager cfg; public EnhancedChatRenderer(JavaPlugin plugin, boolean isGlobal) { + this(plugin, isGlobal, null); + } + + public EnhancedChatRenderer(JavaPlugin plugin, Channel channel) { + this(plugin, channel.isGlobalScope(), channel); + } + + private EnhancedChatRenderer(JavaPlugin plugin, boolean isGlobal, Channel channel) { this.plugin = plugin; this.isGlobal = isGlobal; + this.channel = channel; this.mentionHandler = new MentionHandler(plugin); com.loki.lochat.LoChat loChat = (com.loki.lochat.LoChat) plugin; @@ -85,6 +97,26 @@ public class EnhancedChatRenderer implements ChatRenderer { "message", processed )); } + + private String emojiFor() { + if (channel != null) { + return channel.display() == null ? "" : channel.display(); + } + com.loki.lochat.config.AppearanceConfig appearanceCfg = cfg.getAppearanceConfig(); + return isGlobal ? appearanceCfg.getGlobalEmoji() : appearanceCfg.getLocalEmoji(); + } + + private String prefixText() { + if (channel != null) { + return channel.prefix() == null ? channel.id() : channel.prefix(); + } + com.loki.lochat.config.AppearanceConfig appearanceCfg = cfg.getAppearanceConfig(); + return isGlobal ? appearanceCfg.getGlobalText() : appearanceCfg.getLocalText(); + } + + private String channelColor() { + return channel != null && channel.color() != null ? channel.color() : "&7"; + } private Component buildFormattedMessage(String format, Map parts) { Component result = Component.empty(); @@ -112,8 +144,7 @@ public class EnhancedChatRenderer implements ChatRenderer { } private Component buildEmojiComponent() { - com.loki.lochat.config.AppearanceConfig appearanceCfg = cfg.getAppearanceConfig(); - String emoji = isGlobal ? appearanceCfg.getGlobalEmoji() : appearanceCfg.getLocalEmoji(); + String emoji = emojiFor(); return emoji.isEmpty() ? Component.empty() : Component.text(emoji); } @@ -146,9 +177,11 @@ public class EnhancedChatRenderer implements ChatRenderer { } private Component buildPrefix() { + String text = prefixText(); + if (channel != null) { + return ChatFormatter.parse(channelColor() + text); + } com.loki.lochat.config.AppearanceConfig appearanceCfg = cfg.getAppearanceConfig(); - - String text = isGlobal ? appearanceCfg.getGlobalText() : appearanceCfg.getLocalText(); List colors = isGlobal ? appearanceCfg.getGlobalColors() : appearanceCfg.getLocalColors(); return GradientBuilder.build(text, colors); @@ -157,7 +190,9 @@ public class EnhancedChatRenderer implements ChatRenderer { private Component buildSeparator() { com.loki.lochat.config.AppearanceConfig appearanceCfg = cfg.getAppearanceConfig(); String text = isGlobal ? appearanceCfg.getGlobalSeparatorText() : appearanceCfg.getLocalSeparatorText(); - String color = isGlobal ? appearanceCfg.getGlobalSeparatorColor() : appearanceCfg.getLocalSeparatorColor(); + String color = channel != null + ? channelColor() + : (isGlobal ? appearanceCfg.getGlobalSeparatorColor() : appearanceCfg.getLocalSeparatorColor()); return Component.text(text, parseColor(color)); } diff --git a/src/main/resources/config/channels.yml b/src/main/resources/config/channels.yml new file mode 100644 index 0000000..99a1bfd --- /dev/null +++ b/src/main/resources/config/channels.yml @@ -0,0 +1,57 @@ +# LoChat channels +# Channels let players choose where their chat messages go. +# /join — join a channel +# /leave — leave the current channel (back to default chat) +# /channel [name|list|off] — view info, switch, or list channels +# +# Per channel: +# display — icon shown before the channel name in messages +# prefix — channel label +# color — color of the prefix (legacy & codes or &#RRGGBB) +# radius -1 = global (all players), >0 = local radius in blocks +# cooldown seconds between messages in this channel (0 = off) +# permission required to join/use the channel (empty = everyone) +# aliases alternative names to reference the channel + +channels: + # Master switch for the channel system + enabled: true + # Default channel id: when a player is not in a joined channel, they fall back to it + default: "global" + + list: + global: + display: "\u2726" + prefix: "GLOBAL" + color: "&b" + radius: -1 + cooldown: 3 + permission: "" + aliases: [ g ] + + local: + display: "\u25C6" + prefix: "LOCAL" + color: "&d" + radius: 100 + cooldown: 1 + permission: "" + aliases: [ l ] + + staff: + display: "\u2694" + prefix: "STAFF" + color: "&6" + radius: -1 + cooldown: 2 + permission: "lochat.channel.staff" + aliases: [ s, staffchat ] + + trade: + display: "\u27A4" + prefix: "TRADE" + color: "&e" + radius: -1 + cooldown: 5 + permission: "lochat.channel.trade" + aliases: [ t, tradechat ] \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 38b6a7e..080a702 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -20,6 +20,24 @@ commands: permission: chat.global.use aliases: [ п ] + join: + description: Join a chat channel + usage: /join + permission: chat.channel.join + aliases: [ войти ] + + leave: + description: Leave the current chat channel + usage: /leave + permission: chat.channel.leave + aliases: [ выйти ] + + channel: + description: View or switch chat channel + usage: /channel [channel|list|off] + permission: chat.channel.use + aliases: [ ch, канал, channels, каналы ] + globalchat: description: Toggle global chat usage: /globalchat toggle @@ -225,6 +243,21 @@ commands: permissions: chat.global.use: default: true + chat.channel.use: + description: Can use /channel + default: true + chat.channel.join: + description: Can use /join + default: true + chat.channel.leave: + description: Can use /leave + default: true + lochat.channel.staff: + description: Access to the staff channel + default: op + lochat.channel.trade: + description: Access to the trade channel + default: op chat.global.toggle: default: true chat.global.color: diff --git a/src/test/java/com/loki/lochat/core/service/ChannelServiceImplTest.java b/src/test/java/com/loki/lochat/core/service/ChannelServiceImplTest.java new file mode 100644 index 0000000..01e3601 --- /dev/null +++ b/src/test/java/com/loki/lochat/core/service/ChannelServiceImplTest.java @@ -0,0 +1,111 @@ +package com.loki.lochat.core.service; + +import com.loki.lochat.api.service.ChannelService; +import com.loki.lochat.channel.Channel; +import com.loki.lochat.config.ChannelConfig; + +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ChannelServiceImplTest { + + private final Channel global = new Channel("global", "\u2726", "GLOBAL", "&b", -1, 3, "", List.of("g")); + private final Channel staff = new Channel("staff", "\u2694", "STAFF", "&6", -1, 2, + "lochat.channel.staff", List.of("s")); + + private Player player() { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + return player; + } + + private ChannelService createService() { + ChannelConfig config = mock(ChannelConfig.class); + when(config.isEnabled()).thenReturn(true); + when(config.getDefault()).thenReturn(global); + when(config.get("global")).thenReturn(global); + when(config.get("staff")).thenReturn(staff); + when(config.find("global")).thenReturn(global); + when(config.find("staff")).thenReturn(staff); + when(config.find("s")).thenReturn(staff); + when(config.getChannels()).thenReturn(List.of(global, staff)); + return new ChannelServiceImpl(config); + } + + @Test + void getActive_emptyByDefault() { + Player player = player(); + ChannelService service = createService(); + assertTrue(service.getActive(player).isEmpty()); + assertFalse(service.isInChannel(player)); + } + + @Test + void join_nonDefaultChannel() { + Player player = player(); + when(player.hasPermission("lochat.channel.staff")).thenReturn(true); + ChannelService service = createService(); + assertTrue(service.join(player, staff)); + Optional active = service.getActive(player); + assertTrue(active.isPresent()); + assertEquals("staff", active.get().id()); + assertTrue(service.isInChannel(player)); + } + + @Test + void join_defaultChannelMeansNotJoined() { + Player player = player(); + ChannelService service = createService(); + assertTrue(service.join(player, global)); + assertTrue(service.getActive(player).isEmpty()); + assertFalse(service.isInChannel(player)); + } + + @Test + void join_withoutPermissionFails() { + Player player = player(); + when(player.hasPermission("lochat.channel.staff")).thenReturn(false); + ChannelService service = createService(); + assertFalse(service.join(player, staff)); + assertFalse(service.isInChannel(player)); + } + + @Test + void join_withPermission() { + Player player = player(); + when(player.hasPermission("lochat.channel.staff")).thenReturn(true); + ChannelService service = createService(); + assertTrue(service.join(player, staff)); + assertEquals("staff", service.getActive(player).orElseThrow().id()); + } + + @Test + void leave_clearsActiveChannel() { + Player player = player(); + when(player.hasPermission("lochat.channel.staff")).thenReturn(true); + ChannelService service = createService(); + service.join(player, staff); + service.leave(player); + assertFalse(service.isInChannel(player)); + assertTrue(service.getActive(player).isEmpty()); + } + + @Test + void find_byAlias() { + ChannelService service = createService(); + Channel byId = service.find("staff"); + Channel byAlias = service.find("s"); + assertEquals("staff", byId.id()); + assertEquals("staff", byAlias.id()); + } +} \ No newline at end of file