feat: add full server rules to /rules book

This commit is contained in:
2026-10-05 19:41:01 +02:00
parent 2175fb1a28
commit 06c9ec4976
7 changed files with 240 additions and 26 deletions
Binary file not shown.
@@ -0,0 +1,49 @@
package org.examp.john.mixin.client;
import net.minecraft.client.gui.Font;
import net.minecraft.client.gui.GuiGraphicsExtractor;
import net.minecraft.client.gui.components.PlayerTabOverlay;
import net.minecraft.client.multiplayer.PlayerInfo;
import net.minecraft.network.chat.Component;
import net.minecraft.world.level.GameType;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.Redirect;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
/**
* Прибирає курсив та сірий колір для spectator у таб-листі.
*/
@Mixin(PlayerTabOverlay.class)
public class PlayerTabOverlayMixin {
/**
* Прибирає курсив з назви spectator в табі.
*/
@Inject(method = "getNameForDisplay", at = @At("RETURN"), cancellable = true)
private void removeSpectatorItalic(PlayerInfo playerInfo, CallbackInfoReturnable<Component> cir) {
if (playerInfo.getGameMode() == GameType.SPECTATOR) {
Component original = cir.getReturnValue();
cir.setReturnValue(original.copy().withStyle(original.getStyle().withItalic(false)));
}
}
/**
* Перехоплюємо виклик text(Font, Component, x, y, color) в extractRenderState.
* Якщо колір = -1862270977 (сірий для spectator), замінюємо на -1 (білий).
*/
@Redirect(
method = "extractRenderState",
at = @At(
value = "INVOKE",
target = "Lnet/minecraft/client/gui/GuiGraphicsExtractor;text(Lnet/minecraft/client/gui/Font;Lnet/minecraft/network/chat/Component;III)V"
)
)
private void forceWhiteText(GuiGraphicsExtractor instance, Font font, Component text, int x, int y, int color) {
if (color == -1862270977) {
color = -1; // білий
}
instance.text(font, text, x, y, color);
}
}
@@ -4,6 +4,7 @@
"package": "org.examp.john.mixin.client",
"compatibilityLevel": "JAVA_25",
"client": [
"PlayerTabOverlayMixin"
],
"injectors": {
"defaultRequire": 1
@@ -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();
}
}
@@ -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<Filterable<Component>> 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<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;
}
}
@@ -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<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);
}
}
@@ -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());
}
}