From a4d2fc5f5eb5e09bbad74c1203b3cf008b923fe5 Mon Sep 17 00:00:00 2001 From: John Etherfield Date: Wed, 12 Aug 2026 09:54:33 +0200 Subject: [PATCH 1/4] feat: implement /rules command with custom networking payload --- .../examp/john/client/LocalchatClient.java | 6 +- .../client/rules/RulesClientNetworking.java | 19 +++ .../examp/john/client/rules/RulesScreen.java | 145 ++++++++++++++++++ src/main/java/org/examp/john/ChatLogger.java | 5 - src/main/java/org/examp/john/Localchat.java | 7 +- .../org/examp/john/rules/RulesCommand.java | 43 ++++++ .../java/org/examp/john/rules/RulesData.java | 80 ++++++++++ .../org/examp/john/rules/RulesLoader.java | 78 ++++++++++ .../org/examp/john/rules/RulesNetworking.java | 16 ++ .../org/examp/john/rules/RulesPayload.java | 42 +++++ 10 files changed, 433 insertions(+), 8 deletions(-) create mode 100644 src/client/java/org/examp/john/client/rules/RulesClientNetworking.java create mode 100644 src/client/java/org/examp/john/client/rules/RulesScreen.java create mode 100644 src/main/java/org/examp/john/rules/RulesCommand.java create mode 100644 src/main/java/org/examp/john/rules/RulesData.java create mode 100644 src/main/java/org/examp/john/rules/RulesLoader.java create mode 100644 src/main/java/org/examp/john/rules/RulesNetworking.java create mode 100644 src/main/java/org/examp/john/rules/RulesPayload.java diff --git a/src/client/java/org/examp/john/client/LocalchatClient.java b/src/client/java/org/examp/john/client/LocalchatClient.java index 55ece85..a742344 100644 --- a/src/client/java/org/examp/john/client/LocalchatClient.java +++ b/src/client/java/org/examp/john/client/LocalchatClient.java @@ -1,10 +1,12 @@ package org.examp.john.client; import net.fabricmc.api.ClientModInitializer; +import org.examp.john.client.rules.RulesClientNetworking; +import org.examp.john.rules.RulesNetworking; public class LocalchatClient implements ClientModInitializer { - @Override public void onInitializeClient() { + RulesClientNetworking.register(); } -} +} \ No newline at end of file diff --git a/src/client/java/org/examp/john/client/rules/RulesClientNetworking.java b/src/client/java/org/examp/john/client/rules/RulesClientNetworking.java new file mode 100644 index 0000000..2819848 --- /dev/null +++ b/src/client/java/org/examp/john/client/rules/RulesClientNetworking.java @@ -0,0 +1,19 @@ +package org.examp.john.client.rules; + +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.minecraft.client.Minecraft; +import org.examp.john.rules.RulesData; +import org.examp.john.rules.RulesPayload; + +public class RulesClientNetworking { + + public static void register() { + ClientPlayNetworking.registerGlobalReceiver(RulesPayload.TYPE, (payload, context) -> { + RulesData.RulesRoot data = payload.toData(); + + context.client().execute(() -> + Minecraft.getInstance().setScreen(new RulesScreen(data)) + ); + }); + } +} \ No newline at end of file diff --git a/src/client/java/org/examp/john/client/rules/RulesScreen.java b/src/client/java/org/examp/john/client/rules/RulesScreen.java new file mode 100644 index 0000000..94c9411 --- /dev/null +++ b/src/client/java/org/examp/john/client/rules/RulesScreen.java @@ -0,0 +1,145 @@ +package org.examp.john.client.rules; + +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; +import org.examp.john.rules.RulesData; + +import java.util.List; + +/** + * GUI екран правил серверу. Зверху ряд кнопок-вкладок (по одній на кожен розділ), + * знизу список пунктів обраного розділу з кольоровим трикутником-значком + * (зелений/жовтий/червоний) залежно від тяжкості порушення. + * + * Дані (розділи, пункти, нумерація, кольори) повністю визначаються rules.json + * на сервері — цей клас нічого не хардкодить, окрім розмірів/відступів GUI. + * + * У Minecraft 26.1.2+ Mojang перейменували GuiGraphics на GuiGraphicsExtractor + * (джерело: docs.fabricmc.net/develop/rendering/gui-graphics). + */ +public class RulesScreen extends Screen { + + // ARGB кольори. З Minecraft 1.21.6+ текстовий колір інтерпретується як ARGB, + // тому альфа-канал (FF) обов'язковий, інакше текст буде прозорим/невидимим. + private static final int COLOR_WHITE = 0xFFFFFFFF; + private static final int COLOR_GRAY = 0xFFAAAAAA; + private static final int COLOR_BG_PANEL = 0xCC101010; + private static final int COLOR_TAB_ACTIVE = 0xFF3A3A3A; + private static final int COLOR_TAB_INACTIVE = 0xFF202020; + + private static final int TAB_HEIGHT = 20; + private static final int TAB_WIDTH = 100; + private static final int ENTRY_HEIGHT = 24; + private static final int PADDING = 8; + + private final RulesData.RulesRoot rulesData; + private int selectedSection = 0; + private int scrollOffset = 0; + + public RulesScreen(RulesData.RulesRoot rulesData) { + super(Component.literal("Правила серверу")); + this.rulesData = rulesData; + } + + @Override + protected void init() { + super.init(); + clearWidgets(); + + if (rulesData == null || rulesData.sections == null || rulesData.sections.isEmpty()) { + return; + } + + int tabX = PADDING; + int tabY = PADDING + 20; + + for (int i = 0; i < rulesData.sections.size(); i++) { + final int index = i; + RulesData.Section section = rulesData.sections.get(i); + + Button tabButton = Button.builder( + Component.literal(section.title), + button -> { + selectedSection = index; + scrollOffset = 0; + } + ).bounds(tabX, tabY, TAB_WIDTH, TAB_HEIGHT).build(); + + addRenderableWidget(tabButton); + + tabX += TAB_WIDTH + 4; + // Переносимо на новий ряд вкладок, якщо не влазить по ширині екрану + if (tabX + TAB_WIDTH > this.width - PADDING) { + tabX = PADDING; + tabY += TAB_HEIGHT + 4; + } + } + + // Кнопка закриття внизу екрану + addRenderableWidget( + Button.builder(Component.literal("Закрити"), button -> onClose()) + .bounds(this.width / 2 - 50, this.height - PADDING - 20, 100, 20) + .build() + ); + } + + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float delta) { + super.extractRenderState(graphics, mouseX, mouseY, delta); + + graphics.centeredText(this.font, this.title, this.width / 2, PADDING, COLOR_WHITE); + + if (rulesData == null || rulesData.sections == null || rulesData.sections.isEmpty()) { + graphics.centeredText( + this.font, + Component.literal("Правила ще не завантажені."), + this.width / 2, this.height / 2, COLOR_GRAY + ); + return; + } + + renderEntries(graphics); + } + + private void renderEntries(GuiGraphicsExtractor graphics) { + RulesData.Section section = rulesData.sections.get(selectedSection); + List entries = section.entries; + + if (entries == null || entries.isEmpty()) { + return; + } + + int panelTop = PADDING + 50; + int panelBottom = this.height - PADDING - 30; + int panelLeft = PADDING; + int panelRight = this.width - PADDING; + + graphics.fill(panelLeft, panelTop, panelRight, panelBottom, COLOR_BG_PANEL); + + int y = panelTop + 6 - scrollOffset; + + for (RulesData.Entry entry : entries) { + if (y + ENTRY_HEIGHT < panelTop || y > panelBottom) { + y += ENTRY_HEIGHT; + continue; + } + + // Кольоровий трикутник-значок тяжкості порушення + String triangle = "\u26A0"; // ⚠ + graphics.text(this.font, triangle, panelLeft + 6, y, entry.severity.color | 0xFF000000, false); + + // Номер пункту + текст + String line = entry.number + ". " + entry.text; + graphics.text(this.font, line, panelLeft + 24, y, COLOR_WHITE, false); + + y += ENTRY_HEIGHT; + } + } + + @Override + public boolean isPauseScreen() { + return false; + } +} \ No newline at end of file diff --git a/src/main/java/org/examp/john/ChatLogger.java b/src/main/java/org/examp/john/ChatLogger.java index 9fd979c..14adf56 100644 --- a/src/main/java/org/examp/john/ChatLogger.java +++ b/src/main/java/org/examp/john/ChatLogger.java @@ -13,7 +13,6 @@ import java.util.stream.Collectors; public class ChatLogger { public static void logGlobal(ServerPlayer sender, String message, List 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 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( diff --git a/src/main/java/org/examp/john/Localchat.java b/src/main/java/org/examp/john/Localchat.java index d33b0f5..be36627 100644 --- a/src/main/java/org/examp/john/Localchat.java +++ b/src/main/java/org/examp/john/Localchat.java @@ -1,6 +1,8 @@ package org.examp.john; import net.fabricmc.api.ModInitializer; +import org.examp.john.rules.RulesCommand; +import org.examp.john.rules.RulesNetworking; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -10,7 +12,10 @@ public class Localchat implements ModInitializer { @Override public void onInitialize() { - // Тепер ChatHandler викликається з того ж пакета без помилок імпорту ChatHandler.register(); + + // Реєстрація мережі /rules (спільна частина: обов'язково і на сервері, і на клієнті) + RulesNetworking.registerCommon(); + RulesCommand.register(); } } diff --git a/src/main/java/org/examp/john/rules/RulesCommand.java b/src/main/java/org/examp/john/rules/RulesCommand.java new file mode 100644 index 0000000..94c5aca --- /dev/null +++ b/src/main/java/org/examp/john/rules/RulesCommand.java @@ -0,0 +1,43 @@ +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.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +/** + * Реєструє команду /rules. Гравець виконує команду -> сервер читає rules.json + * -> надсилає RulesPayload через мережу -> клієнт відкриває RulesScreen (кастомний GUI з вкладками). + */ +public class RulesCommand { + + public static void register() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> + dispatcher.register(buildCommand()) + ); + } + + private static LiteralArgumentBuilder buildCommand() { + return Commands.literal("rules").executes(RulesCommand::execute); + } + + private static int execute(CommandContext context) { + CommandSourceStack source = context.getSource(); + + if (!(source.getEntity() instanceof ServerPlayer player)) { + source.sendFailure(Component.literal("Цю команду можна викликати лише в грі.")); + return 0; + } + + RulesData.RulesRoot rules = RulesLoader.get(); + RulesPayload payload = RulesPayload.fromData(rules); + + ServerPlayNetworking.send(player, payload); + + return 1; + } +} diff --git a/src/main/java/org/examp/john/rules/RulesData.java b/src/main/java/org/examp/john/rules/RulesData.java new file mode 100644 index 0000000..2250f1a --- /dev/null +++ b/src/main/java/org/examp/john/rules/RulesData.java @@ -0,0 +1,80 @@ +package org.examp.john.rules; + +import java.util.List; + +/** + * Моделі даних для системи правил сервера. + * Структура JSON конфігу: + * { + * "sections": [ + * { + * "title": "Правила будівництва", + * "entries": [ + * { "number": "1.1", "text": "Заборонено будувати без дозволу...", "severity": "MEDIUM" } + * ] + * } + * ] + * } + */ +public class RulesData { + + /** + * Рівень тяжкості порушення. Визначає колір трикутника-значка в GUI. + * LIGHT -> зелений + * MEDIUM -> жовтий + * HEAVY -> червоний + */ + public enum Severity { + LIGHT(0x55FF55), // зелений + MEDIUM(0xFFFF55), // жовтий + HEAVY(0xFF5555); // червоний + + public final int color; + + Severity(int color) { + this.color = color; + } + + 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 entries; + + public Section(String title, List entries) { + this.title = title; + this.entries = entries; + } + } + + /** Корінь конфігу — список усіх розділів. */ + public static class RulesRoot { + public List
sections; + + public RulesRoot(List
sections) { + this.sections = sections; + } + } +} diff --git a/src/main/java/org/examp/john/rules/RulesLoader.java b/src/main/java/org/examp/john/rules/RulesLoader.java new file mode 100644 index 0000000..e6d47f6 --- /dev/null +++ b/src/main/java/org/examp/john/rules/RulesLoader.java @@ -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()); + } +} diff --git a/src/main/java/org/examp/john/rules/RulesNetworking.java b/src/main/java/org/examp/john/rules/RulesNetworking.java new file mode 100644 index 0000000..271cab4 --- /dev/null +++ b/src/main/java/org/examp/john/rules/RulesNetworking.java @@ -0,0 +1,16 @@ +package org.examp.john.rules; + +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; + +/** + * Реєструє тип пакета RulesPayload. Викликається і на клієнті, і на сервері + * (обов'язкова вимога Fabric API: обидві сторони мають знати про CustomPayload). + */ +public class RulesNetworking { + + public static void registerCommon() { + // У Fabric API 26.1+ playS2C() перейменували на clientboundPlay() + // (офіційний гайд порту: docs.fabricmc.net/develop/porting/fabric-api). + PayloadTypeRegistry.clientboundPlay().register(RulesPayload.TYPE, RulesPayload.CODEC); + } +} \ No newline at end of file diff --git a/src/main/java/org/examp/john/rules/RulesPayload.java b/src/main/java/org/examp/john/rules/RulesPayload.java new file mode 100644 index 0000000..d47df5f --- /dev/null +++ b/src/main/java/org/examp/john/rules/RulesPayload.java @@ -0,0 +1,42 @@ +package org.examp.john.rules; + +import com.google.gson.Gson; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; +import org.examp.john.Localchat; + +/** + * Пакет сервер -> клієнт, що передає весь список правил у вигляді JSON-рядка. + * Це найпростіший спосіб не писати окремий StreamCodec для кожного поля + * RulesData: серіалізуємо через Gson в один String і десеріалізуємо назад на клієнті. + */ +public record RulesPayload(String rulesJson) implements CustomPacketPayload { + + public static final CustomPacketPayload.Type TYPE = + new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(Localchat.MOD_ID, "rules_payload")); + + public static final StreamCodec CODEC = + StreamCodec.of( + (buf, payload) -> buf.writeUtf(payload.rulesJson(), Short.MAX_VALUE), + buf -> new RulesPayload(buf.readUtf(Short.MAX_VALUE)) + ); + + @Override + public Type type() { + return TYPE; + } + + /** Створює payload з поточних правил, серіалізуючи їх у JSON. */ + public static RulesPayload fromData(RulesData.RulesRoot data) { + Gson gson = new Gson(); + return new RulesPayload(gson.toJson(data)); + } + + /** Десеріалізує JSON назад у RulesData на клієнтській стороні. */ + public RulesData.RulesRoot toData() { + Gson gson = new Gson(); + return gson.fromJson(rulesJson, RulesData.RulesRoot.class); + } +} \ No newline at end of file From 3537060ba535f536152e8d9f73e3b0f0c7ffe98a Mon Sep 17 00:00:00 2001 From: John Etherfield Date: Fri, 14 Aug 2026 15:53:58 +0200 Subject: [PATCH 2/4] rewrite: /rules via temporary written_book instead of GUI --- .../examp/john/client/LocalchatClient.java | 12 -- .../client/rules/RulesClientNetworking.java | 19 --- .../examp/john/client/rules/RulesScreen.java | 145 ------------------ src/main/java/org/examp/john/Localchat.java | 6 +- .../org/examp/john/rules/RulesCommand.java | 94 +++++++++++- .../java/org/examp/john/rules/RulesData.java | 24 +-- .../org/examp/john/rules/RulesLoader.java | 2 +- .../org/examp/john/rules/RulesNetworking.java | 16 -- .../org/examp/john/rules/RulesPayload.java | 42 ----- src/main/resources/fabric.mod.json | 3 - 10 files changed, 104 insertions(+), 259 deletions(-) delete mode 100644 src/client/java/org/examp/john/client/LocalchatClient.java delete mode 100644 src/client/java/org/examp/john/client/rules/RulesClientNetworking.java delete mode 100644 src/client/java/org/examp/john/client/rules/RulesScreen.java delete mode 100644 src/main/java/org/examp/john/rules/RulesNetworking.java delete mode 100644 src/main/java/org/examp/john/rules/RulesPayload.java diff --git a/src/client/java/org/examp/john/client/LocalchatClient.java b/src/client/java/org/examp/john/client/LocalchatClient.java deleted file mode 100644 index a742344..0000000 --- a/src/client/java/org/examp/john/client/LocalchatClient.java +++ /dev/null @@ -1,12 +0,0 @@ -package org.examp.john.client; - -import net.fabricmc.api.ClientModInitializer; -import org.examp.john.client.rules.RulesClientNetworking; -import org.examp.john.rules.RulesNetworking; - -public class LocalchatClient implements ClientModInitializer { - @Override - public void onInitializeClient() { - RulesClientNetworking.register(); - } -} \ No newline at end of file diff --git a/src/client/java/org/examp/john/client/rules/RulesClientNetworking.java b/src/client/java/org/examp/john/client/rules/RulesClientNetworking.java deleted file mode 100644 index 2819848..0000000 --- a/src/client/java/org/examp/john/client/rules/RulesClientNetworking.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.examp.john.client.rules; - -import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; -import net.minecraft.client.Minecraft; -import org.examp.john.rules.RulesData; -import org.examp.john.rules.RulesPayload; - -public class RulesClientNetworking { - - public static void register() { - ClientPlayNetworking.registerGlobalReceiver(RulesPayload.TYPE, (payload, context) -> { - RulesData.RulesRoot data = payload.toData(); - - context.client().execute(() -> - Minecraft.getInstance().setScreen(new RulesScreen(data)) - ); - }); - } -} \ No newline at end of file diff --git a/src/client/java/org/examp/john/client/rules/RulesScreen.java b/src/client/java/org/examp/john/client/rules/RulesScreen.java deleted file mode 100644 index 94c9411..0000000 --- a/src/client/java/org/examp/john/client/rules/RulesScreen.java +++ /dev/null @@ -1,145 +0,0 @@ -package org.examp.john.client.rules; - -import net.minecraft.client.gui.GuiGraphicsExtractor; -import net.minecraft.client.gui.components.Button; -import net.minecraft.client.gui.screens.Screen; -import net.minecraft.network.chat.Component; -import org.examp.john.rules.RulesData; - -import java.util.List; - -/** - * GUI екран правил серверу. Зверху ряд кнопок-вкладок (по одній на кожен розділ), - * знизу список пунктів обраного розділу з кольоровим трикутником-значком - * (зелений/жовтий/червоний) залежно від тяжкості порушення. - * - * Дані (розділи, пункти, нумерація, кольори) повністю визначаються rules.json - * на сервері — цей клас нічого не хардкодить, окрім розмірів/відступів GUI. - * - * У Minecraft 26.1.2+ Mojang перейменували GuiGraphics на GuiGraphicsExtractor - * (джерело: docs.fabricmc.net/develop/rendering/gui-graphics). - */ -public class RulesScreen extends Screen { - - // ARGB кольори. З Minecraft 1.21.6+ текстовий колір інтерпретується як ARGB, - // тому альфа-канал (FF) обов'язковий, інакше текст буде прозорим/невидимим. - private static final int COLOR_WHITE = 0xFFFFFFFF; - private static final int COLOR_GRAY = 0xFFAAAAAA; - private static final int COLOR_BG_PANEL = 0xCC101010; - private static final int COLOR_TAB_ACTIVE = 0xFF3A3A3A; - private static final int COLOR_TAB_INACTIVE = 0xFF202020; - - private static final int TAB_HEIGHT = 20; - private static final int TAB_WIDTH = 100; - private static final int ENTRY_HEIGHT = 24; - private static final int PADDING = 8; - - private final RulesData.RulesRoot rulesData; - private int selectedSection = 0; - private int scrollOffset = 0; - - public RulesScreen(RulesData.RulesRoot rulesData) { - super(Component.literal("Правила серверу")); - this.rulesData = rulesData; - } - - @Override - protected void init() { - super.init(); - clearWidgets(); - - if (rulesData == null || rulesData.sections == null || rulesData.sections.isEmpty()) { - return; - } - - int tabX = PADDING; - int tabY = PADDING + 20; - - for (int i = 0; i < rulesData.sections.size(); i++) { - final int index = i; - RulesData.Section section = rulesData.sections.get(i); - - Button tabButton = Button.builder( - Component.literal(section.title), - button -> { - selectedSection = index; - scrollOffset = 0; - } - ).bounds(tabX, tabY, TAB_WIDTH, TAB_HEIGHT).build(); - - addRenderableWidget(tabButton); - - tabX += TAB_WIDTH + 4; - // Переносимо на новий ряд вкладок, якщо не влазить по ширині екрану - if (tabX + TAB_WIDTH > this.width - PADDING) { - tabX = PADDING; - tabY += TAB_HEIGHT + 4; - } - } - - // Кнопка закриття внизу екрану - addRenderableWidget( - Button.builder(Component.literal("Закрити"), button -> onClose()) - .bounds(this.width / 2 - 50, this.height - PADDING - 20, 100, 20) - .build() - ); - } - - @Override - public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float delta) { - super.extractRenderState(graphics, mouseX, mouseY, delta); - - graphics.centeredText(this.font, this.title, this.width / 2, PADDING, COLOR_WHITE); - - if (rulesData == null || rulesData.sections == null || rulesData.sections.isEmpty()) { - graphics.centeredText( - this.font, - Component.literal("Правила ще не завантажені."), - this.width / 2, this.height / 2, COLOR_GRAY - ); - return; - } - - renderEntries(graphics); - } - - private void renderEntries(GuiGraphicsExtractor graphics) { - RulesData.Section section = rulesData.sections.get(selectedSection); - List entries = section.entries; - - if (entries == null || entries.isEmpty()) { - return; - } - - int panelTop = PADDING + 50; - int panelBottom = this.height - PADDING - 30; - int panelLeft = PADDING; - int panelRight = this.width - PADDING; - - graphics.fill(panelLeft, panelTop, panelRight, panelBottom, COLOR_BG_PANEL); - - int y = panelTop + 6 - scrollOffset; - - for (RulesData.Entry entry : entries) { - if (y + ENTRY_HEIGHT < panelTop || y > panelBottom) { - y += ENTRY_HEIGHT; - continue; - } - - // Кольоровий трикутник-значок тяжкості порушення - String triangle = "\u26A0"; // ⚠ - graphics.text(this.font, triangle, panelLeft + 6, y, entry.severity.color | 0xFF000000, false); - - // Номер пункту + текст - String line = entry.number + ". " + entry.text; - graphics.text(this.font, line, panelLeft + 24, y, COLOR_WHITE, false); - - y += ENTRY_HEIGHT; - } - } - - @Override - public boolean isPauseScreen() { - return false; - } -} \ No newline at end of file diff --git a/src/main/java/org/examp/john/Localchat.java b/src/main/java/org/examp/john/Localchat.java index be36627..a2fa5e4 100644 --- a/src/main/java/org/examp/john/Localchat.java +++ b/src/main/java/org/examp/john/Localchat.java @@ -2,7 +2,6 @@ package org.examp.john; import net.fabricmc.api.ModInitializer; import org.examp.john.rules.RulesCommand; -import org.examp.john.rules.RulesNetworking; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,9 +12,6 @@ public class Localchat implements ModInitializer { @Override public void onInitialize() { ChatHandler.register(); - - // Реєстрація мережі /rules (спільна частина: обов'язково і на сервері, і на клієнті) - RulesNetworking.registerCommon(); RulesCommand.register(); } -} +} \ No newline at end of file diff --git a/src/main/java/org/examp/john/rules/RulesCommand.java b/src/main/java/org/examp/john/rules/RulesCommand.java index 94c5aca..e5ffa14 100644 --- a/src/main/java/org/examp/john/rules/RulesCommand.java +++ b/src/main/java/org/examp/john/rules/RulesCommand.java @@ -3,18 +3,36 @@ 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.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; 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. Гравець виконує команду -> сервер читає rules.json - * -> надсилає RulesPayload через мережу -> клієнт відкриває RulesScreen (кастомний GUI з вкладками). + * Реєструє команду /rules. Повністю server-side: не потребує жодного мода + * на клієнті гравця. Книга створюється програмно через Data Components API + * і відкривається через openItemGui (ClientboundOpenBookPacket). + * Книга НЕ залишається в інвентарі — тимчасово ставиться в руку, + * відкривається, одразу повертається оригінальний предмет. */ public class RulesCommand { + private static final Map cooldowns = new HashMap<>(); + private static final long COOLDOWN_MS = 5000; + public static void register() { CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> dispatcher.register(buildCommand()) @@ -33,11 +51,77 @@ public class RulesCommand { 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(); - RulesPayload payload = RulesPayload.fromData(rules); - ServerPlayNetworking.send(player, payload); + 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> 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()); + } } diff --git a/src/main/java/org/examp/john/rules/RulesData.java b/src/main/java/org/examp/john/rules/RulesData.java index 2250f1a..ec72582 100644 --- a/src/main/java/org/examp/john/rules/RulesData.java +++ b/src/main/java/org/examp/john/rules/RulesData.java @@ -19,20 +19,22 @@ import java.util.List; public class RulesData { /** - * Рівень тяжкості порушення. Визначає колір трикутника-значка в GUI. - * LIGHT -> зелений - * MEDIUM -> жовтий - * HEAVY -> червоний + * Рівень тяжкості порушення. Визначає колір символу-значка в книзі правил + * через ванільні коди форматування (§) — книга рендериться повністю клієнтом, + * без жодного мода на клієнті. + * LIGHT -> зелений (§a) + * MEDIUM -> жовтий (§e) + * HEAVY -> червоний (§c) */ public enum Severity { - LIGHT(0x55FF55), // зелений - MEDIUM(0xFFFF55), // жовтий - HEAVY(0xFF5555); // червоний + LIGHT("§a"), + MEDIUM("§e"), + HEAVY("§c"); - public final int color; + public final String colorCode; - Severity(int color) { - this.color = color; + Severity(String colorCode) { + this.colorCode = colorCode; } public static Severity fromString(String raw) { @@ -77,4 +79,4 @@ public class RulesData { this.sections = sections; } } -} +} \ No newline at end of file diff --git a/src/main/java/org/examp/john/rules/RulesLoader.java b/src/main/java/org/examp/john/rules/RulesLoader.java index e6d47f6..7c0fb06 100644 --- a/src/main/java/org/examp/john/rules/RulesLoader.java +++ b/src/main/java/org/examp/john/rules/RulesLoader.java @@ -75,4 +75,4 @@ public class RulesLoader { Localchat.magicModLog.info("Створено конфіг за замовчуванням: {}", CONFIG_PATH.toAbsolutePath()); } -} +} \ No newline at end of file diff --git a/src/main/java/org/examp/john/rules/RulesNetworking.java b/src/main/java/org/examp/john/rules/RulesNetworking.java deleted file mode 100644 index 271cab4..0000000 --- a/src/main/java/org/examp/john/rules/RulesNetworking.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.examp.john.rules; - -import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; - -/** - * Реєструє тип пакета RulesPayload. Викликається і на клієнті, і на сервері - * (обов'язкова вимога Fabric API: обидві сторони мають знати про CustomPayload). - */ -public class RulesNetworking { - - public static void registerCommon() { - // У Fabric API 26.1+ playS2C() перейменували на clientboundPlay() - // (офіційний гайд порту: docs.fabricmc.net/develop/porting/fabric-api). - PayloadTypeRegistry.clientboundPlay().register(RulesPayload.TYPE, RulesPayload.CODEC); - } -} \ No newline at end of file diff --git a/src/main/java/org/examp/john/rules/RulesPayload.java b/src/main/java/org/examp/john/rules/RulesPayload.java deleted file mode 100644 index d47df5f..0000000 --- a/src/main/java/org/examp/john/rules/RulesPayload.java +++ /dev/null @@ -1,42 +0,0 @@ -package org.examp.john.rules; - -import com.google.gson.Gson; -import net.minecraft.network.RegistryFriendlyByteBuf; -import net.minecraft.network.codec.StreamCodec; -import net.minecraft.network.protocol.common.custom.CustomPacketPayload; -import net.minecraft.resources.Identifier; -import org.examp.john.Localchat; - -/** - * Пакет сервер -> клієнт, що передає весь список правил у вигляді JSON-рядка. - * Це найпростіший спосіб не писати окремий StreamCodec для кожного поля - * RulesData: серіалізуємо через Gson в один String і десеріалізуємо назад на клієнті. - */ -public record RulesPayload(String rulesJson) implements CustomPacketPayload { - - public static final CustomPacketPayload.Type TYPE = - new CustomPacketPayload.Type<>(Identifier.fromNamespaceAndPath(Localchat.MOD_ID, "rules_payload")); - - public static final StreamCodec CODEC = - StreamCodec.of( - (buf, payload) -> buf.writeUtf(payload.rulesJson(), Short.MAX_VALUE), - buf -> new RulesPayload(buf.readUtf(Short.MAX_VALUE)) - ); - - @Override - public Type type() { - return TYPE; - } - - /** Створює payload з поточних правил, серіалізуючи їх у JSON. */ - public static RulesPayload fromData(RulesData.RulesRoot data) { - Gson gson = new Gson(); - return new RulesPayload(gson.toJson(data)); - } - - /** Десеріалізує JSON назад у RulesData на клієнтській стороні. */ - public RulesData.RulesRoot toData() { - Gson gson = new Gson(); - return gson.fromJson(rulesJson, RulesData.RulesRoot.class); - } -} \ No newline at end of file diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 7694098..e9a0fc4 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -13,9 +13,6 @@ "fabric-datagen": [ "org.examp.john.client.LocalchatDataGenerator" ], - "client": [ - "org.examp.john.client.LocalchatClient" - ], "main": [ "org.examp.john.Localchat" ] From 2175fb1a28e417fad43284354f408365f962de23 Mon Sep 17 00:00:00 2001 From: John Etherfield Date: Fri, 14 Aug 2026 17:12:55 +0200 Subject: [PATCH 3/4] feat: clickable player names with LuckPerms colors --- src/main/java/org/examp/john/AntiAbuse.java | 77 ++++++++++ src/main/java/org/examp/john/ChatHandler.java | 85 ++++++---- src/main/java/org/examp/john/Localchat.java | 8 +- .../examp/john/stealth/StealthCommand.java | 145 ++++++++++++++++++ .../examp/john/stealth/StealthStorage.java | 83 ++++++++++ 5 files changed, 362 insertions(+), 36 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..7d53289 --- /dev/null +++ b/src/main/java/org/examp/john/AntiAbuse.java @@ -0,0 +1,77 @@ +package org.examp.john; + +import net.minecraft.server.level.ServerPlayer; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Детектить патерн пошуку гравців через локальний чат. + * + * Якщо одне і те ж повідомлення повторюється >=5 разів за 5 хвилин + * і гравець перемістився >50 блоків — логує в консоль. + */ +public class AntiAbuse { + + private static final int REPEAT_THRESHOLD = 5; + private static final double DISTANCE_THRESHOLD = 50.0; + private static final long TIME_WINDOW_MS = 5 * 60 * 1000; + + private static final Map> history = new HashMap<>(); + + public static void recordMessage(ServerPlayer player, String message) { + UUID uuid = player.getUUID(); + List records = history.computeIfAbsent(uuid, k -> new ArrayList<>()); + + long now = System.currentTimeMillis(); + records.add(new MessageRecord(message, now, player.getX(), player.getZ())); + + long cutoff = now - TIME_WINDOW_MS; + records.removeIf(r -> r.timestamp < cutoff); + + if (records.size() >= REPEAT_THRESHOLD) { + checkForAbuse(player, records); + } + } + + private static void checkForAbuse(ServerPlayer player, List records) { + Map counts = new HashMap<>(); + Map bounds = new HashMap<>(); + + for (MessageRecord r : records) { + counts.merge(r.message, new int[]{1}, (a, b) -> { + a[0]++; + return a; + }); + + double[] b = bounds.computeIfAbsent(r.message, k -> new double[]{r.x, r.x, r.z, r.z}); + if (r.x < b[0]) b[0] = r.x; + if (r.x > b[1]) b[1] = r.x; + if (r.z < b[2]) b[2] = r.z; + if (r.z > b[3]) b[3] = r.z; + } + + for (Map.Entry entry : counts.entrySet()) { + if (entry.getValue()[0] < REPEAT_THRESHOLD) continue; + + double[] b = bounds.get(entry.getKey()); + double spread = Math.max(b[1] - b[0], b[3] - b[2]); + + if (spread > DISTANCE_THRESHOLD) { + Localchat.magicModLog.warn( + "[ANTI-ABUSE] Гравець {} ймовірно шукає інших: " + + "'{}' ×{} разів, переміщення ~{} блоків", + player.getName().getString(), + entry.getKey(), + entry.getValue()[0], + (int) spread + ); + } + } + } + + private record MessageRecord(String message, long timestamp, double x, double z) {} +} diff --git a/src/main/java/org/examp/john/ChatHandler.java b/src/main/java/org/examp/john/ChatHandler.java index 6061e12..901ae29 100644 --- a/src/main/java/org/examp/john/ChatHandler.java +++ b/src/main/java/org/examp/john/ChatHandler.java @@ -5,11 +5,14 @@ import net.luckperms.api.LuckPerms; import net.luckperms.api.LuckPermsProvider; import net.luckperms.api.model.user.User; import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.ClickEvent; import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.HoverEvent; import net.minecraft.network.chat.MutableComponent; -import net.minecraft.server.MinecraftServer; +import net.minecraft.network.chat.Style; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; +import org.examp.john.stealth.StealthStorage; import java.util.ArrayList; import java.util.List; @@ -22,29 +25,11 @@ public class ChatHandler { ServerMessageEvents.ALLOW_CHAT_MESSAGE.register((message, sender, params) -> { String text = message.signedContent(); ServerLevel serverLevel = sender.level(); - MinecraftServer server = serverLevel.getServer(); - List players = server.getPlayerList().getPlayers(); + List players = serverLevel.getServer().getPlayerList().getPlayers(); - String playerPrefix = ""; - try { - LuckPerms luckPerms = LuckPermsProvider.get(); - User user = luckPerms.getUserManager().getUser(sender.getUUID()); - if (user != null) { - String metaPrefix = user.getCachedData().getMetaData().getPrefix(); - if (metaPrefix != null) { - playerPrefix = metaPrefix; - } - } - } catch (Throwable ignored) { - } - - MutableComponent formattedPrefix = Component.literal(playerPrefix.replace("&", "§")); - MutableComponent formattedName = Component.empty() - .append(formattedPrefix) - .append(sender.getDisplayName()); + MutableComponent formattedName = buildFormattedName(sender); if (text.startsWith(prefix)) { - // ГЛОБАЛЬНИЙ ЧАТ String actualMessage = text.substring(1).trim(); Component globalMessage = Component.empty() .append(formattedName) @@ -54,17 +39,26 @@ public class ChatHandler { player.sendSystemMessage(globalMessage); } - // Логування: глобальний чат чують всі онлайн-гравці ChatLogger.logGlobal(sender, actualMessage, players); } else { - // ЛОКАЛЬНИЙ ЧАТ + AntiAbuse.recordMessage(sender, text); + + if (StealthStorage.isEnabled(sender.getUUID())) { + sender.sendSystemMessage( + Component.literal("Вас ніхто не почув...") + .withStyle(ChatFormatting.ITALIC, ChatFormatting.YELLOW) + ); + ChatLogger.logUnheard(sender, text); + return false; + } + List hearers = new ArrayList<>(); for (ServerPlayer player : players) { - if (player.level() == serverLevel && player.distanceTo(sender) <= LOCAL_CHAT_RADIUS) { - if (player != sender) { - hearers.add(player); - } + if (player != sender + && player.level() == serverLevel + && player.distanceTo(sender) <= LOCAL_CHAT_RADIUS) { + hearers.add(player); } } @@ -73,27 +67,52 @@ public class ChatHandler { .append(formattedName) .append(Component.literal(": " + text).withStyle(ChatFormatting.GRAY)); - // ОПТИМІЗАЦІЯ: Замість повторної перевірки всіх гравців сервера, - // просто відправляємо повідомлення самому відправнику та вже готовому списку слухачів. sender.sendSystemMessage(localMessage); for (ServerPlayer hearer : hearers) { hearer.sendSystemMessage(localMessage); } - // Логування: локальний чат, хто почув + координати ChatLogger.logLocal(sender, text, hearers); } else { sender.sendSystemMessage( - Component.literal("Вас ніхто не почув, додайте на початок повідомлення \"!\" аби написати в глобальний чат") + Component.literal("Вас ніхто не почув, додайте \"!\" на початок для глобального чату") .withStyle(ChatFormatting.ITALIC, ChatFormatting.YELLOW) ); - // Логування: ніхто не почув ChatLogger.logUnheard(sender, text); } } return false; }); } -} \ No newline at end of file + + private static MutableComponent buildFormattedName(ServerPlayer player) { + String luckPermsPrefix = getLuckPermsPrefix(player); + String playerName = player.getName().getString(); + String formattedPrefix = luckPermsPrefix.replace("&", "\u00a7"); + + Style clickStyle = Style.EMPTY + .withClickEvent(new ClickEvent.SuggestCommand("/msg " + playerName + " ")) + .withHoverEvent(new HoverEvent.ShowText( + Component.literal("Натисніть щоб написати " + playerName) + .withStyle(ChatFormatting.GRAY))); + + return Component.literal(formattedPrefix + playerName).withStyle(clickStyle); + } + + private static String getLuckPermsPrefix(ServerPlayer player) { + try { + LuckPerms luckPerms = LuckPermsProvider.get(); + User user = luckPerms.getUserManager().getUser(player.getUUID()); + if (user != null) { + String metaPrefix = user.getCachedData().getMetaData().getPrefix(); + if (metaPrefix != null) { + return metaPrefix; + } + } + } catch (Throwable ignored) { + } + return ""; + } +} diff --git a/src/main/java/org/examp/john/Localchat.java b/src/main/java/org/examp/john/Localchat.java index a2fa5e4..b37a737 100644 --- a/src/main/java/org/examp/john/Localchat.java +++ b/src/main/java/org/examp/john/Localchat.java @@ -1,7 +1,8 @@ package org.examp.john; import net.fabricmc.api.ModInitializer; -import org.examp.john.rules.RulesCommand; +import org.examp.john.stealth.StealthCommand; +import org.examp.john.stealth.StealthStorage; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -11,7 +12,8 @@ public class Localchat implements ModInitializer { @Override public void onInitialize() { + StealthStorage.load(); ChatHandler.register(); - RulesCommand.register(); + StealthCommand.register(); } -} \ No newline at end of file +} diff --git a/src/main/java/org/examp/john/stealth/StealthCommand.java b/src/main/java/org/examp/john/stealth/StealthCommand.java new file mode 100644 index 0000000..77749d7 --- /dev/null +++ b/src/main/java/org/examp/john/stealth/StealthCommand.java @@ -0,0 +1,145 @@ +package org.examp.john.stealth; + +import com.mojang.brigadier.builder.LiteralArgumentBuilder; +import com.mojang.brigadier.context.CommandContext; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.minecraft.ChatFormatting; +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; + +/** + * Команда /stealth — керує режимом прихованості гравця. + * + * Підкоманди: + * /stealth — переключити стан (on/off) + * /stealth on — увімкнути stealth + * /stealth off — вимкнути stealth + * /stealth info — показати інформацію про команду + */ +public class StealthCommand { + + public static void register() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> + dispatcher.register(buildCommand()) + ); + } + + private static LiteralArgumentBuilder buildCommand() { + return Commands.literal("stealth") + .executes(StealthCommand::executeToggle) + .then(Commands.literal("on").executes(StealthCommand::executeOn)) + .then(Commands.literal("off").executes(StealthCommand::executeOff)) + .then(Commands.literal("info").executes(StealthCommand::executeInfo)); + } + + private static int executeToggle(CommandContext context) { + ServerPlayer player = getPlayer(context); + if (player == null) return 0; + boolean newState = StealthStorage.toggle(player.getUUID()); + sendStatus(player, newState); + return 1; + } + + private static int executeOn(CommandContext context) { + ServerPlayer player = getPlayer(context); + if (player == null) return 0; + StealthStorage.set(player.getUUID(), true); + sendStatus(player, true); + return 1; + } + + private static int executeOff(CommandContext context) { + ServerPlayer player = getPlayer(context); + if (player == null) return 0; + StealthStorage.set(player.getUUID(), false); + sendStatus(player, false); + return 1; + } + + private static int executeInfo(CommandContext context) { + ServerPlayer player = getPlayer(context); + if (player == null) return 0; + + boolean enabled = StealthStorage.isEnabled(player.getUUID()); + + player.sendSystemMessage(Component.literal("=== Stealth Mode ===").withStyle(ChatFormatting.GOLD)); + player.sendSystemMessage(Component.empty()); + player.sendSystemMessage(Component.literal("Статус: ").append( + enabled + ? Component.literal("УВІМКНЕНО").withStyle(ChatFormatting.GREEN) + : Component.literal("ВИМКНЕНО").withStyle(ChatFormatting.RED) + )); + player.sendSystemMessage(Component.empty()); + + player.sendSystemMessage(Component.literal("Навіщо це потрібно:").withStyle(ChatFormatting.YELLOW)); + player.sendSystemMessage(Component.literal(" Локальний чат можна зловживати для пошуку") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" гравців: хтось пише в чат, бігає по карті,") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" і дивиться хто відповів — так можна") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" визначити місцеперебування іншого гравця.") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" Stealth прибирає вас з локального чату:") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" ваші повідомлення не бачить ніхто,") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" але ви можете бачити чужі якщо вони поруч.") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.empty()); + + player.sendSystemMessage(Component.literal("Підкоманди:").withStyle(ChatFormatting.YELLOW)); + player.sendSystemMessage(Component.literal(" /stealth").withStyle(ChatFormatting.AQUA) + .append(Component.literal(" — переключити стан (on/off)").withStyle(ChatFormatting.GRAY))); + player.sendSystemMessage(Component.literal(" /stealth on").withStyle(ChatFormatting.AQUA) + .append(Component.literal(" — увімкнути stealth").withStyle(ChatFormatting.GRAY))); + player.sendSystemMessage(Component.literal(" /stealth off").withStyle(ChatFormatting.AQUA) + .append(Component.literal(" — вимкнути stealth").withStyle(ChatFormatting.GRAY))); + player.sendSystemMessage(Component.literal(" /stealth info").withStyle(ChatFormatting.AQUA) + .append(Component.literal(" — ця інформація").withStyle(ChatFormatting.GRAY))); + player.sendSystemMessage(Component.empty()); + + player.sendSystemMessage(Component.literal("Як працює:").withStyle(ChatFormatting.YELLOW)); + player.sendSystemMessage(Component.literal(" Звичайний чат: повідомлення бачать лише") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" ті хто в радіусі 100 блоків.") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" Якщо stealth ON: ваші повідомлення") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" не доходять взагалі — ви бачите") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" 'Вас ніхто не почув...' замість цього.") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" Глобальний чат (! префікс) працює") + .withStyle(ChatFormatting.GRAY)); + player.sendSystemMessage(Component.literal(" завжди, навіть з stealth.") + .withStyle(ChatFormatting.GRAY)); + + return 1; + } + + private static void sendStatus(ServerPlayer player, boolean enabled) { + if (enabled) { + player.sendSystemMessage(Component.literal("Stealth-режим УВІМКНЕНО.") + .withStyle(ChatFormatting.GREEN)); + player.sendSystemMessage(Component.literal("Ваші повідомлення в локальному чаті тепер не доходять.") + .withStyle(ChatFormatting.GRAY)); + } else { + player.sendSystemMessage(Component.literal("Stealth-режим ВИМКНЕНО.") + .withStyle(ChatFormatting.RED)); + player.sendSystemMessage(Component.literal("Ваші повідомлення в локальному чаті знову чутні.") + .withStyle(ChatFormatting.GRAY)); + } + } + + private static ServerPlayer getPlayer(CommandContext context) { + if (!(context.getSource().getEntity() instanceof ServerPlayer player)) { + context.getSource().sendFailure( + Component.literal("Цю команду можна викликати лише в грі.")); + return null; + } + return player; + } +} diff --git a/src/main/java/org/examp/john/stealth/StealthStorage.java b/src/main/java/org/examp/john/stealth/StealthStorage.java new file mode 100644 index 0000000..ebeb4dd --- /dev/null +++ b/src/main/java/org/examp/john/stealth/StealthStorage.java @@ -0,0 +1,83 @@ +package org.examp.john.stealth; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonSyntaxException; +import org.examp.john.Localchat; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Зберігає стан stealth-режиму для кожного гравця. + * Файл: config/localchat/stealth.json — {"uuid1": true, "uuid2": false, ...} + */ +public class StealthStorage { + + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Path CONFIG_PATH = Path.of("config", "localchat", "stealth.json"); + + private static Map data = new HashMap<>(); + + public static void load() { + try { + if (!Files.exists(CONFIG_PATH)) { + data = new HashMap<>(); + save(); + return; + } + try (Reader reader = Files.newBufferedReader(CONFIG_PATH, StandardCharsets.UTF_8)) { + @SuppressWarnings("unchecked") + Map raw = GSON.fromJson(reader, Map.class); + data = new HashMap<>(); + if (raw != null) { + for (Map.Entry entry : raw.entrySet()) { + data.put(UUID.fromString(entry.getKey()), entry.getValue()); + } + } + } + } catch (IOException | JsonSyntaxException | IllegalArgumentException e) { + Localchat.magicModLog.warn("Не вдалося прочитати stealth.json: {}", e.getMessage()); + data = new HashMap<>(); + } + } + + public static boolean isEnabled(UUID uuid) { + return data.getOrDefault(uuid, false); + } + + public static boolean toggle(UUID uuid) { + boolean current = isEnabled(uuid); + boolean newValue = !current; + data.put(uuid, newValue); + save(); + return newValue; + } + + public static void set(UUID uuid, boolean value) { + data.put(uuid, value); + save(); + } + + private static void save() { + try { + Files.createDirectories(CONFIG_PATH.getParent()); + try (Writer writer = Files.newBufferedWriter(CONFIG_PATH, StandardCharsets.UTF_8)) { + Map raw = new HashMap<>(); + for (Map.Entry entry : data.entrySet()) { + raw.put(entry.getKey().toString(), entry.getValue()); + } + GSON.toJson(raw, writer); + } + } catch (IOException e) { + Localchat.magicModLog.error("Не вдалося зберегти stealth.json: {}", e.getMessage()); + } + } +} From 06c9ec49760c2765681f2ad5959683b6798d86c2 Mon Sep 17 00:00:00 2001 From: John Etherfield Date: Mon, 5 Oct 2026 19:41:01 +0200 Subject: [PATCH 4/4] feat: add full server rules to /rules book --- org/examp/john/rules/RulesCommand.class | Bin 0 -> 11529 bytes .../mixin/client/PlayerTabOverlayMixin.java | 49 +++++++ .../resources/localchat.client.mixins.json | 1 + src/main/java/org/examp/john/Localchat.java | 2 + .../org/examp/john/rules/RulesCommand.java | 129 +++++++++++++++--- .../java/org/examp/john/rules/RulesData.java | 79 ++++++++++- .../org/examp/john/rules/RulesLoader.java | 6 +- 7 files changed, 240 insertions(+), 26 deletions(-) create mode 100644 org/examp/john/rules/RulesCommand.class create mode 100644 src/client/java/org/examp/john/mixin/client/PlayerTabOverlayMixin.java diff --git a/org/examp/john/rules/RulesCommand.class b/org/examp/john/rules/RulesCommand.class new file mode 100644 index 0000000000000000000000000000000000000000..ea12c2717696a785f5b5a14a45490db85c371d5b GIT binary patch literal 11529 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 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); + } +} \ No newline at end of file diff --git a/src/client/resources/localchat.client.mixins.json b/src/client/resources/localchat.client.mixins.json index b19dc56..f4006f2 100644 --- a/src/client/resources/localchat.client.mixins.json +++ b/src/client/resources/localchat.client.mixins.json @@ -4,6 +4,7 @@ "package": "org.examp.john.mixin.client", "compatibilityLevel": "JAVA_25", "client": [ + "PlayerTabOverlayMixin" ], "injectors": { "defaultRequire": 1 diff --git a/src/main/java/org/examp/john/Localchat.java b/src/main/java/org/examp/john/Localchat.java index b37a737..37321c4 100644 --- a/src/main/java/org/examp/john/Localchat.java +++ b/src/main/java/org/examp/john/Localchat.java @@ -1,6 +1,7 @@ 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; @@ -15,5 +16,6 @@ public class Localchat implements ModInitializer { StealthStorage.load(); ChatHandler.register(); StealthCommand.register(); + RulesCommand.register(); } } diff --git a/src/main/java/org/examp/john/rules/RulesCommand.java b/src/main/java/org/examp/john/rules/RulesCommand.java index e5ffa14..a0e2f1e 100644 --- a/src/main/java/org/examp/john/rules/RulesCommand.java +++ b/src/main/java/org/examp/john/rules/RulesCommand.java @@ -22,11 +22,9 @@ import java.util.Map; import java.util.UUID; /** - * Реєструє команду /rules. Повністю server-side: не потребує жодного мода - * на клієнті гравця. Книга створюється програмно через Data Components API - * і відкривається через openItemGui (ClientboundOpenBookPacket). - * Книга НЕ залишається в інвентарі — тимчасово ставиться в руку, - * відкривається, одразу повертається оригінальний предмет. + * Команда /rules. Повністю server-side: клієнт не потребує мода. + * Книга створюється через Data Components API (WrittenBookContent). + * Відкривається через openItemGui — книга НЕ залишається в інвентарі. */ public class RulesCommand { @@ -87,12 +85,23 @@ public class RulesCommand { player.setItemInHand(hand, originalItem); } + /** + * Створює книгу правил: + * 1. Сторінка 0 — Легенда + Зміст + * 2. Кожен розділ починається на новій сторінці + * 3. Пагінація по ~280 символів/сторінку (реальний ліміт книги) + */ private static ItemStack createRuleBook(RulesData.RulesRoot rules) { ItemStack book = new ItemStack(Items.WRITTEN_BOOK); List> pages = new ArrayList<>(); + + // Сторінка 0: Легенда + Зміст + pages.add(Filterable.passThrough(buildLegendAndContentsPage(rules))); + + // Сторінки з правилами (кожен розділ починається на новій сторінці) for (RulesData.Section section : rules.sections) { - pages.add(Filterable.passThrough(buildPageComponent(section))); + pages.addAll(buildSectionPages(section)); } WrittenBookContent content = new WrittenBookContent( @@ -107,21 +116,101 @@ public class RulesCommand { return book; } - private static Component buildPageComponent(RulesData.Section section) { - StringBuilder pageText = new StringBuilder(); - pageText.append("§l§n").append(section.title).append("§r\n\n"); + /** Сторінка 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"); - 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"); - } + 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); } - return Component.literal(pageText.toString()); + 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> buildSectionPages(RulesData.Section section) { + List> 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; + } +} \ No newline at end of file diff --git a/src/main/java/org/examp/john/rules/RulesData.java b/src/main/java/org/examp/john/rules/RulesData.java index ec72582..f1f30fc 100644 --- a/src/main/java/org/examp/john/rules/RulesData.java +++ b/src/main/java/org/examp/john/rules/RulesData.java @@ -1,5 +1,6 @@ package org.examp.john.rules; +import java.util.ArrayList; import java.util.List; /** @@ -49,8 +50,8 @@ public class RulesData { /** Один пункт правила всередині розділу. */ public static class Entry { - public String number; // напр. "1.1" або "IV" - public String text; // текст правила / опис порушення + public String number; + public String text; public Severity severity; public Entry(String number, String text, Severity severity) { @@ -79,4 +80,78 @@ public class RulesData { this.sections = sections; } } + + /** Повний набір правил за замовчуванням для створення конфігу при першому запуску. */ + public static RulesRoot createDefaultRules() { + List
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); + } } \ No newline at end of file diff --git a/src/main/java/org/examp/john/rules/RulesLoader.java b/src/main/java/org/examp/john/rules/RulesLoader.java index 7c0fb06..2882984 100644 --- a/src/main/java/org/examp/john/rules/RulesLoader.java +++ b/src/main/java/org/examp/john/rules/RulesLoader.java @@ -65,14 +65,12 @@ public class RulesLoader { 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)); + 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()); + Localchat.magicModLog.info("Створено конфіг правил за замовчуванням: {}", CONFIG_PATH.toAbsolutePath()); } } \ No newline at end of file