From 2edff88cac8e523bde17e53f889bdb41f4b292d1 Mon Sep 17 00:00:00 2001 From: John Etherfield Date: Fri, 14 Aug 2026 16:39:10 +0200 Subject: [PATCH] feat: add stealth mode and anti-abuse chat detectionD --- src/main/java/org/examp/john/AntiAbuse.java | 81 ++++++++++ src/main/java/org/examp/john/ChatHandler.java | 72 +++++---- src/main/java/org/examp/john/MSNexus.java | 5 +- .../examp/john/stealth/StealthCommand.java | 152 ++++++++++++++++++ .../examp/john/stealth/StealthStorage.java | 84 ++++++++++ 5 files changed, 360 insertions(+), 34 deletions(-) create mode 100644 src/main/java/org/examp/john/AntiAbuse.java create mode 100644 src/main/java/org/examp/john/stealth/StealthCommand.java create mode 100644 src/main/java/org/examp/john/stealth/StealthStorage.java diff --git a/src/main/java/org/examp/john/AntiAbuse.java b/src/main/java/org/examp/john/AntiAbuse.java new file mode 100644 index 0000000..5a47ad7 --- /dev/null +++ b/src/main/java/org/examp/john/AntiAbuse.java @@ -0,0 +1,81 @@ +package org.examp.john; + +import net.minecraft.server.level.ServerPlayer; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Детектить патерн пошуку гравців через локальний чат. + * + * Алгоритм: + * 1. Записує кожне локальне повідомлення (текст + координати + час) + * 2. Якщо одне і те ж повідомлення повторюється >=5 разів за 5 хвилин + * І гравець перемістився більше ніж на 50 блоків — + * це очевидний пошук гравців. + * 3. Логує в термінал для адміна. + */ +public class AntiAbuse { + + private static final int REPEAT_THRESHOLD = 5; + private static final double DISTANCE_THRESHOLD = 50.0; + private static final long TIME_WINDOW_MS = 5 * 60 * 1000; + + private static final Map> history = new HashMap<>(); + + public static void recordMessage(ServerPlayer player, String message) { + UUID uuid = player.getUUID(); + List records = history.computeIfAbsent(uuid, k -> new ArrayList<>()); + + long now = System.currentTimeMillis(); + records.add(new MessageRecord(message, now, player.getX(), player.getZ())); + + long cutoff = now - TIME_WINDOW_MS; + records.removeIf(r -> r.timestamp < cutoff); + + if (records.size() >= REPEAT_THRESHOLD) { + checkForAbuse(player, records); + } + } + + private static void checkForAbuse(ServerPlayer player, List records) { + Map counts = new HashMap<>(); + Map bounds = new HashMap<>(); + + for (MessageRecord r : records) { + counts.merge(r.message, new int[]{1}, (a, b) -> { + a[0]++; + return a; + }); + + double[] b = bounds.computeIfAbsent(r.message, k -> new double[]{r.x, r.x, r.z, r.z}); + if (r.x < b[0]) b[0] = r.x; + if (r.x > b[1]) b[1] = r.x; + if (r.z < b[2]) b[2] = r.z; + if (r.z > b[3]) b[3] = r.z; + } + + for (Map.Entry entry : counts.entrySet()) { + if (entry.getValue()[0] < REPEAT_THRESHOLD) continue; + + double[] b = bounds.get(entry.getKey()); + double spread = Math.max(b[1] - b[0], b[3] - b[2]); + + if (spread > DISTANCE_THRESHOLD) { + Localchat.magicModLog.warn( + "[ANTI-ABUSE] Гравець {} ймовірно шукає інших: " + + "'{}' ×{} разів, переміщення ~{} блоків", + player.getName().getString(), + entry.getKey(), + entry.getValue()[0], + (int) spread + ); + } + } + } + + private record MessageRecord(String message, long timestamp, double x, double z) {} +} diff --git a/src/main/java/org/examp/john/ChatHandler.java b/src/main/java/org/examp/john/ChatHandler.java index 6061e12..dc6a77a 100644 --- a/src/main/java/org/examp/john/ChatHandler.java +++ b/src/main/java/org/examp/john/ChatHandler.java @@ -7,9 +7,9 @@ import net.luckperms.api.model.user.User; import net.minecraft.ChatFormatting; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.MutableComponent; -import net.minecraft.server.MinecraftServer; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; +import org.examp.john.stealth.StealthStorage; import java.util.ArrayList; import java.util.List; @@ -22,29 +22,11 @@ public class ChatHandler { ServerMessageEvents.ALLOW_CHAT_MESSAGE.register((message, sender, params) -> { String text = message.signedContent(); ServerLevel serverLevel = sender.level(); - MinecraftServer server = serverLevel.getServer(); - List players = server.getPlayerList().getPlayers(); + List players = serverLevel.getServer().getPlayerList().getPlayers(); - String playerPrefix = ""; - try { - LuckPerms luckPerms = LuckPermsProvider.get(); - User user = luckPerms.getUserManager().getUser(sender.getUUID()); - if (user != null) { - String metaPrefix = user.getCachedData().getMetaData().getPrefix(); - if (metaPrefix != null) { - playerPrefix = metaPrefix; - } - } - } catch (Throwable ignored) { - } - - MutableComponent formattedPrefix = Component.literal(playerPrefix.replace("&", "§")); - MutableComponent formattedName = Component.empty() - .append(formattedPrefix) - .append(sender.getDisplayName()); + MutableComponent formattedName = buildFormattedName(sender); if (text.startsWith(prefix)) { - // ГЛОБАЛЬНИЙ ЧАТ String actualMessage = text.substring(1).trim(); Component globalMessage = Component.empty() .append(formattedName) @@ -54,17 +36,26 @@ public class ChatHandler { player.sendSystemMessage(globalMessage); } - // Логування: глобальний чат чують всі онлайн-гравці ChatLogger.logGlobal(sender, actualMessage, players); } else { - // ЛОКАЛЬНИЙ ЧАТ + AntiAbuse.recordMessage(sender, text); + + if (StealthStorage.isEnabled(sender.getUUID())) { + sender.sendSystemMessage( + Component.literal("Вас ніхто не почув...") + .withStyle(ChatFormatting.ITALIC, ChatFormatting.YELLOW) + ); + ChatLogger.logUnheard(sender, text); + return false; + } + List hearers = new ArrayList<>(); for (ServerPlayer player : players) { - if (player.level() == serverLevel && player.distanceTo(sender) <= LOCAL_CHAT_RADIUS) { - if (player != sender) { - hearers.add(player); - } + if (player != sender + && player.level() == serverLevel + && player.distanceTo(sender) <= LOCAL_CHAT_RADIUS) { + hearers.add(player); } } @@ -73,27 +64,42 @@ public class ChatHandler { .append(formattedName) .append(Component.literal(": " + text).withStyle(ChatFormatting.GRAY)); - // ОПТИМІЗАЦІЯ: Замість повторної перевірки всіх гравців сервера, - // просто відправляємо повідомлення самому відправнику та вже готовому списку слухачів. sender.sendSystemMessage(localMessage); for (ServerPlayer hearer : hearers) { hearer.sendSystemMessage(localMessage); } - // Логування: локальний чат, хто почув + координати ChatLogger.logLocal(sender, text, hearers); } else { sender.sendSystemMessage( - Component.literal("Вас ніхто не почув, додайте на початок повідомлення \"!\" аби написати в глобальний чат") + Component.literal("Вас ніхто не почув, додайте \"!\" на початок для глобального чату") .withStyle(ChatFormatting.ITALIC, ChatFormatting.YELLOW) ); - // Логування: ніхто не почув ChatLogger.logUnheard(sender, text); } } return false; }); } -} \ No newline at end of file + + private static MutableComponent buildFormattedName(ServerPlayer player) { + String prefix = ""; + try { + LuckPerms luckPerms = LuckPermsProvider.get(); + User user = luckPerms.getUserManager().getUser(player.getUUID()); + if (user != null) { + String metaPrefix = user.getCachedData().getMetaData().getPrefix(); + if (metaPrefix != null) { + prefix = metaPrefix; + } + } + } catch (Throwable ignored) { + } + + return Component.empty() + .append(Component.literal(prefix.replace("&", "§"))) + .append(player.getDisplayName()); + } +} diff --git a/src/main/java/org/examp/john/MSNexus.java b/src/main/java/org/examp/john/MSNexus.java index 35d3249..4b067d9 100644 --- a/src/main/java/org/examp/john/MSNexus.java +++ b/src/main/java/org/examp/john/MSNexus.java @@ -1,6 +1,8 @@ package org.examp.john; import net.fabricmc.api.ModInitializer; +import org.examp.john.stealth.StealthCommand; +import org.examp.john.stealth.StealthStorage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -10,7 +12,8 @@ public class MSNexus implements ModInitializer { @Override public void onInitialize() { - // Тепер ChatHandler викликається з того ж пакета без помилок імпорту + StealthStorage.load(); ChatHandler.register(); + StealthCommand.register(); } } diff --git a/src/main/java/org/examp/john/stealth/StealthCommand.java b/src/main/java/org/examp/john/stealth/StealthCommand.java new file mode 100644 index 0000000..b28e300 --- /dev/null +++ b/src/main/java/org/examp/john/stealth/StealthCommand.java @@ -0,0 +1,152 @@ +package org.examp.john.stealth; + +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.minecraft.ChatFormatting; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +/** + * Команда /stealth — керує режимом прихованості гравця. + * + * Підкоманди: + * /stealth — переключити стан (on/off) + * /stealth on — увімкнути stealth + * /stealth off — вимкнути stealth + * /stealth info — показати інформацію про команду + * + * Коли stealth увімкнено, повідомлення локального чату від гравця + * не доходять до інших — гравець бачить "Вас ніхто не почув..." + * Глобальний чат (! префікс) працює як зазвичай. + */ +public class StealthCommand { + + public static void register() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> + dispatcher.register(buildCommand()) + ); + } + + private static LiteralArgumentBuilder buildCommand() { + return Commands.literal("stealth") + .executes(StealthCommand::executeToggle) + .then(Commands.literal("on").executes(StealthCommand::executeOn)) + .then(Commands.literal("off").executes(StealthCommand::executeOff)) + .then(Commands.literal("info").executes(StealthCommand::executeInfo)); + } + + private static int executeToggle(CommandContext context) { + ServerPlayer player = getPlayer(context); + if (player == null) return 0; + + boolean newState = StealthStorage.toggle(player.getUUID()); + sendStatus(player, newState); + return 1; + } + + private static int executeOn(CommandContext context) { + ServerPlayer player = getPlayer(context); + if (player == null) return 0; + + StealthStorage.set(player.getUUID(), true); + sendStatus(player, true); + return 1; + } + + private static int executeOff(CommandContext context) { + ServerPlayer player = getPlayer(context); + if (player == null) return 0; + + StealthStorage.set(player.getUUID(), false); + sendStatus(player, false); + return 1; + } + + private static int executeInfo(CommandContext context) { + ServerPlayer player = getPlayer(context); + if (player == null) return 0; + + boolean enabled = StealthStorage.isEnabled(player.getUUID()); + + player.sendSystemMessage(Component.literal("=== Stealth Mode ===").withStyle(ChatFormatting.GOLD)); + player.sendSystemMessage(Component.empty()); + player.sendSystemMessage(Component.literal("Статус: ").append( + enabled + ? Component.literal("УВІМКНЕНО").withStyle(ChatFormatting.GREEN) + : Component.literal("ВИМКНЕНО").withStyle(ChatFormatting.RED) + )); + player.sendSystemMessage(Component.empty()); + + player.sendSystemMessage(Component.literal("Навіщо це потрібно:").withStyle(ChatFormatting.YELLOW)); + player.sendSystemMessage(Component.literal(" Локальний чат можна зловживати для пошуку") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" гравців: хтось пише в чат, бігає по карті,") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" і дивиться хто відповів — так можна") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" визначити місцеперебування іншого гравця.") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" Stealth прибирає вас з локального чату:") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" ваші повідомлення не бачить ніхто,") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" але ви можете бачити чужі якщо вони поруч.") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.empty()); + + player.sendSystemMessage(Component.literal("Підкоманди:").withStyle(ChatFormatting.YELLOW)); + player.sendSystemMessage(Component.literal(" /stealth").withStyle(ChatFormatting.AQUA) + .append(Component.literal(" — переключити стан (on/off)").withStyle(ChatFormatting.GRAY))); + player.sendSystemMessage(Component.literal(" /stealth on").withStyle(ChatFormatting.AQUA) + .append(Component.literal(" — увімкнути stealth").withStyle(ChatFormatting.GRAY))); + player.sendSystemMessage(Component.literal(" /stealth off").withStyle(ChatFormatting.AQUA) + .append(Component.literal(" — вимкнути stealth").withStyle(ChatFormatting.GRAY))); + player.sendSystemMessage(Component.literal(" /stealth info").withStyle(ChatFormatting.AQUA) + .append(Component.literal(" — ця інформація").withStyle(ChatFormatting.GRAY))); + player.sendSystemMessage(Component.empty()); + + player.sendSystemMessage(Component.literal("Як працює:").withStyle(ChatFormatting.YELLOW)); + player.sendSystemMessage(Component.literal(" Звичайний чат: повідомлення бачать лише") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" ті хто в радіусі 100 блоків.") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" Якщо stealth ON: ваші повідомлення") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" не доходять взагалі — ви бачите") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" 'Вас ніхто не почув...' замість цього.") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" Глобальний чат (! префікс) працює") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" завжди, навіть з stealth.") + .withStyle(ChatFormatting.GRAY)); + + return 1; + } + + private static void sendStatus(ServerPlayer player, boolean enabled) { + if (enabled) { + player.sendSystemMessage(Component.literal("Stealth-режим УВІМКНЕНО.") + .withStyle(ChatFormatting.GREEN)); + player.sendSystemMessage(Component.literal("Ваші повідомлення в локальному чаті тепер не доходять.") + .withStyle(ChatFormatting.GRAY)); + } else { + player.sendSystemMessage(Component.literal("Stealth-режим ВИМКНЕНО.") + .withStyle(ChatFormatting.RED)); + player.sendSystemMessage(Component.literal("Ваші повідомлення в локальному чаті знову чутні.") + .withStyle(ChatFormatting.GRAY)); + } + } + + private static ServerPlayer getPlayer(CommandContext context) { + if (!(context.getSource().getEntity() instanceof ServerPlayer player)) { + context.getSource().sendFailure( + Component.literal("Цю команду можна викликати лише в грі.")); + return null; + } + return player; + } +} diff --git a/src/main/java/org/examp/john/stealth/StealthStorage.java b/src/main/java/org/examp/john/stealth/StealthStorage.java new file mode 100644 index 0000000..d26c9b7 --- /dev/null +++ b/src/main/java/org/examp/john/stealth/StealthStorage.java @@ -0,0 +1,84 @@ +package org.examp.john.stealth; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; +import org.examp.john.Localchat; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Зберігає стан stealth-режиму для кожного гравця. + * Файл: config/localchat/stealth.json — {"uuid1": true, "uuid2": false, ...} + * Зміни зберігаються на диск при кожному toggle. + */ +public class StealthStorage { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Path CONFIG_PATH = Path.of("config", "localchat", "stealth.json"); + + private static Map data = new HashMap<>(); + + public static void load() { + try { + if (!Files.exists(CONFIG_PATH)) { + data = new HashMap<>(); + save(); + return; + } + try (Reader reader = Files.newBufferedReader(CONFIG_PATH, StandardCharsets.UTF_8)) { + @SuppressWarnings("unchecked") + Map raw = GSON.fromJson(reader, Map.class); + data = new HashMap<>(); + if (raw != null) { + for (Map.Entry entry : raw.entrySet()) { + data.put(UUID.fromString(entry.getKey()), entry.getValue()); + } + } + } + } catch (IOException | JsonSyntaxException | IllegalArgumentException e) { + Localchat.magicModLog.warn("Не вдалося прочитати stealth.json: {}", e.getMessage()); + data = new HashMap<>(); + } + } + + public static boolean isEnabled(UUID uuid) { + return data.getOrDefault(uuid, false); + } + + public static boolean toggle(UUID uuid) { + boolean current = isEnabled(uuid); + boolean newValue = !current; + data.put(uuid, newValue); + save(); + return newValue; + } + + public static void set(UUID uuid, boolean value) { + data.put(uuid, value); + save(); + } + + private static void save() { + try { + Files.createDirectories(CONFIG_PATH.getParent()); + try (Writer writer = Files.newBufferedWriter(CONFIG_PATH, StandardCharsets.UTF_8)) { + Map raw = new HashMap<>(); + for (Map.Entry entry : data.entrySet()) { + raw.put(entry.getKey().toString(), entry.getValue()); + } + GSON.toJson(raw, writer); + } + } catch (IOException e) { + Localchat.magicModLog.error("Не вдалося зберегти stealth.json: {}", e.getMessage()); + } + } +}