14 changed files with 862 additions and 52 deletions
Binary file not shown.
@@ -1,10 +0,0 @@
package org.examp.john.client;
import net.fabricmc.api.ClientModInitializer;
public class LocalchatClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
}
}
@@ -0,0 +1,49 @@
package org.examp.john.mixin.client;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.PlayerTabOverlay;
import net.minecraft.client.multiplayer.PlayerInfo;
import net.minecraft.network.chat.Component;
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.Inject;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/**
* Прибирає курсив та сірий колір для spectator у таб-листі.
*/
@Mixin(PlayerTabOverlay.class)
public class PlayerTabOverlayMixin {
/**
* Прибирає курсив з назви spectator в табі.
*/
@Inject(method = "getNameForDisplay", at = @At("RETURN"), cancellable = true)
private void removeSpectatorItalic(PlayerInfo playerInfo, CallbackInfoReturnable<Component> cir) {
if (playerInfo.getGameMode() == GameType.SPECTATOR) {
Component original = cir.getReturnValue();
cir.setReturnValue(original.copy().withStyle(original.getStyle().withItalic(false)));
}
}
/**
* Перехоплюємо виклик text(Font, Component, x, y, color) в extractRenderState.
* Якщо колір = -1862270977 (сірий для spectator), замінюємо на -1 (білий).
*/
@Redirect(
method = "extractRenderState",
at = @At(
value = "INVOKE",
target = "Lnet/minecraft/client/gui/GuiGraphicsExtractor;text(Lnet/minecraft/client/gui/Font;Lnet/minecraft/network/chat/Component;III)V"
)
)
private void forceWhiteText(GuiGraphicsExtractor instance, Font font, Component text, int x, int y, int color) {
if (color == -1862270977) {
color = -1; // білий
}
instance.text(font, text, x, y, color);
}
}
@@ -4,6 +4,7 @@
"package": "org.examp.john.mixin.client",
"compatibilityLevel": "JAVA_25",
"client": [
"PlayerTabOverlayMixin"
],
"injectors": {
"defaultRequire": 1
@@ -0,0 +1,77 @@
package org.examp.john;
import net.minecraft.server.level.ServerPlayer;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Детектить патерн пошуку гравців через локальний чат.
*
* Якщо одне і те ж повідомлення повторюється >=5 разів за 5 хвилин
* і гравець перемістився >50 блоків — логує в консоль.
*/
public class AntiAbuse {
private static final int REPEAT_THRESHOLD = 5;
private static final double DISTANCE_THRESHOLD = 50.0;
private static final long TIME_WINDOW_MS = 5 * 60 * 1000;
private static final Map<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) {}
}
+51 -32
View File
@@ -5,11 +5,14 @@ import net.luckperms.api.LuckPerms;
import net.luckperms.api.LuckPermsProvider;
import net.luckperms.api.model.user.User;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.ClickEvent;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.HoverEvent;
import net.minecraft.network.chat.MutableComponent;
import net.minecraft.server.MinecraftServer;
import net.minecraft.network.chat.Style;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import org.examp.john.stealth.StealthStorage;
import java.util.ArrayList;
import java.util.List;
@@ -22,29 +25,11 @@ public class ChatHandler {
ServerMessageEvents.ALLOW_CHAT_MESSAGE.register((message, sender, params) -> {
String text = message.signedContent();
ServerLevel serverLevel = sender.level();
MinecraftServer server = serverLevel.getServer();
List<ServerPlayer> players = server.getPlayerList().getPlayers();
List<ServerPlayer> players = serverLevel.getServer().getPlayerList().getPlayers();
String playerPrefix = "";
try {
LuckPerms luckPerms = LuckPermsProvider.get();
User user = luckPerms.getUserManager().getUser(sender.getUUID());
if (user != null) {
String metaPrefix = user.getCachedData().getMetaData().getPrefix();
if (metaPrefix != null) {
playerPrefix = metaPrefix;
}
}
} catch (Throwable ignored) {
}
MutableComponent formattedPrefix = Component.literal(playerPrefix.replace("&", "§"));
MutableComponent formattedName = Component.empty()
.append(formattedPrefix)
.append(sender.getDisplayName());
MutableComponent formattedName = buildFormattedName(sender);
if (text.startsWith(prefix)) {
// ГЛОБАЛЬНИЙ ЧАТ
String actualMessage = text.substring(1).trim();
Component globalMessage = Component.empty()
.append(formattedName)
@@ -54,17 +39,26 @@ public class ChatHandler {
player.sendSystemMessage(globalMessage);
}
// Логування: глобальний чат чують всі онлайн-гравці
ChatLogger.logGlobal(sender, actualMessage, players);
} else {
// ЛОКАЛЬНИЙ ЧАТ
AntiAbuse.recordMessage(sender, text);
if (StealthStorage.isEnabled(sender.getUUID())) {
sender.sendSystemMessage(
Component.literal("Вас ніхто не почув...")
.withStyle(ChatFormatting.ITALIC, ChatFormatting.YELLOW)
);
ChatLogger.logUnheard(sender, text);
return false;
}
List<ServerPlayer> hearers = new ArrayList<>();
for (ServerPlayer player : players) {
if (player.level() == serverLevel && player.distanceTo(sender) <= LOCAL_CHAT_RADIUS) {
if (player != sender) {
hearers.add(player);
}
if (player != sender
&& player.level() == serverLevel
&& player.distanceTo(sender) <= LOCAL_CHAT_RADIUS) {
hearers.add(player);
}
}
@@ -73,27 +67,52 @@ public class ChatHandler {
.append(formattedName)
.append(Component.literal(": " + text).withStyle(ChatFormatting.GRAY));
// ОПТИМІЗАЦІЯ: Замість повторної перевірки всіх гравців сервера,
// просто відправляємо повідомлення самому відправнику та вже готовому списку слухачів.
sender.sendSystemMessage(localMessage);
for (ServerPlayer hearer : hearers) {
hearer.sendSystemMessage(localMessage);
}
// Логування: локальний чат, хто почув + координати
ChatLogger.logLocal(sender, text, hearers);
} else {
sender.sendSystemMessage(
Component.literal("Вас ніхто не почув, додайте на початок повідомлення \"!\" аби написати в глобальний чат")
Component.literal("Вас ніхто не почув, додайте \"!\" на початок для глобального чату")
.withStyle(ChatFormatting.ITALIC, ChatFormatting.YELLOW)
);
// Логування: ніхто не почув
ChatLogger.logUnheard(sender, text);
}
}
return false;
});
}
private static MutableComponent buildFormattedName(ServerPlayer player) {
String luckPermsPrefix = getLuckPermsPrefix(player);
String playerName = player.getName().getString();
String formattedPrefix = luckPermsPrefix.replace("&", "\u00a7");
Style clickStyle = Style.EMPTY
.withClickEvent(new ClickEvent.SuggestCommand("/msg " + playerName + " "))
.withHoverEvent(new HoverEvent.ShowText(
Component.literal("Натисніть щоб написати " + playerName)
.withStyle(ChatFormatting.GRAY)));
return Component.literal(formattedPrefix + playerName).withStyle(clickStyle);
}
private static String getLuckPermsPrefix(ServerPlayer player) {
try {
LuckPerms luckPerms = LuckPermsProvider.get();
User user = luckPerms.getUserManager().getUser(player.getUUID());
if (user != null) {
String metaPrefix = user.getCachedData().getMetaData().getPrefix();
if (metaPrefix != null) {
return metaPrefix;
}
}
} catch (Throwable ignored) {
}
return "";
}
}
@@ -13,7 +13,6 @@ import java.util.stream.Collectors;
public class ChatLogger {
public static void logGlobal(ServerPlayer sender, String message, List<ServerPlayer> recipients) {
// Для глобального чату ігноруємо список отримувачів і просто пишемо "everyone"
Localchat.magicModLog.info(
"[CHAT][GLOBAL] {} said: \"{}\" | heard by: everyone | pos: {}",
sender.getName().getString(),
@@ -23,7 +22,6 @@ public class ChatLogger {
}
public static void logLocal(ServerPlayer sender, String message, List<ServerPlayer> recipients) {
// Для локального чату виводимо лише імена отримувачів, без їхніх координат
String heardBy = recipients.isEmpty()
? "nobody"
: recipients.stream()
@@ -50,9 +48,6 @@ public class ChatLogger {
private static String formatPos(ServerPlayer player) {
ServerLevel level = player.level();
// У ResourceKey дістаємо Identifier/ResourceLocation через .identifier(),
// після чого через .getPath() беремо чисту назву ("overworld", "the_nether", "the_end")
String rawDimension = level.dimension().identifier().getPath();
return String.format(
+6 -1
View File
@@ -1,6 +1,9 @@
package org.examp.john;
import net.fabricmc.api.ModInitializer;
import org.examp.john.rules.RulesCommand;
import org.examp.john.stealth.StealthCommand;
import org.examp.john.stealth.StealthStorage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -10,7 +13,9 @@ public class Localchat implements ModInitializer {
@Override
public void onInitialize() {
// Тепер ChatHandler викликається з того ж пакета без помилок імпорту
StealthStorage.load();
ChatHandler.register();
StealthCommand.register();
RulesCommand.register();
}
}
@@ -0,0 +1,216 @@
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 (WrittenBookContent).
* Відкривається через openItemGui — книга НЕ залишається в інвентарі.
*/
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);
}
/**
* Створює книгу правил:
* 1. Сторінка 0 — Легенда + Зміст
* 2. Кожен розділ починається на новій сторінці
* 3. Пагінація по ~280 символів/сторінку (реальний ліміт книги)
*/
private static ItemStack createRuleBook(RulesData.RulesRoot rules) {
ItemStack book = new ItemStack(Items.WRITTEN_BOOK);
List<Filterable<Component>> pages = new ArrayList<>();
// Сторінка 0: Легенда + Зміст
pages.add(Filterable.passThrough(buildLegendAndContentsPage(rules)));
// Сторінки з правилами (кожен розділ починається на новій сторінці)
for (RulesData.Section section : rules.sections) {
pages.addAll(buildSectionPages(section));
}
WrittenBookContent content = new WrittenBookContent(
Filterable.passThrough("Правила серверу"),
"Server",
0,
pages,
true
);
book.set(DataComponents.WRITTEN_BOOK_CONTENT, content);
return book;
}
/** Сторінка 0: Легенда + Зміст */
private static Component buildLegendAndContentsPage(RulesData.RulesRoot rules) {
StringBuilder sb = new StringBuilder();
sb.append("§l§nПравила серверу§r\n\n");
sb.append("§l§nЛегенда важливості§r\n");
sb.append("§c⚠§r — Тяжке порушення (бан 5+ днів або перманент)\n");
sb.append("§e⚠§r — Середнє порушення (бан 1–5 днів)\n");
sb.append("§a⚠§r — Легке порушення (попередження/мут)\n\n");
sb.append("§l§nЗміст§r\n\n");
int page = 1;
for (RulesData.Section section : rules.sections) {
sb.append(" §e► §r§f").append(section.title).append(" §7(стор. ").append(page).append(")§r\n");
page += estimatePagesForSection(section);
}
sb.append("\n§7Стрілки в книзі — навігація.§r");
return Component.literal(sb.toString());
}
/** Оцінює скільки сторінок займе розділ (~180 видимих символів/сторінку — фізичний ліміт книги) */
private static int estimatePagesForSection(RulesData.Section section) {
if (section.entries == null || section.entries.isEmpty()) return 1;
final int CHARS_PER_PAGE = 180;
int totalVisible = section.title.length() + 30;
for (RulesData.Entry entry : section.entries) {
totalVisible += visibleLength(entry.number) + visibleLength(entry.text) + 30;
}
return Math.max(1, (totalVisible + CHARS_PER_PAGE - 1) / CHARS_PER_PAGE);
}
/** Повертає довжину рядка БЕЗ §-кодів (тільки видимі символи) */
private static int visibleLength(String s) {
if (s == null) return 0;
int len = 0;
boolean inCode = false;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '§') {
inCode = true;
continue;
}
if (inCode) {
inCode = false;
continue;
}
len++;
}
return len;
}
/** Будує сторінки для одного розділу з пагінацією */
private static List<Filterable<Component>> buildSectionPages(RulesData.Section section) {
List<Filterable<Component>> pages = new ArrayList<>();
if (section.entries == null || section.entries.isEmpty()) {
pages.add(Filterable.passThrough(Component.literal("§l§n" + section.title + "§r\n\n§7(розділ порожній)§r")));
return pages;
}
final int CHARS_PER_PAGE = 180;
StringBuilder currentPage = new StringBuilder();
int currentVisible = 0;
boolean firstPage = true;
for (RulesData.Entry entry : section.entries) {
String symbol = entry.severity != null ? entry.severity.colorCode + "⚠§r" : "§a⚠§r";
String entryText = symbol + " " + entry.number + ". " + entry.text + "\n\n";
int entryVisible = visibleLength(entryText);
if (firstPage) {
String header = "§l§n" + section.title + "§r\n\n";
currentPage.append(header);
currentVisible = visibleLength(header);
firstPage = false;
}
// Якщо правило не вміщується на поточній сторінці — переносимо ЦІЛЕ правило на нову сторінку
if (currentVisible + entryVisible > CHARS_PER_PAGE && currentVisible > 0) {
pages.add(Filterable.passThrough(Component.literal(currentPage.toString())));
currentPage = new StringBuilder();
currentVisible = 0;
}
currentPage.append(entryText);
currentVisible += entryVisible;
}
if (currentVisible > 0) {
pages.add(Filterable.passThrough(Component.literal(currentPage.toString())));
}
return pages;
}
}
@@ -0,0 +1,157 @@
package org.examp.john.rules;
import java.util.ArrayList;
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;
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;
}
}
/** Повний набір правил за замовчуванням для створення конфігу при першому запуску. */
public static RulesRoot createDefaultRules() {
List<Section> sections = new ArrayList<>();
// 1. ЧЕСНА ГРА
sections.add(new Section("1. ЧЕСНА ГРА", List.of(
new Entry("1.1", "Чити та заборонені модифікації. Заборонено використовувати чити, модифікації, ресурспаки або інші засоби, які дають перевагу.", Severity.HEAVY),
new Entry("1.2", "Використання багів. Заборонено навмисно використовувати помилки та баги сервера для отримання переваги.", Severity.HEAVY),
new Entry("1.3", "Дюпи піску та подібні механіки дозволені. Заборонено дюпи, що ламають економіку або стабільність сервера.", Severity.HEAVY),
new Entry("1.4", "Обман адміністрації. Заборонено навмисно вводити адміністрацію в оману щодо обставин порушення.", Severity.HEAVY),
new Entry("1.5", "Порушення стабільності сервера. Заборонено створювати навантаження, що спричиняють нестабільність.", Severity.HEAVY),
new Entry("1.6", "Реальні гроші. Заборонено купувати/продавати ігрові ресурси за реальні гроші.", Severity.HEAVY),
new Entry("1.7", "Кількість акаунтів. Один гравець — не більше 5 акаунтів.", Severity.HEAVY),
new Entry("1.8", "Продаж або передача акаунта. Заборонено.", Severity.HEAVY),
new Entry("1.9", "Злам акаунтів. Заборонено намагатися отримати доступ до чужих акаунтів.", Severity.HEAVY),
new Entry("1.10", "Підбурювання до порушень. Заборонено закликати інших гравців порушувати правила.", Severity.MEDIUM),
new Entry("1.11", "Шкідлива дезінформація. Заборонено поширювати неправду, що шкодить гравцям/проєкту.", Severity.MEDIUM),
new Entry("1.12", "Персональні дані. Заборонено використовувати/поширювати дані інших без згоди.", Severity.HEAVY),
new Entry("1.13", "Обхід покарання. Заборонено обходити бан/мут через інші акаунти.", Severity.HEAVY),
new Entry("1.14", "Шкідливі дії проти адміністрації. Заборонено навмисно шкодити адмінам/персоналу.", Severity.HEAVY),
new Entry("1.15", "Атаки на сервер та лаунчер. Заборонено втручатися в роботу інфраструктури.", Severity.HEAVY)
)));
// 2. СПІЛКУВАННЯ
sections.add(new Section("2. СПІЛКУВАННЯ", List.of(
new Entry("2.1", "Мова спілкування. Основні: українська, англійська. Російська заборонена в публічному чаті.", Severity.MEDIUM),
new Entry("2.2", "Образи, приниження, провокації. Заборонено ображати гравців, їхніх родичів або адміністрацію. Порнографія заборонена.", Severity.MEDIUM),
new Entry("2.3", "Ненормативна лексика. Заборонено надмірне використання.", Severity.LIGHT),
new Entry("2.4", "Caps Lock. Заборонено надмірне використання, якщо заважає спілкуванню.", Severity.LIGHT),
new Entry("2.5", "Реклама. Заборонено рекламувати сторонні проєкти без дозволу адміністрації (у тому числі в ПМ).", Severity.HEAVY),
new Entry("2.6", "Провокації та конфлікти. Заборонено навмисно розпалювати конфлікти.", Severity.HEAVY),
new Entry("2.7", "Публічні звинувачення адміністрації. Заборонено безпідставно дискредитувати модерацію. Критику — через офіційні канали.", Severity.LIGHT)
)));
// 3. ТЕРИТОРІЇ ТА БУДІВНИЦТВО
sections.add(new Section("3. ТЕРИТОРІЇ ТА БУДІВНИЦТВО", List.of(
new Entry("3.1", "Територія гравця. Гравець розпоряджається своєю територією. Втручання без дозволу — порушення.", Severity.MEDIUM),
new Entry("3.2", "Гріферство. Заборонено руйнувати будівлі, псувати територію, знищувати майно без дозволу.", Severity.HEAVY),
new Entry("3.3", "Неприйнятні споруди. Заборонено: образливий/провокаційний/порнографічний/політичний/нацистський/наркотичний контент.", Severity.MEDIUM),
new Entry("3.4", "Псування світу. Заборонено створювати безлад: хаотичне знищення ландшафту, залишення занедбаних будівель, розливання рідин, безглузді стовпи.", Severity.MEDIUM),
new Entry("3.5", "Контейнери з чужими ресурсами. Заборонено знищувати скрині/шалкери з чужими речами без причини.", Severity.MEDIUM),
new Entry("3.6", "Назви предметів. Не повинні містити заборонений контент.", Severity.LIGHT)
)));
// 4. НІКНЕЙМИ, СКІНИ ТА ПРОФІЛІ
sections.add(new Section("4. НІКНЕЙМИ, СКІНИ ТА ПРОФІЛІ", List.of(
new Entry("4.1", "Заборонені нікнейми: образи, провокації, порнографія, реклама, наркотики, неприйнятний контент.", Severity.HEAVY),
new Entry("4.2", "Заборонені префікси: образливий, провокаційний, порнографічний, рекламний контент.", Severity.MEDIUM),
new Entry("4.3", "Скіни та плащі. Заборонено порнографічний/неприйнятний контент.", Severity.MEDIUM),
new Entry("4.4", "Імітація адміністрації. Заборонено виглядати як адмін/команда проєкту або відомі медіа-особи.", Severity.HEAVY),
new Entry("4.5", "Імітація іншого гравця. Заборонено створювати схожі ніки з метою обману.", Severity.HEAVY),
new Entry("4.6", "Вхід під чужим акаунтом. Заборонено.", Severity.HEAVY)
)));
// 5. PvP ТА ВЗАЄМОДІЯ
sections.add(new Section("5. PvP ТА ВЗАЄМОДІЯ", List.of(
new Entry("5.1", "Перебування на чужій території. Піти після другого прохання власника. Виняток — обов'язки персоналу.", Severity.LIGHT)
)));
// 6. ТЕХНІЧНІ ТА ІНШІ ПОРУШЕННЯ
sections.add(new Section("6. ТЕХНІЧНІ ТА ІНШІ ПОРУШЕННЯ", List.of(
new Entry("6.1", "Втручання в роботу проєкту. Заборонено втручатися в сервер/лаунчер для переваги/обходу захисту.", Severity.HEAVY),
new Entry("6.2", "Обхід систем захисту. Заборонено обходити авторизацію/захист через сторонні клієнти.", Severity.HEAVY),
new Entry("6.3", "Порушення, не передбачені правилами. Адміністрація може тимчасово обмежити доступ для з'ясування.", Severity.MEDIUM)
)));
// 7. ІВЕНТИ
sections.add(new Section("7. ІВЕНТИ", List.of(
new Entry("7.1", "Правила івентів. Необхідно дотримуватися окремих правил івенту. Порушення карається за умовами івенту.", Severity.LIGHT)
)));
return new RulesRoot(sections);
}
}
@@ -0,0 +1,76 @@
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.RulesRoot defaultRoot = RulesData.createDefaultRules();
try (var writer = Files.newBufferedWriter(CONFIG_PATH, StandardCharsets.UTF_8)) {
new GsonBuilder().setPrettyPrinting().create().toJson(defaultRoot, writer);
}
Localchat.magicModLog.info("Створено конфіг правил за замовчуванням: {}", CONFIG_PATH.toAbsolutePath());
}
}
@@ -0,0 +1,145 @@
package org.examp.john.stealth;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.minecraft.ChatFormatting;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
/**
* Команда /stealth — керує режимом прихованості гравця.
*
* Підкоманди:
* /stealth — переключити стан (on/off)
* /stealth on — увімкнути stealth
* /stealth off — вимкнути stealth
* /stealth info — показати інформацію про команду
*/
public class StealthCommand {
public static void register() {
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) ->
dispatcher.register(buildCommand())
);
}
private static LiteralArgumentBuilder<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,83 @@
package org.examp.john.stealth;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import org.examp.john.Localchat;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* Зберігає стан stealth-режиму для кожного гравця.
* Файл: config/localchat/stealth.json — {"uuid1": true, "uuid2": false, ...}
*/
public class StealthStorage {
private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create();
private static final Path CONFIG_PATH = Path.of("config", "localchat", "stealth.json");
private static Map<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());
}
}
}
-3
View File
@@ -13,9 +13,6 @@
"fabric-datagen": [
"org.examp.john.client.LocalchatDataGenerator"
],
"client": [
"org.examp.john.client.LocalchatClient"
],
"main": [
"org.examp.john.Localchat"
]