diff --git a/.gitignore b/.gitignore index d26488b..b191285 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ -build/** -run/** -.idea/** -.gradle/** +build/ +run/ +out/ +/org/ +*.class +.idea/ +.gradle/ diff --git a/out/client/resources/localchat.client.mixins.json b/out/client/resources/localchat.client.mixins.json deleted file mode 100644 index b19dc56..0000000 --- a/out/client/resources/localchat.client.mixins.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "required": true, - "minVersion": "0.8", - "package": "org.examp.john.mixin.client", - "compatibilityLevel": "JAVA_25", - "client": [ - ], - "injectors": { - "defaultRequire": 1 - }, - "overwrites": { - "requireAnnotations": true - } -} diff --git a/out/production/resources/fabric.mod.json b/out/production/resources/fabric.mod.json deleted file mode 100644 index 7694098..0000000 --- a/out/production/resources/fabric.mod.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "schemaVersion": 1, - "id": "localchat", - "version": "${version}", - "name": "localchat", - "description": "", - "authors": [], - "contact": {}, - "license": "All-Rights-Reserved", - "icon": "assets/localchat/icon.png", - "environment": "*", - "entrypoints": { - "fabric-datagen": [ - "org.examp.john.client.LocalchatDataGenerator" - ], - "client": [ - "org.examp.john.client.LocalchatClient" - ], - "main": [ - "org.examp.john.Localchat" - ] - }, - "mixins": [ - "localchat.mixins.json", - { - "config": "localchat.client.mixins.json", - "environment": "client" - } - ], - "depends": { - "fabricloader": ">=${loader_version}", - "fabric-api": "*", - "minecraft": "${minecraft_version}" - } -} diff --git a/out/production/resources/localchat.mixins.json b/out/production/resources/localchat.mixins.json deleted file mode 100644 index eb1a5f0..0000000 --- a/out/production/resources/localchat.mixins.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "required": true, - "minVersion": "0.8", - "package": "org.examp.john.mixin", - "compatibilityLevel": "JAVA_25", - "mixins": [ - ], - "injectors": { - "defaultRequire": 1 - }, - "overwrites": { - "requireAnnotations": true - } -} diff --git a/src/main/java/org/examp/john/ChatHandler.java b/src/main/java/org/examp/john/ChatHandler.java index f96c139..bc87550 100644 --- a/src/main/java/org/examp/john/ChatHandler.java +++ b/src/main/java/org/examp/john/ChatHandler.java @@ -264,9 +264,16 @@ public class ChatHandler { private static MutableComponent buildFormattedName(ServerPlayer player) { String prefix = formatLegacyPrefix(getLuckPermsPrefix(player)); + String playerName = player.getName().getString(); + + Style clickStyle = Style.EMPTY + .withClickEvent(new ClickEvent.SuggestCommand("/msg " + playerName + " ")) + .withHoverEvent(new HoverEvent.ShowText( + Component.literal("Натисніть щоб написати " + playerName) + .withStyle(ChatFormatting.GRAY))); return Component.empty() .append(Component.literal(prefix)) - .append(player.getDisplayName()); + .append(player.getDisplayName().copy().withStyle(clickStyle)); } } diff --git a/src/main/java/org/examp/john/MSNexus.java b/src/main/java/org/examp/john/MSNexus.java index f1c86b2..a0c79ae 100644 --- a/src/main/java/org/examp/john/MSNexus.java +++ b/src/main/java/org/examp/john/MSNexus.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; @@ -16,5 +17,6 @@ public class MSNexus implements ModInitializer { AntiAbuse.register(); 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 new file mode 100644 index 0000000..9b6486d --- /dev/null +++ b/src/main/java/org/examp/john/rules/RulesCommand.java @@ -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.MSNexus; + +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 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 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; + } + + 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); + MSNexus.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> 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> 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 new file mode 100644 index 0000000..f1f30fc --- /dev/null +++ b/src/main/java/org/examp/john/rules/RulesData.java @@ -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 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; + } + } + + /** Повний набір правил за замовчуванням для створення конфігу при першому запуску. */ + 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 new file mode 100644 index 0000000..34eac8e --- /dev/null +++ b/src/main/java/org/examp/john/rules/RulesLoader.java @@ -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.MSNexus; + +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) { + MSNexus.magicModLog.warn("rules.json порожній або невалідний, використовую пустий список правил"); + return new RulesData.RulesRoot(Collections.emptyList()); + } + return root; + } + } catch (IOException e) { + MSNexus.magicModLog.error("Не вдалося прочитати rules.json: {}", e.getMessage()); + return new RulesData.RulesRoot(Collections.emptyList()); + } catch (JsonSyntaxException e) { + MSNexus.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); + } + + MSNexus.magicModLog.info("Створено конфіг правил за замовчуванням: {}", CONFIG_PATH.toAbsolutePath()); + } +} \ No newline at end of file