Compare commits

Author SHA1 Message Date
john 2edff88cac feat: add stealth mode and anti-abuse chat detectionD 2026-08-14 16:40:24 +02:00
ProstoMaslo 080a838028 REFACTOR: Change modid, modname e.t.c 2026-08-11 20:44:28 +03:00
12 changed files with 376 additions and 50 deletions
+1 -1
View File
@@ -4,5 +4,5 @@ yarn_mappings=1.21.11+build.6
loader_version=0.19.2 loader_version=0.19.2
mod_version=1.0 mod_version=1.0
maven_group=org.example1 maven_group=org.example1
archives_base_name=localhost archives_base_name=msnexus
fabric_version=0.155.2+26.1.2 fabric_version=0.155.2+26.1.2
@@ -2,7 +2,7 @@ package org.examp.john.client;
import net.fabricmc.api.ClientModInitializer; import net.fabricmc.api.ClientModInitializer;
public class LocalchatClient implements ClientModInitializer { public class MSNexusClient implements ClientModInitializer {
@Override @Override
public void onInitializeClient() { public void onInitializeClient() {
@@ -3,7 +3,7 @@ package org.examp.john.client;
import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint; import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint;
import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator; import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator;
public class LocalchatDataGenerator implements DataGeneratorEntrypoint { public class MSNexusDataGenerator implements DataGeneratorEntrypoint {
@Override @Override
public void onInitializeDataGenerator(FabricDataGenerator fabricDataGenerator) { public void onInitializeDataGenerator(FabricDataGenerator fabricDataGenerator) {
@@ -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<UUID, List<MessageRecord>> history = new HashMap<>();
public static void recordMessage(ServerPlayer player, String message) {
UUID uuid = player.getUUID();
List<MessageRecord> 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<MessageRecord> records) {
Map<String, int[]> counts = new HashMap<>();
Map<String, double[]> 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<String, int[]> 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) {}
}
+37 -31
View File
@@ -7,9 +7,9 @@ import net.luckperms.api.model.user.User;
import net.minecraft.ChatFormatting; import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent; import net.minecraft.network.chat.MutableComponent;
import net.minecraft.server.MinecraftServer;
import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer; import net.minecraft.server.level.ServerPlayer;
import org.examp.john.stealth.StealthStorage;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@@ -22,29 +22,11 @@ public class ChatHandler {
ServerMessageEvents.ALLOW_CHAT_MESSAGE.register((message, sender, params) -> { ServerMessageEvents.ALLOW_CHAT_MESSAGE.register((message, sender, params) -> {
String text = message.signedContent(); String text = message.signedContent();
ServerLevel serverLevel = sender.level(); ServerLevel serverLevel = sender.level();
MinecraftServer server = serverLevel.getServer(); List<ServerPlayer> players = serverLevel.getServer().getPlayerList().getPlayers();
List<ServerPlayer> players = server.getPlayerList().getPlayers();
String playerPrefix = ""; MutableComponent formattedName = buildFormattedName(sender);
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());
if (text.startsWith(prefix)) { if (text.startsWith(prefix)) {
// ГЛОБАЛЬНИЙ ЧАТ
String actualMessage = text.substring(1).trim(); String actualMessage = text.substring(1).trim();
Component globalMessage = Component.empty() Component globalMessage = Component.empty()
.append(formattedName) .append(formattedName)
@@ -54,46 +36,70 @@ public class ChatHandler {
player.sendSystemMessage(globalMessage); player.sendSystemMessage(globalMessage);
} }
// Логування: глобальний чат чують всі онлайн-гравці
ChatLogger.logGlobal(sender, actualMessage, players); ChatLogger.logGlobal(sender, actualMessage, players);
} else { } 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<ServerPlayer> hearers = new ArrayList<>(); List<ServerPlayer> hearers = new ArrayList<>();
for (ServerPlayer player : players) { for (ServerPlayer player : players) {
if (player.level() == serverLevel && player.distanceTo(sender) <= LOCAL_CHAT_RADIUS) { if (player != sender
if (player != sender) { && player.level() == serverLevel
&& player.distanceTo(sender) <= LOCAL_CHAT_RADIUS) {
hearers.add(player); hearers.add(player);
} }
} }
}
if (!hearers.isEmpty()) { if (!hearers.isEmpty()) {
Component localMessage = Component.empty() Component localMessage = Component.empty()
.append(formattedName) .append(formattedName)
.append(Component.literal(": " + text).withStyle(ChatFormatting.GRAY)); .append(Component.literal(": " + text).withStyle(ChatFormatting.GRAY));
// ОПТИМІЗАЦІЯ: Замість повторної перевірки всіх гравців сервера,
// просто відправляємо повідомлення самому відправнику та вже готовому списку слухачів.
sender.sendSystemMessage(localMessage); sender.sendSystemMessage(localMessage);
for (ServerPlayer hearer : hearers) { for (ServerPlayer hearer : hearers) {
hearer.sendSystemMessage(localMessage); hearer.sendSystemMessage(localMessage);
} }
// Логування: локальний чат, хто почув + координати
ChatLogger.logLocal(sender, text, hearers); ChatLogger.logLocal(sender, text, hearers);
} else { } else {
sender.sendSystemMessage( sender.sendSystemMessage(
Component.literal("Вас ніхто не почув, додайте на початок повідомлення \"!\" аби написати в глобальний чат") Component.literal("Вас ніхто не почув, додайте \"!\" на початок для глобального чату")
.withStyle(ChatFormatting.ITALIC, ChatFormatting.YELLOW) .withStyle(ChatFormatting.ITALIC, ChatFormatting.YELLOW)
); );
// Логування: ніхто не почув
ChatLogger.logUnheard(sender, text); ChatLogger.logUnheard(sender, text);
} }
} }
return false; return false;
}); });
} }
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());
}
} }
+3 -3
View File
@@ -14,7 +14,7 @@ public class ChatLogger {
public static void logGlobal(ServerPlayer sender, String message, List<ServerPlayer> recipients) { public static void logGlobal(ServerPlayer sender, String message, List<ServerPlayer> recipients) {
// Для глобального чату ігноруємо список отримувачів і просто пишемо "everyone" // Для глобального чату ігноруємо список отримувачів і просто пишемо "everyone"
Localchat.magicModLog.info( MSNexus.magicModLog.info(
"[CHAT][GLOBAL] {} said: \"{}\" | heard by: everyone | pos: {}", "[CHAT][GLOBAL] {} said: \"{}\" | heard by: everyone | pos: {}",
sender.getName().getString(), sender.getName().getString(),
message, message,
@@ -30,7 +30,7 @@ public class ChatLogger {
.map(p -> p.getName().getString()) .map(p -> p.getName().getString())
.collect(Collectors.joining(", ")); .collect(Collectors.joining(", "));
Localchat.magicModLog.info( MSNexus.magicModLog.info(
"[CHAT][LOCAL] {} said: \"{}\" | heard by: {} | pos: {}", "[CHAT][LOCAL] {} said: \"{}\" | heard by: {} | pos: {}",
sender.getName().getString(), sender.getName().getString(),
message, message,
@@ -40,7 +40,7 @@ public class ChatLogger {
} }
public static void logUnheard(ServerPlayer sender, String message) { public static void logUnheard(ServerPlayer sender, String message) {
Localchat.magicModLog.info( MSNexus.magicModLog.info(
"[CHAT][LOCAL][UNHEARD] {} said: \"{}\" | heard by: nobody | pos: {}", "[CHAT][LOCAL][UNHEARD] {} said: \"{}\" | heard by: nobody | pos: {}",
sender.getName().getString(), sender.getName().getString(),
message, message,
@@ -1,16 +1,19 @@
package org.examp.john; package org.examp.john;
import net.fabricmc.api.ModInitializer; import net.fabricmc.api.ModInitializer;
import org.examp.john.stealth.StealthCommand;
import org.examp.john.stealth.StealthStorage;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
public class Localchat implements ModInitializer { public class MSNexus implements ModInitializer {
public static final String MOD_ID = "localhost"; public static final String MOD_ID = "msnexus";
public static final Logger magicModLog = LoggerFactory.getLogger(MOD_ID); public static final Logger magicModLog = LoggerFactory.getLogger(MOD_ID);
@Override @Override
public void onInitialize() { public void onInitialize() {
// Тепер ChatHandler викликається з того ж пакета без помилок імпорту StealthStorage.load();
ChatHandler.register(); ChatHandler.register();
StealthCommand.register();
} }
} }
@@ -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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> context) {
if (!(context.getSource().getEntity() instanceof ServerPlayer player)) {
context.getSource().sendFailure(
Component.literal("Цю команду можна викликати лише в грі."));
return null;
}
return player;
}
}
@@ -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<UUID, Boolean> 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<String, Boolean> raw = GSON.fromJson(reader, Map.class);
data = new HashMap<>();
if (raw != null) {
for (Map.Entry<String, Boolean> 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<String, Boolean> raw = new HashMap<>();
for (Map.Entry<UUID, Boolean> entry : data.entrySet()) {
raw.put(entry.getKey().toString(), entry.getValue());
}
GSON.toJson(raw, writer);
}
} catch (IOException e) {
Localchat.magicModLog.error("Не вдалося зберегти stealth.json: {}", e.getMessage());
}
}
}
+8 -8
View File
@@ -1,29 +1,29 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"id": "localchat", "id": "msnexus",
"version": "${version}", "version": "${version}",
"name": "localchat", "name": "msnexus",
"description": "", "description": "",
"authors": [], "authors": [],
"contact": {}, "contact": {},
"license": "All-Rights-Reserved", "license": "All-Rights-Reserved",
"icon": "assets/localchat/icon.png", "icon": "assets/msnexus/icon.png",
"environment": "*", "environment": "*",
"entrypoints": { "entrypoints": {
"fabric-datagen": [ "fabric-datagen": [
"org.examp.john.client.LocalchatDataGenerator" "org.examp.john.client.MSNexusDataGenerator"
], ],
"client": [ "client": [
"org.examp.john.client.LocalchatClient" "org.examp.john.client.MSNexusClient"
], ],
"main": [ "main": [
"org.examp.john.Localchat" "org.examp.john.MSNexus"
] ]
}, },
"mixins": [ "mixins": [
"localchat.mixins.json", "msnexus.mixins.json",
{ {
"config": "localchat.client.mixins.json", "config": "msnexus.client.mixins.json",
"environment": "client" "environment": "client"
} }
], ],