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..7d53289 --- /dev/null +++ b/src/main/java/org/examp/john/AntiAbuse.java @@ -0,0 +1,77 @@ +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; + +/** + * Детектить патерн пошуку гравців через локальний чат. + * + * Якщо одне і те ж повідомлення повторюється >=5 разів за 5 хвилин + * і гравець перемістився >50 блоків — логує в консоль. + */ +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..901ae29 100644 --- a/src/main/java/org/examp/john/ChatHandler.java +++ b/src/main/java/org/examp/john/ChatHandler.java @@ -5,11 +5,14 @@ import net.luckperms.api.LuckPerms; import net.luckperms.api.LuckPermsProvider; import net.luckperms.api.model.user.User; import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.ClickEvent; import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.HoverEvent; import net.minecraft.network.chat.MutableComponent; -import net.minecraft.server.MinecraftServer; +import net.minecraft.network.chat.Style; 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 +25,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 +39,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 +67,52 @@ 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 luckPermsPrefix = getLuckPermsPrefix(player); + String playerName = player.getName().getString(); + String formattedPrefix = luckPermsPrefix.replace("&", "\u00a7"); + + Style clickStyle = Style.EMPTY + .withClickEvent(new ClickEvent.SuggestCommand("/msg " + playerName + " ")) + .withHoverEvent(new HoverEvent.ShowText( + Component.literal("Натисніть щоб написати " + playerName) + .withStyle(ChatFormatting.GRAY))); + + return Component.literal(formattedPrefix + playerName).withStyle(clickStyle); + } + + private static String getLuckPermsPrefix(ServerPlayer player) { + try { + LuckPerms luckPerms = LuckPermsProvider.get(); + User user = luckPerms.getUserManager().getUser(player.getUUID()); + if (user != null) { + String metaPrefix = user.getCachedData().getMetaData().getPrefix(); + if (metaPrefix != null) { + return metaPrefix; + } + } + } catch (Throwable ignored) { + } + return ""; + } +} diff --git a/src/main/java/org/examp/john/Localchat.java b/src/main/java/org/examp/john/Localchat.java index a2fa5e4..b37a737 100644 --- a/src/main/java/org/examp/john/Localchat.java +++ b/src/main/java/org/examp/john/Localchat.java @@ -1,7 +1,8 @@ package org.examp.john; import net.fabricmc.api.ModInitializer; -import org.examp.john.rules.RulesCommand; +import org.examp.john.stealth.StealthCommand; +import org.examp.john.stealth.StealthStorage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,7 +12,8 @@ public class Localchat implements ModInitializer { @Override public void onInitialize() { + StealthStorage.load(); ChatHandler.register(); - RulesCommand.register(); + StealthCommand.register(); } -} \ No newline at end of file +} 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..77749d7 --- /dev/null +++ b/src/main/java/org/examp/john/stealth/StealthCommand.java @@ -0,0 +1,145 @@ +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 — показати інформацію про команду + */ +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..ebeb4dd --- /dev/null +++ b/src/main/java/org/examp/john/stealth/StealthStorage.java @@ -0,0 +1,83 @@ +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, ...} + */ +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()); + } + } +}