From 080a8380283bafa585520303c1b5b5bb75559afb Mon Sep 17 00:00:00 2001 From: ProstoMaslo Date: Tue, 11 Aug 2026 20:44:28 +0300 Subject: [PATCH 1/9] REFACTOR: Change modid, modname e.t.c --- gradle.properties | 2 +- .../{LocalchatClient.java => MSNexusClient.java} | 2 +- ...aGenerator.java => MSNexusDataGenerator.java} | 2 +- ...nt.mixins.json => msnexus.client.mixins.json} | 0 src/main/java/org/examp/john/ChatLogger.java | 6 +++--- .../examp/john/{Localchat.java => MSNexus.java} | 4 ++-- src/main/resources/fabric.mod.json | 16 ++++++++-------- ...localchat.mixins.json => msnexus.mixins.json} | 0 8 files changed, 16 insertions(+), 16 deletions(-) rename src/client/java/org/examp/john/client/{LocalchatClient.java => MSNexusClient.java} (69%) rename src/client/java/org/examp/john/client/{LocalchatDataGenerator.java => MSNexusDataGenerator.java} (82%) rename src/client/resources/{localchat.client.mixins.json => msnexus.client.mixins.json} (100%) rename src/main/java/org/examp/john/{Localchat.java => MSNexus.java} (79%) rename src/main/resources/{localchat.mixins.json => msnexus.mixins.json} (100%) diff --git a/gradle.properties b/gradle.properties index 1a68be0..cba7521 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,5 +4,5 @@ yarn_mappings=1.21.11+build.6 loader_version=0.19.2 mod_version=1.0 maven_group=org.example1 -archives_base_name=localhost +archives_base_name=msnexus fabric_version=0.155.2+26.1.2 diff --git a/src/client/java/org/examp/john/client/LocalchatClient.java b/src/client/java/org/examp/john/client/MSNexusClient.java similarity index 69% rename from src/client/java/org/examp/john/client/LocalchatClient.java rename to src/client/java/org/examp/john/client/MSNexusClient.java index 55ece85..adfb882 100644 --- a/src/client/java/org/examp/john/client/LocalchatClient.java +++ b/src/client/java/org/examp/john/client/MSNexusClient.java @@ -2,7 +2,7 @@ package org.examp.john.client; import net.fabricmc.api.ClientModInitializer; -public class LocalchatClient implements ClientModInitializer { +public class MSNexusClient implements ClientModInitializer { @Override public void onInitializeClient() { diff --git a/src/client/java/org/examp/john/client/LocalchatDataGenerator.java b/src/client/java/org/examp/john/client/MSNexusDataGenerator.java similarity index 82% rename from src/client/java/org/examp/john/client/LocalchatDataGenerator.java rename to src/client/java/org/examp/john/client/MSNexusDataGenerator.java index caa73e5..ee4aae9 100644 --- a/src/client/java/org/examp/john/client/LocalchatDataGenerator.java +++ b/src/client/java/org/examp/john/client/MSNexusDataGenerator.java @@ -3,7 +3,7 @@ package org.examp.john.client; import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint; import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator; -public class LocalchatDataGenerator implements DataGeneratorEntrypoint { +public class MSNexusDataGenerator implements DataGeneratorEntrypoint { @Override public void onInitializeDataGenerator(FabricDataGenerator fabricDataGenerator) { diff --git a/src/client/resources/localchat.client.mixins.json b/src/client/resources/msnexus.client.mixins.json similarity index 100% rename from src/client/resources/localchat.client.mixins.json rename to src/client/resources/msnexus.client.mixins.json diff --git a/src/main/java/org/examp/john/ChatLogger.java b/src/main/java/org/examp/john/ChatLogger.java index 9fd979c..20e6eb8 100644 --- a/src/main/java/org/examp/john/ChatLogger.java +++ b/src/main/java/org/examp/john/ChatLogger.java @@ -14,7 +14,7 @@ public class ChatLogger { public static void logGlobal(ServerPlayer sender, String message, List recipients) { // Для глобального чату ігноруємо список отримувачів і просто пишемо "everyone" - Localchat.magicModLog.info( + MSNexus.magicModLog.info( "[CHAT][GLOBAL] {} said: \"{}\" | heard by: everyone | pos: {}", sender.getName().getString(), message, @@ -30,7 +30,7 @@ public class ChatLogger { .map(p -> p.getName().getString()) .collect(Collectors.joining(", ")); - Localchat.magicModLog.info( + MSNexus.magicModLog.info( "[CHAT][LOCAL] {} said: \"{}\" | heard by: {} | pos: {}", sender.getName().getString(), message, @@ -40,7 +40,7 @@ public class ChatLogger { } public static void logUnheard(ServerPlayer sender, String message) { - Localchat.magicModLog.info( + MSNexus.magicModLog.info( "[CHAT][LOCAL][UNHEARD] {} said: \"{}\" | heard by: nobody | pos: {}", sender.getName().getString(), message, diff --git a/src/main/java/org/examp/john/Localchat.java b/src/main/java/org/examp/john/MSNexus.java similarity index 79% rename from src/main/java/org/examp/john/Localchat.java rename to src/main/java/org/examp/john/MSNexus.java index d33b0f5..35d3249 100644 --- a/src/main/java/org/examp/john/Localchat.java +++ b/src/main/java/org/examp/john/MSNexus.java @@ -4,8 +4,8 @@ import net.fabricmc.api.ModInitializer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -public class Localchat implements ModInitializer { - public static final String MOD_ID = "localhost"; +public class MSNexus implements ModInitializer { + public static final String MOD_ID = "msnexus"; public static final Logger magicModLog = LoggerFactory.getLogger(MOD_ID); @Override diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 7694098..c7fb683 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -1,29 +1,29 @@ { "schemaVersion": 1, - "id": "localchat", + "id": "msnexus", "version": "${version}", - "name": "localchat", + "name": "msnexus", "description": "", "authors": [], "contact": {}, "license": "All-Rights-Reserved", - "icon": "assets/localchat/icon.png", + "icon": "assets/msnexus/icon.png", "environment": "*", "entrypoints": { "fabric-datagen": [ - "org.examp.john.client.LocalchatDataGenerator" + "org.examp.john.client.MSNexusDataGenerator" ], "client": [ - "org.examp.john.client.LocalchatClient" + "org.examp.john.client.MSNexusClient" ], "main": [ - "org.examp.john.Localchat" + "org.examp.john.MSNexus" ] }, "mixins": [ - "localchat.mixins.json", + "msnexus.mixins.json", { - "config": "localchat.client.mixins.json", + "config": "msnexus.client.mixins.json", "environment": "client" } ], diff --git a/src/main/resources/localchat.mixins.json b/src/main/resources/msnexus.mixins.json similarity index 100% rename from src/main/resources/localchat.mixins.json rename to src/main/resources/msnexus.mixins.json From 2edff88cac8e523bde17e53f889bdb41f4b292d1 Mon Sep 17 00:00:00 2001 From: John Etherfield Date: Fri, 14 Aug 2026 16:39:10 +0200 Subject: [PATCH 2/9] 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()); + } + } +} From 64bd3141dcb44e2ad3591ef9f0e016a5849f975d Mon Sep 17 00:00:00 2001 From: John Etherfield Date: Sun, 16 Aug 2026 19:16:25 +0200 Subject: [PATCH 3/9] feat: highlight player names in chat with LuckPerms colors --- src/main/java/org/examp/john/ChatHandler.java | 59 ++++++++++++++++++- src/main/resources/fabric.mod.json | 2 +- 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/examp/john/ChatHandler.java b/src/main/java/org/examp/john/ChatHandler.java index dc6a77a..db75e7b 100644 --- a/src/main/java/org/examp/john/ChatHandler.java +++ b/src/main/java/org/examp/john/ChatHandler.java @@ -12,6 +12,7 @@ import net.minecraft.server.level.ServerPlayer; import org.examp.john.stealth.StealthStorage; import java.util.ArrayList; +import java.util.Comparator; import java.util.List; public class ChatHandler { @@ -30,7 +31,8 @@ public class ChatHandler { String actualMessage = text.substring(1).trim(); Component globalMessage = Component.empty() .append(formattedName) - .append(Component.literal(": " + actualMessage)); + .append(Component.literal(": ")) + .append(buildMentionMessage(actualMessage, players)); for (ServerPlayer player : players) { player.sendSystemMessage(globalMessage); @@ -62,7 +64,8 @@ public class ChatHandler { if (!hearers.isEmpty()) { Component localMessage = Component.empty() .append(formattedName) - .append(Component.literal(": " + text).withStyle(ChatFormatting.GRAY)); + .append(Component.literal(": ").withStyle(ChatFormatting.GRAY)) + .append(buildMentionMessage(text, players).withStyle(ChatFormatting.GRAY)); sender.sendSystemMessage(localMessage); for (ServerPlayer hearer : hearers) { @@ -84,6 +87,58 @@ public class ChatHandler { }); } + private static MutableComponent buildMentionMessage(String text, List onlinePlayers) { + List sorted = onlinePlayers.stream() + .sorted(Comparator.comparingInt((ServerPlayer p) -> p.getName().getString().length()).reversed()) + .toList(); + + MutableComponent result = Component.empty(); + String remaining = text; + + while (!remaining.isEmpty()) { + int earliestIndex = remaining.length(); + ServerPlayer mentioned = null; + + for (ServerPlayer player : sorted) { + String name = player.getName().getString(); + int index = remaining.toLowerCase().indexOf(name.toLowerCase()); + if (index >= 0 && index < earliestIndex) { + earliestIndex = index; + mentioned = player; + } + } + + if (mentioned == null) { + result.append(Component.literal(remaining)); + break; + } + + if (earliestIndex > 0) { + result.append(Component.literal(remaining.substring(0, earliestIndex))); + } + + result.append(createMentionComponent(mentioned)); + + remaining = remaining.substring(earliestIndex + mentioned.getName().getString().length()); + } + + return result; + } + + private static Component createMentionComponent(ServerPlayer player) { + String prefix = getLuckPermsPrefix(player); + String formattedPrefix = prefix.replace("&", "\u00a7"); + String name = player.getName().getString(); + + return Component.literal(formattedPrefix + name) + .withStyle(Style.EMPTY + .withClickEvent(new ClickEvent.SuggestCommand("/msg " + name + " ")) + .withHoverEvent(new HoverEvent.ShowText( + Component.literal("Натисніть щоб написати " + name) + .withStyle(ChatFormatting.GRAY))) + ); + } + private static MutableComponent buildFormattedName(ServerPlayer player) { String prefix = ""; try { diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index c7fb683..7584f25 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -1,5 +1,5 @@ { - "schemaVersion": 1, + "schemaVersion": 1,gi "id": "msnexus", "version": "${version}", "name": "msnexus", From dc4da670ee2f7231521a25a77942a389a579b2a4 Mon Sep 17 00:00:00 2001 From: John Etherfield Date: Sat, 3 Oct 2026 19:23:21 +0200 Subject: [PATCH 4/9] fix: repair chat code and add **text** italic formatting to chat --- src/main/java/org/examp/john/AntiAbuse.java | 2 +- src/main/java/org/examp/john/ChatHandler.java | 54 ++++++++++++++++++- .../examp/john/stealth/StealthStorage.java | 6 +-- 3 files changed, 56 insertions(+), 6 deletions(-) diff --git a/src/main/java/org/examp/john/AntiAbuse.java b/src/main/java/org/examp/john/AntiAbuse.java index 5a47ad7..db139a1 100644 --- a/src/main/java/org/examp/john/AntiAbuse.java +++ b/src/main/java/org/examp/john/AntiAbuse.java @@ -65,7 +65,7 @@ public class AntiAbuse { double spread = Math.max(b[1] - b[0], b[3] - b[2]); if (spread > DISTANCE_THRESHOLD) { - Localchat.magicModLog.warn( + MSNexus.magicModLog.warn( "[ANTI-ABUSE] Гравець {} ймовірно шукає інших: " + "'{}' ×{} разів, переміщення ~{} блоків", player.getName().getString(), diff --git a/src/main/java/org/examp/john/ChatHandler.java b/src/main/java/org/examp/john/ChatHandler.java index db75e7b..1e38aa0 100644 --- a/src/main/java/org/examp/john/ChatHandler.java +++ b/src/main/java/org/examp/john/ChatHandler.java @@ -5,8 +5,11 @@ 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.network.chat.Style; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; import org.examp.john.stealth.StealthStorage; @@ -32,7 +35,7 @@ public class ChatHandler { Component globalMessage = Component.empty() .append(formattedName) .append(Component.literal(": ")) - .append(buildMentionMessage(actualMessage, players)); + .append(buildFormattedMessage(actualMessage, players)); for (ServerPlayer player : players) { player.sendSystemMessage(globalMessage); @@ -65,7 +68,7 @@ public class ChatHandler { Component localMessage = Component.empty() .append(formattedName) .append(Component.literal(": ").withStyle(ChatFormatting.GRAY)) - .append(buildMentionMessage(text, players).withStyle(ChatFormatting.GRAY)); + .append(buildFormattedMessage(text, players).withStyle(ChatFormatting.GRAY)); sender.sendSystemMessage(localMessage); for (ServerPlayer hearer : hearers) { @@ -87,6 +90,38 @@ public class ChatHandler { }); } + /** + * Парсить текст між ** і робить його курсивом, поєднуючи з підсвіткою згадок гравців. + * Приклад: "привіт **світе** як справи?" -> "привіт [курсив]світе[/курсив] як справи?" + */ + private static MutableComponent buildFormattedMessage(String text, List players) { + MutableComponent result = Component.empty(); + StringBuilder buffer = new StringBuilder(); + boolean italic = false; + + for (int i = 0; i < text.length(); i++) { + if (i + 1 < text.length() && text.charAt(i) == '*' && text.charAt(i + 1) == '*') { + appendFormattedSegment(result, buffer.toString(), players, italic); + buffer.setLength(0); + italic = !italic; + i++; + } else { + buffer.append(text.charAt(i)); + } + } + appendFormattedSegment(result, buffer.toString(), players, italic); + + return result; + } + + private static void appendFormattedSegment(MutableComponent result, String segment, List players, boolean italic) { + MutableComponent parsed = buildMentionMessage(segment, players); + if (italic) { + parsed.withStyle(Style.EMPTY.withItalic(true)); + } + result.append(parsed); + } + private static MutableComponent buildMentionMessage(String text, List onlinePlayers) { List sorted = onlinePlayers.stream() .sorted(Comparator.comparingInt((ServerPlayer p) -> p.getName().getString().length()).reversed()) @@ -139,6 +174,21 @@ public class ChatHandler { ); } + 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 ""; + } + private static MutableComponent buildFormattedName(ServerPlayer player) { String prefix = ""; try { diff --git a/src/main/java/org/examp/john/stealth/StealthStorage.java b/src/main/java/org/examp/john/stealth/StealthStorage.java index d26c9b7..c273f9f 100644 --- a/src/main/java/org/examp/john/stealth/StealthStorage.java +++ b/src/main/java/org/examp/john/stealth/StealthStorage.java @@ -3,7 +3,7 @@ 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 org.examp.john.MSNexus; import java.io.IOException; import java.io.Reader; @@ -45,7 +45,7 @@ public class StealthStorage { } } } catch (IOException | JsonSyntaxException | IllegalArgumentException e) { - Localchat.magicModLog.warn("Не вдалося прочитати stealth.json: {}", e.getMessage()); + MSNexus.magicModLog.warn("Не вдалося прочитати stealth.json: {}", e.getMessage()); data = new HashMap<>(); } } @@ -78,7 +78,7 @@ public class StealthStorage { GSON.toJson(raw, writer); } } catch (IOException e) { - Localchat.magicModLog.error("Не вдалося зберегти stealth.json: {}", e.getMessage()); + MSNexus.magicModLog.error("Не вдалося зберегти stealth.json: {}", e.getMessage()); } } } From 3c72b6e5eae2091e6374056b263db219ae145b80 Mon Sep 17 00:00:00 2001 From: John Etherfield Date: Sat, 3 Oct 2026 21:00:06 +0200 Subject: [PATCH 5/9] fix: fixed malformed fabric.mod.json causing server crash --- src/main/resources/fabric.mod.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 7584f25..452a961 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -1,5 +1,5 @@ { - "schemaVersion": 1,gi + "schemaVersion": 1, "id": "msnexus", "version": "${version}", "name": "msnexus", @@ -7,7 +7,6 @@ "authors": [], "contact": {}, "license": "All-Rights-Reserved", - "icon": "assets/msnexus/icon.png", "environment": "*", "entrypoints": { "fabric-datagen": [ From 932496532204a5581839105dd7ea7f6c376a00be Mon Sep 17 00:00:00 2001 From: John Etherfield Date: Sun, 4 Oct 2026 11:04:17 +0200 Subject: [PATCH 6/9] feat: add spectator tab highlight and command loggin --- src/main/java/org/examp/john/ChatLogger.java | 2 +- .../java/org/examp/john/CommandLogger.java | 21 ++++++++++ .../org/examp/john/mixin/CommandLogMixin.java | 38 +++++++++++++++++++ .../examp/john/mixin/SpectatorTabMixin.java | 24 ++++++++++++ src/main/resources/fabric.mod.json | 11 +----- src/main/resources/msnexus.mixins.json | 2 + 6 files changed, 88 insertions(+), 10 deletions(-) create mode 100644 src/main/java/org/examp/john/CommandLogger.java create mode 100644 src/main/java/org/examp/john/mixin/CommandLogMixin.java create mode 100644 src/main/java/org/examp/john/mixin/SpectatorTabMixin.java diff --git a/src/main/java/org/examp/john/ChatLogger.java b/src/main/java/org/examp/john/ChatLogger.java index 20e6eb8..e71cbf2 100644 --- a/src/main/java/org/examp/john/ChatLogger.java +++ b/src/main/java/org/examp/john/ChatLogger.java @@ -48,7 +48,7 @@ public class ChatLogger { ); } - private static String formatPos(ServerPlayer player) { + static String formatPos(ServerPlayer player) { ServerLevel level = player.level(); // У ResourceKey дістаємо Identifier/ResourceLocation через .identifier(), diff --git a/src/main/java/org/examp/john/CommandLogger.java b/src/main/java/org/examp/john/CommandLogger.java new file mode 100644 index 0000000..63cf9e4 --- /dev/null +++ b/src/main/java/org/examp/john/CommandLogger.java @@ -0,0 +1,21 @@ +package org.examp.john; + +import net.minecraft.server.level.ServerPlayer; + +/** + * Логує команди, які виконують гравці, в консоль сервера. + * Формат: хто виконав ➔ яка команда ➔ координати гравця. + */ +public class CommandLogger { + + public static void logCommand(ServerPlayer player, String command) { + String normalized = command.startsWith("/") ? command : "/" + command; + + MSNexus.magicModLog.info( + "[CMD] {} ran: \"{}\" | pos: {}", + player.getName().getString(), + normalized, + ChatLogger.formatPos(player) + ); + } +} diff --git a/src/main/java/org/examp/john/mixin/CommandLogMixin.java b/src/main/java/org/examp/john/mixin/CommandLogMixin.java new file mode 100644 index 0000000..dc5e0f3 --- /dev/null +++ b/src/main/java/org/examp/john/mixin/CommandLogMixin.java @@ -0,0 +1,38 @@ +package org.examp.john.mixin; + +import com.mojang.brigadier.ParseResults; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.server.level.ServerPlayer; +import org.examp.john.CommandLogger; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Логує виконання команд гравцями в консоль сервера. + * Хукає Commands.performCommand — спільну точку виконання всіх команд. + */ +@Mixin(Commands.class) +public class CommandLogMixin { + + @Inject( + method = "performCommand(Lcom/mojang/brigadier/ParseResults;Ljava/lang/String;)V", + at = @At("HEAD") + ) + private void msnexus$logExecutedCommand(ParseResults parseResults, String command, CallbackInfo ci) { + try { + CommandSourceStack source = parseResults.getContext().getSource(); + if (source == null) { + return; + } + ServerPlayer player = source.getPlayer(); + if (player != null) { + CommandLogger.logCommand(player, command); + } + } catch (Exception e) { + org.examp.john.MSNexus.magicModLog.warn("[CMD] Не вдалося залогувати команду: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/org/examp/john/mixin/SpectatorTabMixin.java b/src/main/java/org/examp/john/mixin/SpectatorTabMixin.java new file mode 100644 index 0000000..2d52508 --- /dev/null +++ b/src/main/java/org/examp/john/mixin/SpectatorTabMixin.java @@ -0,0 +1,24 @@ +package org.examp.john.mixin; + +import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.GameType; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +@Mixin(ClientboundPlayerInfoUpdatePacket.Entry.class) +public class SpectatorTabMixin { + + @Redirect( + method = "(Lnet/minecraft/server/level/ServerPlayer;)V", + at = @At( + value = "INVOKE", + target = "Lnet/minecraft/server/level/ServerPlayer;gameMode()Lnet/minecraft/world/level/GameType;" + ) + ) + private static GameType msnexus$highlightSpectatorsInTab(ServerPlayer player) { + GameType gameMode = player.gameMode(); + return gameMode == GameType.SPECTATOR ? GameType.SURVIVAL : gameMode; + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 452a961..236da3d 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -7,24 +7,17 @@ "authors": [], "contact": {}, "license": "All-Rights-Reserved", - "environment": "*", + "environment": "server", "entrypoints": { "fabric-datagen": [ "org.examp.john.client.MSNexusDataGenerator" ], - "client": [ - "org.examp.john.client.MSNexusClient" - ], "main": [ "org.examp.john.MSNexus" ] }, "mixins": [ - "msnexus.mixins.json", - { - "config": "msnexus.client.mixins.json", - "environment": "client" - } + "msnexus.mixins.json" ], "depends": { "fabricloader": ">=${loader_version}", diff --git a/src/main/resources/msnexus.mixins.json b/src/main/resources/msnexus.mixins.json index eb1a5f0..fb727b4 100644 --- a/src/main/resources/msnexus.mixins.json +++ b/src/main/resources/msnexus.mixins.json @@ -4,6 +4,8 @@ "package": "org.examp.john.mixin", "compatibilityLevel": "JAVA_25", "mixins": [ + "CommandLogMixin", + "SpectatorTabMixin" ], "injectors": { "defaultRequire": 1 From dd12fe4c00a539ac3be4461b9d9fc513ab38e6e6 Mon Sep 17 00:00:00 2001 From: John Etherfield Date: Sun, 4 Oct 2026 11:24:26 +0200 Subject: [PATCH 7/9] fix: prevent chat handler errors and corrupt stealth.json crashes --- build.gradle | 2 +- gradle.properties | 2 +- src/main/java/org/examp/john/AntiAbuse.java | 18 +++ src/main/java/org/examp/john/ChatHandler.java | 126 ++++++++++-------- src/main/java/org/examp/john/MSNexus.java | 1 + .../org/examp/john/mixin/CommandLogMixin.java | 2 +- .../examp/john/stealth/StealthStorage.java | 38 ++++-- 7 files changed, 122 insertions(+), 67 deletions(-) diff --git a/build.gradle b/build.gradle index 6a79a1d..9b39cf6 100644 --- a/build.gradle +++ b/build.gradle @@ -14,7 +14,7 @@ loom { splitEnvironmentSourceSets() mods { - "localchat" { + "msnexus" { sourceSet sourceSets.main sourceSet sourceSets.client } diff --git a/gradle.properties b/gradle.properties index cba7521..82e47c1 100644 --- a/gradle.properties +++ b/gradle.properties @@ -2,7 +2,7 @@ org.gradle.jvmargs=-Xmx2G minecraft_version=26.1.2 yarn_mappings=1.21.11+build.6 loader_version=0.19.2 -mod_version=1.0 +mod_version=1.1 maven_group=org.example1 archives_base_name=msnexus fabric_version=0.155.2+26.1.2 diff --git a/src/main/java/org/examp/john/AntiAbuse.java b/src/main/java/org/examp/john/AntiAbuse.java index db139a1..2d0663e 100644 --- a/src/main/java/org/examp/john/AntiAbuse.java +++ b/src/main/java/org/examp/john/AntiAbuse.java @@ -1,5 +1,6 @@ package org.examp.john; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; import net.minecraft.server.level.ServerPlayer; import java.util.ArrayList; @@ -23,8 +24,18 @@ 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 long ALERT_COOLDOWN_MS = 30 * 1000; private static final Map> history = new HashMap<>(); + private static final Map lastAlertTime = new HashMap<>(); + + public static void register() { + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> { + UUID uuid = handler.player.getUUID(); + history.remove(uuid); + lastAlertTime.remove(uuid); + }); + } public static void recordMessage(ServerPlayer player, String message) { UUID uuid = player.getUUID(); @@ -42,6 +53,7 @@ public class AntiAbuse { } private static void checkForAbuse(ServerPlayer player, List records) { + UUID uuid = player.getUUID(); Map counts = new HashMap<>(); Map bounds = new HashMap<>(); @@ -65,6 +77,12 @@ public class AntiAbuse { double spread = Math.max(b[1] - b[0], b[3] - b[2]); if (spread > DISTANCE_THRESHOLD) { + long now = System.currentTimeMillis(); + Long lastAlert = lastAlertTime.get(uuid); + if (lastAlert != null && now - lastAlert < ALERT_COOLDOWN_MS) { + continue; + } + lastAlertTime.put(uuid, now); MSNexus.magicModLog.warn( "[ANTI-ABUSE] Гравець {} ймовірно шукає інших: " + "'{}' ×{} разів, переміщення ~{} блоків", diff --git a/src/main/java/org/examp/john/ChatHandler.java b/src/main/java/org/examp/john/ChatHandler.java index 1e38aa0..5c565d8 100644 --- a/src/main/java/org/examp/john/ChatHandler.java +++ b/src/main/java/org/examp/john/ChatHandler.java @@ -20,74 +20,90 @@ import java.util.List; public class ChatHandler { public static void register() { + ServerMessageEvents.ALLOW_CHAT_MESSAGE.register((message, sender, params) -> { + try { + return handleMessage(message.signedContent(), sender); + } catch (Exception e) { + MSNexus.magicModLog.error( + "[CHAT] Не вдалося обробити повідомлення від {}", sender.getName().getString(), e); + return false; + } + }); + } + + private static boolean handleMessage(String text, ServerPlayer sender) { final String prefix = "!"; final float LOCAL_CHAT_RADIUS = 100; - ServerMessageEvents.ALLOW_CHAT_MESSAGE.register((message, sender, params) -> { - String text = message.signedContent(); - ServerLevel serverLevel = sender.level(); - List players = serverLevel.getServer().getPlayerList().getPlayers(); + if (text.isBlank()) { + return false; + } - MutableComponent formattedName = buildFormattedName(sender); + ServerLevel serverLevel = sender.level(); + List players = serverLevel.getServer().getPlayerList().getPlayers(); - if (text.startsWith(prefix)) { - String actualMessage = text.substring(1).trim(); - Component globalMessage = Component.empty() - .append(formattedName) - .append(Component.literal(": ")) - .append(buildFormattedMessage(actualMessage, players)); + MutableComponent formattedName = buildFormattedName(sender); - for (ServerPlayer player : players) { - player.sendSystemMessage(globalMessage); - } + if (text.startsWith(prefix)) { + String actualMessage = text.substring(1).trim(); + if (actualMessage.isEmpty()) { + return false; + } + Component globalMessage = Component.empty() + .append(formattedName) + .append(Component.literal(": ")) + .append(buildFormattedMessage(actualMessage, players)); - ChatLogger.logGlobal(sender, actualMessage, players); + for (ServerPlayer player : players) { + player.sendSystemMessage(globalMessage); + } - } else { - AntiAbuse.recordMessage(sender, text); + ChatLogger.logGlobal(sender, actualMessage, players); - if (StealthStorage.isEnabled(sender.getUUID())) { - sender.sendSystemMessage( - Component.literal("Вас ніхто не почув...") - .withStyle(ChatFormatting.ITALIC, ChatFormatting.YELLOW) - ); - ChatLogger.logUnheard(sender, text); - return false; - } + } else { + AntiAbuse.recordMessage(sender, text); - List hearers = new ArrayList<>(); - for (ServerPlayer player : players) { - if (player != sender - && player.level() == serverLevel - && player.distanceTo(sender) <= LOCAL_CHAT_RADIUS) { - hearers.add(player); - } - } + if (StealthStorage.isEnabled(sender.getUUID())) { + sender.sendSystemMessage( + Component.literal("Вас ніхто не почув...") + .withStyle(ChatFormatting.ITALIC, ChatFormatting.YELLOW) + ); + ChatLogger.logUnheard(sender, text); + return false; + } - if (!hearers.isEmpty()) { - Component localMessage = Component.empty() - .append(formattedName) - .append(Component.literal(": ").withStyle(ChatFormatting.GRAY)) - .append(buildFormattedMessage(text, players).withStyle(ChatFormatting.GRAY)); - - sender.sendSystemMessage(localMessage); - for (ServerPlayer hearer : hearers) { - hearer.sendSystemMessage(localMessage); - } - - ChatLogger.logLocal(sender, text, hearers); - - } else { - sender.sendSystemMessage( - Component.literal("Вас ніхто не почув, додайте \"!\" на початок для глобального чату") - .withStyle(ChatFormatting.ITALIC, ChatFormatting.YELLOW) - ); - - ChatLogger.logUnheard(sender, text); + List hearers = new ArrayList<>(); + for (ServerPlayer player : players) { + if (player != sender + && player.level() == serverLevel + && player.distanceTo(sender) <= LOCAL_CHAT_RADIUS) { + hearers.add(player); } } - return false; - }); + + if (!hearers.isEmpty()) { + Component localMessage = Component.empty() + .append(formattedName) + .append(Component.literal(": ").withStyle(ChatFormatting.GRAY)) + .append(buildFormattedMessage(text, players).withStyle(ChatFormatting.GRAY)); + + sender.sendSystemMessage(localMessage); + for (ServerPlayer hearer : hearers) { + hearer.sendSystemMessage(localMessage); + } + + ChatLogger.logLocal(sender, text, hearers); + + } else { + sender.sendSystemMessage( + Component.literal("Вас ніхто не почув, додайте \"!\" на початок для глобального чату") + .withStyle(ChatFormatting.ITALIC, ChatFormatting.YELLOW) + ); + + ChatLogger.logUnheard(sender, text); + } + } + return false; } /** diff --git a/src/main/java/org/examp/john/MSNexus.java b/src/main/java/org/examp/john/MSNexus.java index 4b067d9..f1c86b2 100644 --- a/src/main/java/org/examp/john/MSNexus.java +++ b/src/main/java/org/examp/john/MSNexus.java @@ -13,6 +13,7 @@ public class MSNexus implements ModInitializer { @Override public void onInitialize() { StealthStorage.load(); + AntiAbuse.register(); ChatHandler.register(); StealthCommand.register(); } diff --git a/src/main/java/org/examp/john/mixin/CommandLogMixin.java b/src/main/java/org/examp/john/mixin/CommandLogMixin.java index dc5e0f3..e159bf8 100644 --- a/src/main/java/org/examp/john/mixin/CommandLogMixin.java +++ b/src/main/java/org/examp/john/mixin/CommandLogMixin.java @@ -32,7 +32,7 @@ public class CommandLogMixin { CommandLogger.logCommand(player, command); } } catch (Exception e) { - org.examp.john.MSNexus.magicModLog.warn("[CMD] Не вдалося залогувати команду: {}", e.getMessage()); + org.examp.john.MSNexus.magicModLog.warn("[CMD] Не вдалося залогувати команду", e); } } } diff --git a/src/main/java/org/examp/john/stealth/StealthStorage.java b/src/main/java/org/examp/john/stealth/StealthStorage.java index c273f9f..10576f4 100644 --- a/src/main/java/org/examp/john/stealth/StealthStorage.java +++ b/src/main/java/org/examp/john/stealth/StealthStorage.java @@ -2,7 +2,7 @@ package org.examp.john.stealth; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.google.gson.JsonSyntaxException; +import net.fabricmc.loader.api.FabricLoader; import org.examp.john.MSNexus; import java.io.IOException; @@ -11,6 +11,7 @@ import java.io.Writer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.HashMap; import java.util.Map; import java.util.UUID; @@ -23,7 +24,9 @@ import java.util.UUID; 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 final Path CONFIG_PATH = FabricLoader.getInstance().getConfigDir() + .resolve("localchat") + .resolve("stealth.json"); private static Map data = new HashMap<>(); @@ -35,23 +38,33 @@ public class StealthStorage { return; } try (Reader reader = Files.newBufferedReader(CONFIG_PATH, StandardCharsets.UTF_8)) { - @SuppressWarnings("unchecked") - Map raw = GSON.fromJson(reader, Map.class); + 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()); + for (Map.Entry entry : raw.entrySet()) { + try { + UUID uuid = UUID.fromString(entry.getKey()); + if (entry.getValue() instanceof Boolean value) { + data.put(uuid, value); + } else { + MSNexus.magicModLog.warn( + "stealth.json: пропущено некоректне значення для {}", entry.getKey()); + } + } catch (IllegalArgumentException e) { + MSNexus.magicModLog.warn( + "stealth.json: пропущено некоректний ключ {}", entry.getKey()); + } } } } - } catch (IOException | JsonSyntaxException | IllegalArgumentException e) { + } catch (IOException | RuntimeException e) { MSNexus.magicModLog.warn("Не вдалося прочитати stealth.json: {}", e.getMessage()); data = new HashMap<>(); } } public static boolean isEnabled(UUID uuid) { - return data.getOrDefault(uuid, false); + return Boolean.TRUE.equals(data.get(uuid)); } public static boolean toggle(UUID uuid) { @@ -70,13 +83,20 @@ public class StealthStorage { private static void save() { try { Files.createDirectories(CONFIG_PATH.getParent()); - try (Writer writer = Files.newBufferedWriter(CONFIG_PATH, StandardCharsets.UTF_8)) { + Path tmp = CONFIG_PATH.resolveSibling(CONFIG_PATH.getFileName() + ".tmp"); + try (Writer writer = Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) { Map raw = new HashMap<>(); for (Map.Entry entry : data.entrySet()) { raw.put(entry.getKey().toString(), entry.getValue()); } GSON.toJson(raw, writer); } + try { + Files.move(tmp, CONFIG_PATH, + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + Files.move(tmp, CONFIG_PATH, StandardCopyOption.REPLACE_EXISTING); + } } catch (IOException e) { MSNexus.magicModLog.error("Не вдалося зберегти stealth.json: {}", e.getMessage()); } From f0b5d603adce2f2f148e18140d5a469b5e42759e Mon Sep 17 00:00:00 2001 From: John Etherfield Date: Sun, 4 Oct 2026 11:44:27 +0200 Subject: [PATCH 8/9] fix: give chat mentions default highlight color and support @tags --- src/main/java/org/examp/john/ChatHandler.java | 92 ++++++++++++++----- 1 file changed, 69 insertions(+), 23 deletions(-) diff --git a/src/main/java/org/examp/john/ChatHandler.java b/src/main/java/org/examp/john/ChatHandler.java index 5c565d8..f96c139 100644 --- a/src/main/java/org/examp/john/ChatHandler.java +++ b/src/main/java/org/examp/john/ChatHandler.java @@ -17,6 +17,7 @@ import org.examp.john.stealth.StealthStorage; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Locale; public class ChatHandler { public static void register() { @@ -149,10 +150,11 @@ public class ChatHandler { while (!remaining.isEmpty()) { int earliestIndex = remaining.length(); ServerPlayer mentioned = null; + String lower = remaining.toLowerCase(Locale.ROOT); for (ServerPlayer player : sorted) { - String name = player.getName().getString(); - int index = remaining.toLowerCase().indexOf(name.toLowerCase()); + String nameLower = player.getName().getString().toLowerCase(Locale.ROOT); + int index = indexOfBoundary(lower, nameLower); if (index >= 0 && index < earliestIndex) { earliestIndex = index; mentioned = player; @@ -164,11 +166,13 @@ public class ChatHandler { break; } - if (earliestIndex > 0) { - result.append(Component.literal(remaining.substring(0, earliestIndex))); + boolean withAt = earliestIndex > 0 && remaining.charAt(earliestIndex - 1) == '@'; + int prefixEnd = withAt ? earliestIndex - 1 : earliestIndex; + if (prefixEnd > 0) { + result.append(Component.literal(remaining.substring(0, prefixEnd))); } - result.append(createMentionComponent(mentioned)); + result.append(createMentionComponent(mentioned, withAt)); remaining = remaining.substring(earliestIndex + mentioned.getName().getString().length()); } @@ -176,13 +180,32 @@ public class ChatHandler { return result; } - private static Component createMentionComponent(ServerPlayer player) { - String prefix = getLuckPermsPrefix(player); - String formattedPrefix = prefix.replace("&", "\u00a7"); - String name = player.getName().getString(); + private static int indexOfBoundary(String lowerHaystack, String lowerNeedle) { + int index = lowerHaystack.indexOf(lowerNeedle); + while (index >= 0) { + boolean okBefore = index == 0 || !isNameChar(lowerHaystack.charAt(index - 1)); + boolean okAfter = index + lowerNeedle.length() == lowerHaystack.length() + || !isNameChar(lowerHaystack.charAt(index + lowerNeedle.length())); + if (okBefore && okAfter) { + return index; + } + index = lowerHaystack.indexOf(lowerNeedle, index + 1); + } + return -1; + } - return Component.literal(formattedPrefix + name) + private static boolean isNameChar(char c) { + return Character.isLetterOrDigit(c) || c == '_'; + } + + private static Component createMentionComponent(ServerPlayer player, boolean withAt) { + String name = player.getName().getString(); + String prefix = formatLegacyPrefix(getLuckPermsPrefix(player)); + String text = (withAt ? "@" : "") + prefix + name; + + return Component.literal(text) .withStyle(Style.EMPTY + .withColor(ChatFormatting.AQUA) .withClickEvent(new ClickEvent.SuggestCommand("/msg " + name + " ")) .withHoverEvent(new HoverEvent.ShowText( Component.literal("Натисніть щоб написати " + name) @@ -190,6 +213,40 @@ public class ChatHandler { ); } + private static String formatLegacyPrefix(String raw) { + if (raw == null || raw.isEmpty()) { + return ""; + } + String s = raw.replace('&', '§'); + StringBuilder out = new StringBuilder(s.length() + 14); + int i = 0; + while (i < s.length()) { + if (s.charAt(i) == '§' && i + 8 <= s.length() && s.charAt(i + 1) == '#' + && isSixHex(s, i + 2)) { + out.append("§x"); + for (int j = 2; j < 8; j++) { + out.append('§').append(s.charAt(i + j)); + } + i += 8; + } else { + out.append(s.charAt(i)); + i++; + } + } + return out.toString(); + } + + private static boolean isSixHex(String s, int start) { + for (int i = start; i < start + 6; i++) { + char c = s.charAt(i); + boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) { + return false; + } + } + return true; + } + private static String getLuckPermsPrefix(ServerPlayer player) { try { LuckPerms luckPerms = LuckPermsProvider.get(); @@ -206,21 +263,10 @@ public class ChatHandler { } 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) { - } + String prefix = formatLegacyPrefix(getLuckPermsPrefix(player)); return Component.empty() - .append(Component.literal(prefix.replace("&", "§"))) + .append(Component.literal(prefix)) .append(player.getDisplayName()); } } From ae102817b6f41e02ba19e100834b9c8ab770d344 Mon Sep 17 00:00:00 2001 From: John Etherfield Date: Sun, 4 Oct 2026 19:28:30 +0200 Subject: [PATCH 9/9] fix: revert spectator tab highlight mixin to restore vanilla spectator gameplay --- .../examp/john/mixin/SpectatorTabMixin.java | 24 ------------------- src/main/resources/msnexus.mixins.json | 3 +-- 2 files changed, 1 insertion(+), 26 deletions(-) delete mode 100644 src/main/java/org/examp/john/mixin/SpectatorTabMixin.java diff --git a/src/main/java/org/examp/john/mixin/SpectatorTabMixin.java b/src/main/java/org/examp/john/mixin/SpectatorTabMixin.java deleted file mode 100644 index 2d52508..0000000 --- a/src/main/java/org/examp/john/mixin/SpectatorTabMixin.java +++ /dev/null @@ -1,24 +0,0 @@ -package org.examp.john.mixin; - -import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket; -import net.minecraft.server.level.ServerPlayer; -import net.minecraft.world.level.GameType; -import org.spongepowered.asm.mixin.Mixin; -import org.spongepowered.asm.mixin.injection.At; -import org.spongepowered.asm.mixin.injection.Redirect; - -@Mixin(ClientboundPlayerInfoUpdatePacket.Entry.class) -public class SpectatorTabMixin { - - @Redirect( - method = "(Lnet/minecraft/server/level/ServerPlayer;)V", - at = @At( - value = "INVOKE", - target = "Lnet/minecraft/server/level/ServerPlayer;gameMode()Lnet/minecraft/world/level/GameType;" - ) - ) - private static GameType msnexus$highlightSpectatorsInTab(ServerPlayer player) { - GameType gameMode = player.gameMode(); - return gameMode == GameType.SPECTATOR ? GameType.SURVIVAL : gameMode; - } -} diff --git a/src/main/resources/msnexus.mixins.json b/src/main/resources/msnexus.mixins.json index fb727b4..8a660c1 100644 --- a/src/main/resources/msnexus.mixins.json +++ b/src/main/resources/msnexus.mixins.json @@ -4,8 +4,7 @@ "package": "org.examp.john.mixin", "compatibilityLevel": "JAVA_25", "mixins": [ - "CommandLogMixin", - "SpectatorTabMixin" + "CommandLogMixin" ], "injectors": { "defaultRequire": 1