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 ae102817b6 fix: revert spectator tab highlight mixin to restore vanilla spectator gameplay 2026-10-04 19:28:30 +02:00
john f0b5d603ad fix: give chat mentions default highlight color and support @tags 2026-10-04 11:44:27 +02:00
john dd12fe4c00 fix: prevent chat handler errors and corrupt stealth.json crashes 2026-10-04 11:24:26 +02:00
john 9324965322 feat: add spectator tab highlight and command loggin 2026-10-04 11:04:17 +02:00
john 3c72b6e5ea fix: fixed malformed fabric.mod.json causing server crash 2026-10-03 21:00:06 +02:00
john dc4da670ee fix: repair chat code and add **text** italic formatting to chat 2026-10-03 19:23:21 +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
20 changed files with 753 additions and 216 deletions
+7 -4
View File
@@ -1,4 +1,7 @@
build/**
run/**
.idea/**
.gradle/**
build/
run/
out/
/org/
*.class
.idea/
.gradle/
+1 -1
View File
@@ -14,7 +14,7 @@ loom {
splitEnvironmentSourceSets()
mods {
"localchat" {
"msnexus" {
sourceSet sourceSets.main
sourceSet sourceSets.client
}
+1 -1
View File
@@ -2,7 +2,7 @@ org.gradle.jvmargs=-Xmx2G
minecraft_version=26.1.2
yarn_mappings=1.21.11+build.6
loader_version=0.19.2
mod_version=1.0
mod_version=1.1
maven_group=org.example1
archives_base_name=msnexus
fabric_version=0.155.2+26.1.2
@@ -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
}
}
@@ -1,49 +0,0 @@
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,7 +4,6 @@
"package": "org.examp.john.mixin.client",
"compatibilityLevel": "JAVA_25",
"client": [
"PlayerTabOverlayMixin"
],
"injectors": {
"defaultRequire": 1
+18 -1
View File
@@ -1,5 +1,6 @@
package org.examp.john;
import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents;
import net.minecraft.server.level.ServerPlayer;
import java.util.ArrayList;
@@ -20,12 +21,21 @@ import java.util.UUID;
*/
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 long ALERT_COOLDOWN_MS = 30 * 1000;
private static final Map<UUID, List<MessageRecord>> history = new HashMap<>();
private static final Map<UUID, Long> lastAlertTime = new HashMap<>();
public static void register() {
ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> {
UUID uuid = handler.player.getUUID();
history.remove(uuid);
lastAlertTime.remove(uuid);
});
}
public static void recordMessage(ServerPlayer player, String message) {
UUID uuid = player.getUUID();
@@ -43,6 +53,7 @@ public class AntiAbuse {
}
private static void checkForAbuse(ServerPlayer player, List<MessageRecord> records) {
UUID uuid = player.getUUID();
Map<String, int[]> counts = new HashMap<>();
Map<String, double[]> bounds = new HashMap<>();
@@ -66,6 +77,12 @@ public class AntiAbuse {
double spread = Math.max(b[1] - b[0], b[3] - b[2]);
if (spread > DISTANCE_THRESHOLD) {
long now = System.currentTimeMillis();
Long lastAlert = lastAlertTime.get(uuid);
if (lastAlert != null && now - lastAlert < ALERT_COOLDOWN_MS) {
continue;
}
lastAlertTime.put(uuid, now);
MSNexus.magicModLog.warn(
"[ANTI-ABUSE] Гравець {} ймовірно шукає інших: "
+ "'{}' ×{} разів, переміщення ~{} блоків",
+132 -26
View File
@@ -17,14 +17,29 @@ import org.examp.john.stealth.StealthStorage;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
public class ChatHandler {
public static void register() {
ServerMessageEvents.ALLOW_CHAT_MESSAGE.register((message, sender, params) -> {
try {
return handleMessage(message.signedContent(), sender);
} catch (Exception e) {
MSNexus.magicModLog.error(
"[CHAT] Не вдалося обробити повідомлення від {}", sender.getName().getString(), e);
return false;
}
});
}
private static boolean handleMessage(String text, ServerPlayer sender) {
final String prefix = "!";
final float LOCAL_CHAT_RADIUS = 100;
ServerMessageEvents.ALLOW_CHAT_MESSAGE.register((message, sender, params) -> {
String text = message.signedContent();
if (text.isBlank()) {
return false;
}
ServerLevel serverLevel = sender.level();
List<ServerPlayer> players = serverLevel.getServer().getPlayerList().getPlayers();
@@ -32,10 +47,13 @@ public class ChatHandler {
if (text.startsWith(prefix)) {
String actualMessage = text.substring(1).trim();
if (actualMessage.isEmpty()) {
return false;
}
Component globalMessage = Component.empty()
.append(formattedName)
.append(Component.literal(": "))
.append(buildMentionMessage(actualMessage, players));
.append(buildFormattedMessage(actualMessage, players));
for (ServerPlayer player : players) {
player.sendSystemMessage(globalMessage);
@@ -68,7 +86,7 @@ public class ChatHandler {
Component localMessage = Component.empty()
.append(formattedName)
.append(Component.literal(": ").withStyle(ChatFormatting.GRAY))
.append(buildMentionMessage(text, players).withStyle(ChatFormatting.GRAY));
.append(buildFormattedMessage(text, players).withStyle(ChatFormatting.GRAY));
sender.sendSystemMessage(localMessage);
for (ServerPlayer hearer : hearers) {
@@ -87,7 +105,38 @@ public class ChatHandler {
}
}
return false;
});
}
/**
* Парсить текст між ** і робить його курсивом, поєднуючи з підсвіткою згадок гравців.
* Приклад: "привіт **світе** як справи?" -> "привіт [курсив]світе[/курсив] як справи?"
*/
private static MutableComponent buildFormattedMessage(String text, List<ServerPlayer> players) {
MutableComponent result = Component.empty();
StringBuilder buffer = new StringBuilder();
boolean italic = false;
for (int i = 0; i < text.length(); i++) {
if (i + 1 < text.length() && text.charAt(i) == '*' && text.charAt(i + 1) == '*') {
appendFormattedSegment(result, buffer.toString(), players, italic);
buffer.setLength(0);
italic = !italic;
i++;
} else {
buffer.append(text.charAt(i));
}
}
appendFormattedSegment(result, buffer.toString(), players, italic);
return result;
}
private static void appendFormattedSegment(MutableComponent result, String segment, List<ServerPlayer> players, boolean italic) {
MutableComponent parsed = buildMentionMessage(segment, players);
if (italic) {
parsed.withStyle(Style.EMPTY.withItalic(true));
}
result.append(parsed);
}
private static MutableComponent buildMentionMessage(String text, List<ServerPlayer> onlinePlayers) {
@@ -101,10 +150,11 @@ public class ChatHandler {
while (!remaining.isEmpty()) {
int earliestIndex = remaining.length();
ServerPlayer mentioned = null;
String lower = remaining.toLowerCase(Locale.ROOT);
for (ServerPlayer player : sorted) {
String name = player.getName().getString();
int index = remaining.toLowerCase().indexOf(name.toLowerCase());
String nameLower = player.getName().getString().toLowerCase(Locale.ROOT);
int index = indexOfBoundary(lower, nameLower);
if (index >= 0 && index < earliestIndex) {
earliestIndex = index;
mentioned = player;
@@ -116,11 +166,13 @@ public class ChatHandler {
break;
}
if (earliestIndex > 0) {
result.append(Component.literal(remaining.substring(0, earliestIndex)));
boolean withAt = earliestIndex > 0 && remaining.charAt(earliestIndex - 1) == '@';
int prefixEnd = withAt ? earliestIndex - 1 : earliestIndex;
if (prefixEnd > 0) {
result.append(Component.literal(remaining.substring(0, prefixEnd)));
}
result.append(createMentionComponent(mentioned));
result.append(createMentionComponent(mentioned, withAt));
remaining = remaining.substring(earliestIndex + mentioned.getName().getString().length());
}
@@ -128,13 +180,32 @@ public class ChatHandler {
return result;
}
private static Component createMentionComponent(ServerPlayer player) {
String lpPrefix = getLuckPermsPrefix(player);
String formattedPrefix = lpPrefix.replace("&", "\u00a7");
String name = player.getName().getString();
private static int indexOfBoundary(String lowerHaystack, String lowerNeedle) {
int index = lowerHaystack.indexOf(lowerNeedle);
while (index >= 0) {
boolean okBefore = index == 0 || !isNameChar(lowerHaystack.charAt(index - 1));
boolean okAfter = index + lowerNeedle.length() == lowerHaystack.length()
|| !isNameChar(lowerHaystack.charAt(index + lowerNeedle.length()));
if (okBefore && okAfter) {
return index;
}
index = lowerHaystack.indexOf(lowerNeedle, index + 1);
}
return -1;
}
return Component.literal(formattedPrefix + name)
private static boolean isNameChar(char c) {
return Character.isLetterOrDigit(c) || c == '_';
}
private static Component createMentionComponent(ServerPlayer player, boolean withAt) {
String name = player.getName().getString();
String prefix = formatLegacyPrefix(getLuckPermsPrefix(player));
String text = (withAt ? "@" : "") + prefix + name;
return Component.literal(text)
.withStyle(Style.EMPTY
.withColor(ChatFormatting.AQUA)
.withClickEvent(new ClickEvent.SuggestCommand("/msg " + name + " "))
.withHoverEvent(new HoverEvent.ShowText(
Component.literal("Натисніть щоб написати " + name)
@@ -142,18 +213,38 @@ public class ChatHandler {
);
}
private static MutableComponent buildFormattedName(ServerPlayer player) {
String lpPrefix = getLuckPermsPrefix(player);
String playerName = player.getName().getString();
String formattedPrefix = lpPrefix.replace("&", "\u00a7");
private static String formatLegacyPrefix(String raw) {
if (raw == null || raw.isEmpty()) {
return "";
}
String s = raw.replace('&', '§');
StringBuilder out = new StringBuilder(s.length() + 14);
int i = 0;
while (i < s.length()) {
if (s.charAt(i) == '§' && i + 8 <= s.length() && s.charAt(i + 1) == '#'
&& isSixHex(s, i + 2)) {
out.append("§x");
for (int j = 2; j < 8; j++) {
out.append('§').append(s.charAt(i + j));
}
i += 8;
} else {
out.append(s.charAt(i));
i++;
}
}
return out.toString();
}
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 boolean isSixHex(String s, int start) {
for (int i = start; i < start + 6; i++) {
char c = s.charAt(i);
boolean hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F');
if (!hex) {
return false;
}
}
return true;
}
private static String getLuckPermsPrefix(ServerPlayer player) {
@@ -170,4 +261,19 @@ public class ChatHandler {
}
return "";
}
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().copy().withStyle(clickStyle));
}
}
+1 -1
View File
@@ -48,7 +48,7 @@ public class ChatLogger {
);
}
private static String formatPos(ServerPlayer player) {
static String formatPos(ServerPlayer player) {
ServerLevel level = player.level();
// У ResourceKey дістаємо Identifier/ResourceLocation через .identifier(),
@@ -0,0 +1,21 @@
package org.examp.john;
import net.minecraft.server.level.ServerPlayer;
/**
* Логує команди, які виконують гравці, в консоль сервера.
* Формат: хто виконав ➔ яка команда ➔ координати гравця.
*/
public class CommandLogger {
public static void logCommand(ServerPlayer player, String command) {
String normalized = command.startsWith("/") ? command : "/" + command;
MSNexus.magicModLog.info(
"[CMD] {} ran: \"{}\" | pos: {}",
player.getName().getString(),
normalized,
ChatLogger.formatPos(player)
);
}
}
@@ -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;
@@ -13,7 +14,9 @@ public class MSNexus implements ModInitializer {
@Override
public void onInitialize() {
StealthStorage.load();
AntiAbuse.register();
ChatHandler.register();
StealthCommand.register();
RulesCommand.register();
}
}
@@ -0,0 +1,38 @@
package org.examp.john.mixin;
import com.mojang.brigadier.ParseResults;
import net.minecraft.commands.CommandSourceStack;
import net.minecraft.commands.Commands;
import net.minecraft.server.level.ServerPlayer;
import org.examp.john.CommandLogger;
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.callback.CallbackInfo;
/**
* Логує виконання команд гравцями в консоль сервера.
* Хукає Commands.performCommand — спільну точку виконання всіх команд.
*/
@Mixin(Commands.class)
public class CommandLogMixin {
@Inject(
method = "performCommand(Lcom/mojang/brigadier/ParseResults;Ljava/lang/String;)V",
at = @At("HEAD")
)
private void msnexus$logExecutedCommand(ParseResults<CommandSourceStack> parseResults, String command, CallbackInfo ci) {
try {
CommandSourceStack source = parseResults.getContext().getSource();
if (source == null) {
return;
}
ServerPlayer player = source.getPlayer();
if (player != null) {
CommandLogger.logCommand(player, command);
}
} catch (Exception e) {
org.examp.john.MSNexus.magicModLog.warn("[CMD] Не вдалося залогувати команду", e);
}
}
}
@@ -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());
}
}
@@ -2,7 +2,7 @@ package org.examp.john.stealth;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import net.fabricmc.loader.api.FabricLoader;
import org.examp.john.MSNexus;
import java.io.IOException;
@@ -11,6 +11,7 @@ import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@@ -23,7 +24,9 @@ import java.util.UUID;
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 final Path CONFIG_PATH = FabricLoader.getInstance().getConfigDir()
.resolve("localchat")
.resolve("stealth.json");
private static Map<UUID, Boolean> data = new HashMap<>();
@@ -35,23 +38,33 @@ public class StealthStorage {
return;
}
try (Reader reader = Files.newBufferedReader(CONFIG_PATH, StandardCharsets.UTF_8)) {
@SuppressWarnings("unchecked")
Map<String, Boolean> raw = GSON.fromJson(reader, Map.class);
Map<String, Object> raw = GSON.fromJson(reader, Map.class);
data = new HashMap<>();
if (raw != null) {
for (Map.Entry<String, Boolean> entry : raw.entrySet()) {
data.put(UUID.fromString(entry.getKey()), entry.getValue());
for (Map.Entry<String, Object> entry : raw.entrySet()) {
try {
UUID uuid = UUID.fromString(entry.getKey());
if (entry.getValue() instanceof Boolean value) {
data.put(uuid, value);
} else {
MSNexus.magicModLog.warn(
"stealth.json: пропущено некоректне значення для {}", entry.getKey());
}
} catch (IllegalArgumentException e) {
MSNexus.magicModLog.warn(
"stealth.json: пропущено некоректний ключ {}", entry.getKey());
}
}
}
} catch (IOException | JsonSyntaxException | IllegalArgumentException e) {
}
} catch (IOException | RuntimeException e) {
MSNexus.magicModLog.warn("Не вдалося прочитати stealth.json: {}", e.getMessage());
data = new HashMap<>();
}
}
public static boolean isEnabled(UUID uuid) {
return data.getOrDefault(uuid, false);
return Boolean.TRUE.equals(data.get(uuid));
}
public static boolean toggle(UUID uuid) {
@@ -70,13 +83,20 @@ public class StealthStorage {
private static void save() {
try {
Files.createDirectories(CONFIG_PATH.getParent());
try (Writer writer = Files.newBufferedWriter(CONFIG_PATH, StandardCharsets.UTF_8)) {
Path tmp = CONFIG_PATH.resolveSibling(CONFIG_PATH.getFileName() + ".tmp");
try (Writer writer = Files.newBufferedWriter(tmp, StandardCharsets.UTF_8)) {
Map<String, Boolean> raw = new HashMap<>();
for (Map.Entry<UUID, Boolean> entry : data.entrySet()) {
raw.put(entry.getKey().toString(), entry.getValue());
}
GSON.toJson(raw, writer);
}
try {
Files.move(tmp, CONFIG_PATH,
StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) {
Files.move(tmp, CONFIG_PATH, StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
MSNexus.magicModLog.error("Не вдалося зберегти stealth.json: {}", e.getMessage());
}
+2 -10
View File
@@ -7,25 +7,17 @@
"authors": [],
"contact": {},
"license": "All-Rights-Reserved",
"icon": "assets/msnexus/icon.png",
"environment": "*",
"environment": "server",
"entrypoints": {
"fabric-datagen": [
"org.examp.john.client.MSNexusDataGenerator"
],
"client": [
"org.examp.john.client.MSNexusClient"
],
"main": [
"org.examp.john.MSNexus"
]
},
"mixins": [
"msnexus.mixins.json",
{
"config": "msnexus.client.mixins.json",
"environment": "client"
}
"msnexus.mixins.json"
],
"depends": {
"fabricloader": ">=${loader_version}",
+1
View File
@@ -4,6 +4,7 @@
"package": "org.examp.john.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"CommandLogMixin"
],
"injectors": {
"defaultRequire": 1