Author SHA1 Message Date
john 40876fec9f merge: resolve conflicts between master and feature/rules-command
- keep master's rename Localchat -> MSNexus (msnexus id, environment: server)
- keep master's AntiAbuse/StealthCommand/StealthStorage/ChatLogger/ChatHandler
  (error handling, alert cooldown, atomic stealth.json save, **italic**, @mentions)
- keep feature's /rules command (RulesCommand/RulesData/RulesLoader), wired into MSNexus
- re-apply feature's clickable/hoverable sender name from buildFormattedName
- fix stale org.examp.john.Localchat references in rules/*
- drop dead client PlayerTabOverlayMixin (mod is server-only, spectator revert kept)
- remove build artifacts out/ and RulesCommand.class from git, ignore them
2026-10-11 11:34:34 +02:00
john 06c9ec4976 feat: add full server rules to /rules book 2026-10-05 19:41:01 +02:00
john 2175fb1a28 feat: clickable player names with LuckPerms colors 2026-08-14 17:12:55 +02:00
john 3537060ba5 rewrite: /rules via temporary written_book instead of GUI 2026-08-14 15:53:58 +02:00
john a4d2fc5f5e feat: implement /rules command with custom networking payload 2026-08-12 09:54:33 +02:00
9 changed files with 466 additions and 68 deletions
+7 -4
View File
@@ -1,4 +1,7 @@
build/** build/
run/** run/
.idea/** out/
.gradle/** /org/
*.class
.idea/
.gradle/
@@ -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
}
}
-35
View File
@@ -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}"
}
}
@@ -1,14 +0,0 @@
{
"required": true,
"minVersion": "0.8",
"package": "org.examp.john.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
],
"injectors": {
"defaultRequire": 1
},
"overwrites": {
"requireAnnotations": true
}
}
@@ -264,9 +264,16 @@ public class ChatHandler {
private static MutableComponent buildFormattedName(ServerPlayer player) { private static MutableComponent buildFormattedName(ServerPlayer player) {
String prefix = formatLegacyPrefix(getLuckPermsPrefix(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() return Component.empty()
.append(Component.literal(prefix)) .append(Component.literal(prefix))
.append(player.getDisplayName()); .append(player.getDisplayName().copy().withStyle(clickStyle));
} }
} }
@@ -1,6 +1,7 @@
package org.examp.john; package org.examp.john;
import net.fabricmc.api.ModInitializer; import net.fabricmc.api.ModInitializer;
import org.examp.john.rules.RulesCommand;
import org.examp.john.stealth.StealthCommand; import org.examp.john.stealth.StealthCommand;
import org.examp.john.stealth.StealthStorage; import org.examp.john.stealth.StealthStorage;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -16,5 +17,6 @@ public class MSNexus implements ModInitializer {
AntiAbuse.register(); AntiAbuse.register();
ChatHandler.register(); ChatHandler.register();
StealthCommand.register(); StealthCommand.register();
RulesCommand.register();
} }
} }
@@ -0,0 +1,216 @@
package org.examp.john.rules;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.core.component.DataComponents;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.server.network.Filterable;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.component.WrittenBookContent;
import org.examp.john.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<UUID, Long> cooldowns = new HashMap<>();
private static final long COOLDOWN_MS = 5000;
public static void register() {
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) ->
dispatcher.register(buildCommand())
);
}
private static LiteralArgumentBuilder<CommandSourceStack> buildCommand() {
return Commands.literal("rules").executes(RulesCommand::execute);
}
private static int execute(CommandContext<CommandSourceStack> context) {
CommandSourceStack source = context.getSource();
if (!(source.getEntity() instanceof ServerPlayer player)) {
source.sendFailure(Component.literal("Цю команду можна викликати лише в грі."));
return 0;
}
long now = System.currentTimeMillis();
Long lastUsed = cooldowns.get(player.getUUID());
if (lastUsed != null && (now - lastUsed) < COOLDOWN_MS) {
long remainingMs = COOLDOWN_MS - (now - lastUsed);
long secondsLeft = (remainingMs + 999) / 1000;
player.sendSystemMessage(Component.literal(
"Зачекайте ще " + secondsLeft + " сек. перед повторним використанням /rules."
));
return 0;
}
cooldowns.put(player.getUUID(), now);
RulesData.RulesRoot rules = RulesLoader.get();
if (rules.sections == null || rules.sections.isEmpty()) {
source.sendFailure(Component.literal("Правила ще не налаштовані. Зверніться до адміністрації."));
return 0;
}
openBook(player, rules);
return 1;
}
private static void openBook(ServerPlayer player, RulesData.RulesRoot rules) {
InteractionHand hand = InteractionHand.MAIN_HAND;
ItemStack originalItem = player.getItemInHand(hand).copy();
ItemStack book = createRuleBook(rules);
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<Filterable<Component>> pages = new ArrayList<>();
// Сторінка 0: Легенда + Зміст
pages.add(Filterable.passThrough(buildLegendAndContentsPage(rules)));
// Сторінки з правилами (кожен розділ починається на новій сторінці)
for (RulesData.Section section : rules.sections) {
pages.addAll(buildSectionPages(section));
}
WrittenBookContent content = new WrittenBookContent(
Filterable.passThrough("Правила серверу"),
"Server",
0,
pages,
true
);
book.set(DataComponents.WRITTEN_BOOK_CONTENT, content);
return book;
}
/** Сторінка 0: Легенда + Зміст */
private static Component buildLegendAndContentsPage(RulesData.RulesRoot rules) {
StringBuilder sb = new StringBuilder();
sb.append("§l§nПравила серверу§r\n\n");
sb.append("§l§nЛегенда важливості§r\n");
sb.append("§c⚠§r — Тяжке порушення (бан 5+ днів або перманент)\n");
sb.append("§e⚠§r — Середнє порушення (бан 1–5 днів)\n");
sb.append("§a⚠§r — Легке порушення (попередження/мут)\n\n");
sb.append("§l§nЗміст§r\n\n");
int page = 1;
for (RulesData.Section section : rules.sections) {
sb.append(" §e► §r§f").append(section.title).append(" §7(стор. ").append(page).append(")§r\n");
page += estimatePagesForSection(section);
}
sb.append("\n§7Стрілки в книзі — навігація.§r");
return Component.literal(sb.toString());
}
/** Оцінює скільки сторінок займе розділ (~180 видимих символів/сторінку — фізичний ліміт книги) */
private static int estimatePagesForSection(RulesData.Section section) {
if (section.entries == null || section.entries.isEmpty()) return 1;
final int CHARS_PER_PAGE = 180;
int totalVisible = section.title.length() + 30;
for (RulesData.Entry entry : section.entries) {
totalVisible += visibleLength(entry.number) + visibleLength(entry.text) + 30;
}
return Math.max(1, (totalVisible + CHARS_PER_PAGE - 1) / CHARS_PER_PAGE);
}
/** Повертає довжину рядка БЕЗ §-кодів (тільки видимі символи) */
private static int visibleLength(String s) {
if (s == null) return 0;
int len = 0;
boolean inCode = false;
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c == '§') {
inCode = true;
continue;
}
if (inCode) {
inCode = false;
continue;
}
len++;
}
return len;
}
/** Будує сторінки для одного розділу з пагінацією */
private static List<Filterable<Component>> buildSectionPages(RulesData.Section section) {
List<Filterable<Component>> pages = new ArrayList<>();
if (section.entries == null || section.entries.isEmpty()) {
pages.add(Filterable.passThrough(Component.literal("§l§n" + section.title + "§r\n\n§7(розділ порожній)§r")));
return pages;
}
final int CHARS_PER_PAGE = 180;
StringBuilder currentPage = new StringBuilder();
int currentVisible = 0;
boolean firstPage = true;
for (RulesData.Entry entry : section.entries) {
String symbol = entry.severity != null ? entry.severity.colorCode + "⚠§r" : "§a⚠§r";
String entryText = symbol + " " + entry.number + ". " + entry.text + "\n\n";
int entryVisible = visibleLength(entryText);
if (firstPage) {
String header = "§l§n" + section.title + "§r\n\n";
currentPage.append(header);
currentVisible = visibleLength(header);
firstPage = false;
}
// Якщо правило не вміщується на поточній сторінці — переносимо ЦІЛЕ правило на нову сторінку
if (currentVisible + entryVisible > CHARS_PER_PAGE && currentVisible > 0) {
pages.add(Filterable.passThrough(Component.literal(currentPage.toString())));
currentPage = new StringBuilder();
currentVisible = 0;
}
currentPage.append(entryText);
currentVisible += entryVisible;
}
if (currentVisible > 0) {
pages.add(Filterable.passThrough(Component.literal(currentPage.toString())));
}
return pages;
}
}
@@ -0,0 +1,157 @@
package org.examp.john.rules;
import java.util.ArrayList;
import java.util.List;
/**
* Моделі даних для системи правил сервера.
* Структура JSON конфігу:
* {
* "sections": [
* {
* "title": "Правила будівництва",
* "entries": [
* { "number": "1.1", "text": "Заборонено будувати без дозволу...", "severity": "MEDIUM" }
* ]
* }
* ]
* }
*/
public class RulesData {
/**
* Рівень тяжкості порушення. Визначає колір символу-значка в книзі правил
* через ванільні коди форматування (§) — книга рендериться повністю клієнтом,
* без жодного мода на клієнті.
* LIGHT -> зелений (§a)
* MEDIUM -> жовтий (§e)
* HEAVY -> червоний (§c)
*/
public enum Severity {
LIGHT("§a"),
MEDIUM("§e"),
HEAVY("§c");
public final String colorCode;
Severity(String colorCode) {
this.colorCode = colorCode;
}
public static Severity fromString(String raw) {
if (raw == null) return LIGHT;
try {
return Severity.valueOf(raw.trim().toUpperCase());
} catch (IllegalArgumentException e) {
return LIGHT;
}
}
}
/** Один пункт правила всередині розділу. */
public static class Entry {
public String number;
public String text;
public Severity severity;
public Entry(String number, String text, Severity severity) {
this.number = number;
this.text = text;
this.severity = severity;
}
}
/** Розділ правил (вкладка в GUI), напр. "Правила будівництва". */
public static class Section {
public String title;
public List<Entry> entries;
public Section(String title, List<Entry> entries) {
this.title = title;
this.entries = entries;
}
}
/** Корінь конфігу — список усіх розділів. */
public static class RulesRoot {
public List<Section> sections;
public RulesRoot(List<Section> sections) {
this.sections = sections;
}
}
/** Повний набір правил за замовчуванням для створення конфігу при першому запуску. */
public static RulesRoot createDefaultRules() {
List<Section> sections = new ArrayList<>();
// 1. ЧЕСНА ГРА
sections.add(new Section("1. ЧЕСНА ГРА", List.of(
new Entry("1.1", "Чити та заборонені модифікації. Заборонено використовувати чити, модифікації, ресурспаки або інші засоби, які дають перевагу.", Severity.HEAVY),
new Entry("1.2", "Використання багів. Заборонено навмисно використовувати помилки та баги сервера для отримання переваги.", Severity.HEAVY),
new Entry("1.3", "Дюпи піску та подібні механіки дозволені. Заборонено дюпи, що ламають економіку або стабільність сервера.", Severity.HEAVY),
new Entry("1.4", "Обман адміністрації. Заборонено навмисно вводити адміністрацію в оману щодо обставин порушення.", Severity.HEAVY),
new Entry("1.5", "Порушення стабільності сервера. Заборонено створювати навантаження, що спричиняють нестабільність.", Severity.HEAVY),
new Entry("1.6", "Реальні гроші. Заборонено купувати/продавати ігрові ресурси за реальні гроші.", Severity.HEAVY),
new Entry("1.7", "Кількість акаунтів. Один гравець — не більше 5 акаунтів.", Severity.HEAVY),
new Entry("1.8", "Продаж або передача акаунта. Заборонено.", Severity.HEAVY),
new Entry("1.9", "Злам акаунтів. Заборонено намагатися отримати доступ до чужих акаунтів.", Severity.HEAVY),
new Entry("1.10", "Підбурювання до порушень. Заборонено закликати інших гравців порушувати правила.", Severity.MEDIUM),
new Entry("1.11", "Шкідлива дезінформація. Заборонено поширювати неправду, що шкодить гравцям/проєкту.", Severity.MEDIUM),
new Entry("1.12", "Персональні дані. Заборонено використовувати/поширювати дані інших без згоди.", Severity.HEAVY),
new Entry("1.13", "Обхід покарання. Заборонено обходити бан/мут через інші акаунти.", Severity.HEAVY),
new Entry("1.14", "Шкідливі дії проти адміністрації. Заборонено навмисно шкодити адмінам/персоналу.", Severity.HEAVY),
new Entry("1.15", "Атаки на сервер та лаунчер. Заборонено втручатися в роботу інфраструктури.", Severity.HEAVY)
)));
// 2. СПІЛКУВАННЯ
sections.add(new Section("2. СПІЛКУВАННЯ", List.of(
new Entry("2.1", "Мова спілкування. Основні: українська, англійська. Російська заборонена в публічному чаті.", Severity.MEDIUM),
new Entry("2.2", "Образи, приниження, провокації. Заборонено ображати гравців, їхніх родичів або адміністрацію. Порнографія заборонена.", Severity.MEDIUM),
new Entry("2.3", "Ненормативна лексика. Заборонено надмірне використання.", Severity.LIGHT),
new Entry("2.4", "Caps Lock. Заборонено надмірне використання, якщо заважає спілкуванню.", Severity.LIGHT),
new Entry("2.5", "Реклама. Заборонено рекламувати сторонні проєкти без дозволу адміністрації (у тому числі в ПМ).", Severity.HEAVY),
new Entry("2.6", "Провокації та конфлікти. Заборонено навмисно розпалювати конфлікти.", Severity.HEAVY),
new Entry("2.7", "Публічні звинувачення адміністрації. Заборонено безпідставно дискредитувати модерацію. Критику — через офіційні канали.", Severity.LIGHT)
)));
// 3. ТЕРИТОРІЇ ТА БУДІВНИЦТВО
sections.add(new Section("3. ТЕРИТОРІЇ ТА БУДІВНИЦТВО", List.of(
new Entry("3.1", "Територія гравця. Гравець розпоряджається своєю територією. Втручання без дозволу — порушення.", Severity.MEDIUM),
new Entry("3.2", "Гріферство. Заборонено руйнувати будівлі, псувати територію, знищувати майно без дозволу.", Severity.HEAVY),
new Entry("3.3", "Неприйнятні споруди. Заборонено: образливий/провокаційний/порнографічний/політичний/нацистський/наркотичний контент.", Severity.MEDIUM),
new Entry("3.4", "Псування світу. Заборонено створювати безлад: хаотичне знищення ландшафту, залишення занедбаних будівель, розливання рідин, безглузді стовпи.", Severity.MEDIUM),
new Entry("3.5", "Контейнери з чужими ресурсами. Заборонено знищувати скрині/шалкери з чужими речами без причини.", Severity.MEDIUM),
new Entry("3.6", "Назви предметів. Не повинні містити заборонений контент.", Severity.LIGHT)
)));
// 4. НІКНЕЙМИ, СКІНИ ТА ПРОФІЛІ
sections.add(new Section("4. НІКНЕЙМИ, СКІНИ ТА ПРОФІЛІ", List.of(
new Entry("4.1", "Заборонені нікнейми: образи, провокації, порнографія, реклама, наркотики, неприйнятний контент.", Severity.HEAVY),
new Entry("4.2", "Заборонені префікси: образливий, провокаційний, порнографічний, рекламний контент.", Severity.MEDIUM),
new Entry("4.3", "Скіни та плащі. Заборонено порнографічний/неприйнятний контент.", Severity.MEDIUM),
new Entry("4.4", "Імітація адміністрації. Заборонено виглядати як адмін/команда проєкту або відомі медіа-особи.", Severity.HEAVY),
new Entry("4.5", "Імітація іншого гравця. Заборонено створювати схожі ніки з метою обману.", Severity.HEAVY),
new Entry("4.6", "Вхід під чужим акаунтом. Заборонено.", Severity.HEAVY)
)));
// 5. PvP ТА ВЗАЄМОДІЯ
sections.add(new Section("5. PvP ТА ВЗАЄМОДІЯ", List.of(
new Entry("5.1", "Перебування на чужій території. Піти після другого прохання власника. Виняток — обов'язки персоналу.", Severity.LIGHT)
)));
// 6. ТЕХНІЧНІ ТА ІНШІ ПОРУШЕННЯ
sections.add(new Section("6. ТЕХНІЧНІ ТА ІНШІ ПОРУШЕННЯ", List.of(
new Entry("6.1", "Втручання в роботу проєкту. Заборонено втручатися в сервер/лаунчер для переваги/обходу захисту.", Severity.HEAVY),
new Entry("6.2", "Обхід систем захисту. Заборонено обходити авторизацію/захист через сторонні клієнти.", Severity.HEAVY),
new Entry("6.3", "Порушення, не передбачені правилами. Адміністрація може тимчасово обмежити доступ для з'ясування.", Severity.MEDIUM)
)));
// 7. ІВЕНТИ
sections.add(new Section("7. ІВЕНТИ", List.of(
new Entry("7.1", "Правила івентів. Необхідно дотримуватися окремих правил івенту. Порушення карається за умовами івенту.", Severity.LIGHT)
)));
return new RulesRoot(sections);
}
}
@@ -0,0 +1,76 @@
package org.examp.john.rules;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import org.examp.john.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());
}
}