Compare commits

15 changed files with 322 additions and 52 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=msnexus archives_base_name=localhost
fabric_version=0.155.2+26.1.2 fabric_version=0.155.2+26.1.2
@@ -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 MSNexusDataGenerator implements DataGeneratorEntrypoint { public class LocalchatDataGenerator implements DataGeneratorEntrypoint {
@Override @Override
public void onInitializeDataGenerator(FabricDataGenerator fabricDataGenerator) { public void onInitializeDataGenerator(FabricDataGenerator fabricDataGenerator) {
@@ -1,10 +0,0 @@
package org.examp.john.client;
import net.fabricmc.api.ClientModInitializer;
public class MSNexusClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
}
}
+2 -6
View File
@@ -11,12 +11,8 @@ import java.util.UUID;
/** /**
* Детектить патерн пошуку гравців через локальний чат. * Детектить патерн пошуку гравців через локальний чат.
* *
* Алгоритм: * Якщо одне і те ж повідомлення повторюється >=5 разів за 5 хвилин
* 1. Записує кожне локальне повідомлення (текст + координати + час) * і гравець перемістився >50 блоків — логує в консоль.
* 2. Якщо одне і те ж повідомлення повторюється >=5 разів за 5 хвилин
* І гравець перемістився більше ніж на 50 блоків —
* це очевидний пошук гравців.
* 3. Логує в термінал для адміна.
*/ */
public class AntiAbuse { public class AntiAbuse {
+19 -6
View File
@@ -5,8 +5,11 @@ import net.luckperms.api.LuckPerms;
import net.luckperms.api.LuckPermsProvider; import net.luckperms.api.LuckPermsProvider;
import net.luckperms.api.model.user.User; import net.luckperms.api.model.user.User;
import net.minecraft.ChatFormatting; import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.ClickEvent;
import net.minecraft.network.chat.Component; import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.HoverEvent;
import net.minecraft.network.chat.MutableComponent; import net.minecraft.network.chat.MutableComponent;
import net.minecraft.network.chat.Style;
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 org.examp.john.stealth.StealthStorage;
@@ -85,21 +88,31 @@ public class ChatHandler {
} }
private static MutableComponent buildFormattedName(ServerPlayer player) { private static MutableComponent buildFormattedName(ServerPlayer player) {
String prefix = ""; 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 { try {
LuckPerms luckPerms = LuckPermsProvider.get(); LuckPerms luckPerms = LuckPermsProvider.get();
User user = luckPerms.getUserManager().getUser(player.getUUID()); User user = luckPerms.getUserManager().getUser(player.getUUID());
if (user != null) { if (user != null) {
String metaPrefix = user.getCachedData().getMetaData().getPrefix(); String metaPrefix = user.getCachedData().getMetaData().getPrefix();
if (metaPrefix != null) { if (metaPrefix != null) {
prefix = metaPrefix; return metaPrefix;
} }
} }
} catch (Throwable ignored) { } catch (Throwable ignored) {
} }
return "";
return Component.empty()
.append(Component.literal(prefix.replace("&", "§")))
.append(player.getDisplayName());
} }
} }
+3 -8
View File
@@ -13,8 +13,7 @@ import java.util.stream.Collectors;
public class ChatLogger { 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" 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,
@@ -23,14 +22,13 @@ public class ChatLogger {
} }
public static void logLocal(ServerPlayer sender, String message, List<ServerPlayer> recipients) { public static void logLocal(ServerPlayer sender, String message, List<ServerPlayer> recipients) {
// Для локального чату виводимо лише імена отримувачів, без їхніх координат
String heardBy = recipients.isEmpty() String heardBy = recipients.isEmpty()
? "nobody" ? "nobody"
: recipients.stream() : recipients.stream()
.map(p -> p.getName().getString()) .map(p -> p.getName().getString())
.collect(Collectors.joining(", ")); .collect(Collectors.joining(", "));
MSNexus.magicModLog.info( Localchat.magicModLog.info(
"[CHAT][LOCAL] {} said: \"{}\" | heard by: {} | pos: {}", "[CHAT][LOCAL] {} said: \"{}\" | heard by: {} | pos: {}",
sender.getName().getString(), sender.getName().getString(),
message, message,
@@ -40,7 +38,7 @@ public class ChatLogger {
} }
public static void logUnheard(ServerPlayer sender, String message) { public static void logUnheard(ServerPlayer sender, String message) {
MSNexus.magicModLog.info( Localchat.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,
@@ -50,9 +48,6 @@ public class ChatLogger {
private static String formatPos(ServerPlayer player) { private static String formatPos(ServerPlayer player) {
ServerLevel level = player.level(); ServerLevel level = player.level();
// У ResourceKey дістаємо Identifier/ResourceLocation через .identifier(),
// після чого через .getPath() беремо чисту назву ("overworld", "the_nether", "the_end")
String rawDimension = level.dimension().identifier().getPath(); String rawDimension = level.dimension().identifier().getPath();
return String.format( return String.format(
@@ -6,8 +6,8 @@ import org.examp.john.stealth.StealthStorage;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
public class MSNexus implements ModInitializer { public class Localchat implements ModInitializer {
public static final String MOD_ID = "msnexus"; public static final String MOD_ID = "localhost";
public static final Logger magicModLog = LoggerFactory.getLogger(MOD_ID); public static final Logger magicModLog = LoggerFactory.getLogger(MOD_ID);
@Override @Override
@@ -0,0 +1,127 @@
package org.examp.john.rules;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.network.Filterable;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.component.WrittenBookContent;
import org.examp.john.Localchat;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Реєструє команду /rules. Повністю server-side: не потребує жодного мода
* на клієнті гравця. Книга створюється програмно через Data Components API
* і відкривається через openItemGui (ClientboundOpenBookPacket).
* Книга НЕ залишається в інвентарі — тимчасово ставиться в руку,
* відкривається, одразу повертається оригінальний предмет.
*/
public class RulesCommand {
private static final Map<UUID, Long> cooldowns = new HashMap<>();
private static final long COOLDOWN_MS = 5000;
public static void register() {
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) ->
dispatcher.register(buildCommand())
);
}
private static LiteralArgumentBuilder<CommandSourceStack> buildCommand() {
return Commands.literal("rules").executes(RulesCommand::execute);
}
private static int execute(CommandContext<CommandSourceStack> context) {
CommandSourceStack source = context.getSource();
if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Цю команду можна викликати лише в грі."));
return 0;
}
long now = System.currentTimeMillis();
Long lastUsed = cooldowns.get(player.getUUID());
if (lastUsed != null && (now - lastUsed) < COOLDOWN_MS) {
long remainingMs = COOLDOWN_MS - (now - lastUsed);
long secondsLeft = (remainingMs + 999) / 1000;
player.sendSystemMessage(Component.literal(
"Зачекайте ще " + secondsLeft + " сек. перед повторним використанням /rules."
));
return 0;
}
cooldowns.put(player.getUUID(), now);
RulesData.RulesRoot rules = RulesLoader.get();
if (rules.sections == null || rules.sections.isEmpty()) {
source.sendFailure(Component.literal("Правила ще не налаштовані. Зверніться до адміністрації."));
return 0;
}
openBook(player, rules);
return 1;
}
private static void openBook(ServerPlayer player, RulesData.RulesRoot rules) {
InteractionHand hand = InteractionHand.MAIN_HAND;
ItemStack originalItem = player.getItemInHand(hand).copy();
ItemStack book = createRuleBook(rules);
Localchat.magicModLog.info("Відкриваємо книгу правил для {}", player.getName().getString());
player.setItemInHand(hand, book);
player.containerMenu.sendAllDataToRemote();
player.openItemGui(book, hand);
player.setItemInHand(hand, originalItem);
}
private static ItemStack createRuleBook(RulesData.RulesRoot rules) {
ItemStack book = new ItemStack(Items.WRITTEN_BOOK);
List<Filterable<Component>> pages = new ArrayList<>();
for (RulesData.Section section : rules.sections) {
pages.add(Filterable.passThrough(buildPageComponent(section)));
}
WrittenBookContent content = new WrittenBookContent(
Filterable.passThrough("Правила серверу"),
"Server",
0,
pages,
true
);
book.set(DataComponents.WRITTEN_BOOK_CONTENT, content);
return book;
}
private static Component buildPageComponent(RulesData.Section section) {
StringBuilder pageText = new StringBuilder();
pageText.append("§l§n").append(section.title).append("§r\n\n");
if (section.entries != null) {
for (RulesData.Entry entry : section.entries) {
pageText.append(entry.severity.colorCode)
.append("[!] §r")
.append(entry.number)
.append(". ")
.append(entry.text)
.append("\n\n");
}
}
return Component.literal(pageText.toString());
}
}
@@ -0,0 +1,82 @@
package org.examp.john.rules;
import java.util.List;
/**
* Моделі даних для системи правил сервера.
* Структура JSON конфігу:
* {
* "sections": [
* {
* "title": "Правила будівництва",
* "entries": [
* { "number": "1.1", "text": "Заборонено будувати без дозволу...", "severity": "MEDIUM" }
* ]
* }
* ]
* }
*/
public class RulesData {
/**
* Рівень тяжкості порушення. Визначає колір символу-значка в книзі правил
* через ванільні коди форматування (§) — книга рендериться повністю клієнтом,
* без жодного мода на клієнті.
* LIGHT -> зелений (§a)
* MEDIUM -> жовтий (§e)
* HEAVY -> червоний (§c)
*/
public enum Severity {
LIGHT("§a"),
MEDIUM("§e"),
HEAVY("§c");
public final String colorCode;
Severity(String colorCode) {
this.colorCode = colorCode;
}
public static Severity fromString(String raw) {
if (raw == null) return LIGHT;
try {
return Severity.valueOf(raw.trim().toUpperCase());
} catch (IllegalArgumentException e) {
return LIGHT;
}
}
}
/** Один пункт правила всередині розділу. */
public static class Entry {
public String number; // напр. "1.1" або "IV"
public String text; // текст правила / опис порушення
public Severity severity;
public Entry(String number, String text, Severity severity) {
this.number = number;
this.text = text;
this.severity = severity;
}
}
/** Розділ правил (вкладка в GUI), напр. "Правила будівництва". */
public static class Section {
public String title;
public List<Entry> entries;
public Section(String title, List<Entry> entries) {
this.title = title;
this.entries = entries;
}
}
/** Корінь конфігу — список усіх розділів. */
public static class RulesRoot {
public List<Section> sections;
public RulesRoot(List<Section> sections) {
this.sections = sections;
}
}
}
@@ -0,0 +1,78 @@
package org.examp.john.rules;
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.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
/**
* Завантажує та кешує rules.json з папки config мода.
* Файл лежить у config/localchat/rules.json — адміни можуть редагувати
* без перезбирання мода, зміни підхоплюються після /rules reload (додамо пізніше)
* або перезапуску сервера.
*/
public class RulesLoader {
private static final Gson GSON = new GsonBuilder().create();
private static final Path CONFIG_PATH = Path.of("config", "localchat", "rules.json");
private static RulesData.RulesRoot cached;
/** Повертає закешовані правила, або завантажує їх з диску при першому виклику. */
public static RulesData.RulesRoot get() {
if (cached == null) {
cached = load();
}
return cached;
}
/** Примусово перечитує файл з диску (для майбутньої команди /rules reload). */
public static RulesData.RulesRoot reload() {
cached = load();
return cached;
}
private static RulesData.RulesRoot load() {
try {
if (!Files.exists(CONFIG_PATH)) {
createDefaultConfig();
}
try (Reader reader = Files.newBufferedReader(CONFIG_PATH, StandardCharsets.UTF_8)) {
RulesData.RulesRoot root = GSON.fromJson(reader, RulesData.RulesRoot.class);
if (root == null || root.sections == null) {
Localchat.magicModLog.warn("rules.json порожній або невалідний, використовую пустий список правил");
return new RulesData.RulesRoot(Collections.emptyList());
}
return root;
}
} catch (IOException e) {
Localchat.magicModLog.error("Не вдалося прочитати rules.json: {}", e.getMessage());
return new RulesData.RulesRoot(Collections.emptyList());
} catch (JsonSyntaxException e) {
Localchat.magicModLog.error("rules.json містить синтаксичну помилку JSON: {}", e.getMessage());
return new RulesData.RulesRoot(Collections.emptyList());
}
}
private static void createDefaultConfig() throws IOException {
Files.createDirectories(CONFIG_PATH.getParent());
RulesData.Entry defaultEntry = new RulesData.Entry("1.1", "Пункт 1", RulesData.Severity.LIGHT);
RulesData.Section defaultSection = new RulesData.Section("Правила", Collections.singletonList(defaultEntry));
RulesData.RulesRoot defaultRoot = new RulesData.RulesRoot(Collections.singletonList(defaultSection));
try (var writer = Files.newBufferedWriter(CONFIG_PATH, StandardCharsets.UTF_8)) {
new GsonBuilder().setPrettyPrinting().create().toJson(defaultRoot, writer);
}
Localchat.magicModLog.info("Створено конфіг за замовчуванням: {}", CONFIG_PATH.toAbsolutePath());
}
}
@@ -17,10 +17,6 @@ import net.minecraft.server.level.ServerPlayer;
* /stealth on — увімкнути stealth * /stealth on — увімкнути stealth
* /stealth off — вимкнути stealth * /stealth off — вимкнути stealth
* /stealth info — показати інформацію про команду * /stealth info — показати інформацію про команду
*
* Коли stealth увімкнено, повідомлення локального чату від гравця
* не доходять до інших — гравець бачить "Вас ніхто не почув..."
* Глобальний чат (! префікс) працює як зазвичай.
*/ */
public class StealthCommand { public class StealthCommand {
@@ -41,7 +37,6 @@ public class StealthCommand {
private static int executeToggle(CommandContext<CommandSourceStack> context) { private static int executeToggle(CommandContext<CommandSourceStack> context) {
ServerPlayer player = getPlayer(context); ServerPlayer player = getPlayer(context);
if (player == null) return 0; if (player == null) return 0;
boolean newState = StealthStorage.toggle(player.getUUID()); boolean newState = StealthStorage.toggle(player.getUUID());
sendStatus(player, newState); sendStatus(player, newState);
return 1; return 1;
@@ -50,7 +45,6 @@ public class StealthCommand {
private static int executeOn(CommandContext<CommandSourceStack> context) { private static int executeOn(CommandContext<CommandSourceStack> context) {
ServerPlayer player = getPlayer(context); ServerPlayer player = getPlayer(context);
if (player == null) return 0; if (player == null) return 0;
StealthStorage.set(player.getUUID(), true); StealthStorage.set(player.getUUID(), true);
sendStatus(player, true); sendStatus(player, true);
return 1; return 1;
@@ -59,7 +53,6 @@ public class StealthCommand {
private static int executeOff(CommandContext<CommandSourceStack> context) { private static int executeOff(CommandContext<CommandSourceStack> context) {
ServerPlayer player = getPlayer(context); ServerPlayer player = getPlayer(context);
if (player == null) return 0; if (player == null) return 0;
StealthStorage.set(player.getUUID(), false); StealthStorage.set(player.getUUID(), false);
sendStatus(player, false); sendStatus(player, false);
return 1; return 1;
@@ -18,7 +18,6 @@ import java.util.UUID;
/** /**
* Зберігає стан stealth-режиму для кожного гравця. * Зберігає стан stealth-режиму для кожного гравця.
* Файл: config/localchat/stealth.json — {"uuid1": true, "uuid2": false, ...} * Файл: config/localchat/stealth.json — {"uuid1": true, "uuid2": false, ...}
* Зміни зберігаються на диск при кожному toggle.
*/ */
public class StealthStorage { public class StealthStorage {
+7 -10
View File
@@ -1,29 +1,26 @@
{ {
"schemaVersion": 1, "schemaVersion": 1,
"id": "msnexus", "id": "localchat",
"version": "${version}", "version": "${version}",
"name": "msnexus", "name": "localchat",
"description": "", "description": "",
"authors": [], "authors": [],
"contact": {}, "contact": {},
"license": "All-Rights-Reserved", "license": "All-Rights-Reserved",
"icon": "assets/msnexus/icon.png", "icon": "assets/localchat/icon.png",
"environment": "*", "environment": "*",
"entrypoints": { "entrypoints": {
"fabric-datagen": [ "fabric-datagen": [
"org.examp.john.client.MSNexusDataGenerator" "org.examp.john.client.LocalchatDataGenerator"
],
"client": [
"org.examp.john.client.MSNexusClient"
], ],
"main": [ "main": [
"org.examp.john.MSNexus" "org.examp.john.Localchat"
] ]
}, },
"mixins": [ "mixins": [
"msnexus.mixins.json", "localchat.mixins.json",
{ {
"config": "msnexus.client.mixins.json", "config": "localchat.client.mixins.json",
"environment": "client" "environment": "client"
} }
], ],