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 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 64bd3141dc feat: highlight player names in chat with LuckPerms colors 2026-08-16 19:16:25 +02:00
john 2edff88cac feat: add stealth mode and anti-abuse chat detectionD 2026-08-14 16:40:24 +02:00
ProstoMaslo 080a838028 REFACTOR: Change modid, modname e.t.c 2026-08-11 20:44:28 +03:00
23 changed files with 405 additions and 233 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
}
+2 -2
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=localhost
archives_base_name=msnexus
fabric_version=0.155.2+26.1.2
Binary file not shown.
-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
}
}
@@ -0,0 +1,10 @@
package org.examp.john.client;
import net.fabricmc.api.ClientModInitializer;
public class MSNexusClient implements ClientModInitializer {
@Override
public void onInitializeClient() {
}
}
@@ -3,7 +3,7 @@ package org.examp.john.client;
import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint;
import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator;
public class LocalchatDataGenerator implements DataGeneratorEntrypoint {
public class MSNexusDataGenerator implements DataGeneratorEntrypoint {
@Override
public void onInitializeDataGenerator(FabricDataGenerator fabricDataGenerator) {
@@ -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);
}
}
@@ -1,15 +0,0 @@
{
"required": true,
"minVersion": "0.8",
"package": "org.examp.john.mixin.client",
"compatibilityLevel": "JAVA_25",
"client": [
"PlayerTabOverlayMixin"
],
"injectors": {
"defaultRequire": 1
},
"overwrites": {
"requireAnnotations": true
}
}
+25 -3
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;
@@ -11,16 +12,30 @@ import java.util.UUID;
/**
* Детектить патерн пошуку гравців через локальний чат.
*
* Якщо одне і те ж повідомлення повторюється >=5 разів за 5 хвилин
* і гравець перемістився >50 блоків — логує в консоль.
* Алгоритм:
* 1. Записує кожне локальне повідомлення (текст + координати + час)
* 2. Якщо одне і те ж повідомлення повторюється >=5 разів за 5 хвилин
* І гравець перемістився більше ніж на 50 блоків —
* це очевидний пошук гравців.
* 3. Логує в термінал для адміна.
*/
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();
@@ -38,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<>();
@@ -61,7 +77,13 @@ public class AntiAbuse {
double spread = Math.max(b[1] - b[0], b[3] - b[2]);
if (spread > DISTANCE_THRESHOLD) {
Localchat.magicModLog.warn(
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] Гравець {} ймовірно шукає інших: "
+ "'{}' ×{} разів, переміщення ~{} блоків",
player.getName().getString(),
+175 -14
View File
@@ -15,15 +15,31 @@ import net.minecraft.server.level.ServerPlayer;
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();
@@ -31,9 +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(": " + actualMessage));
.append(Component.literal(": "))
.append(buildFormattedMessage(actualMessage, players));
for (ServerPlayer player : players) {
player.sendSystemMessage(globalMessage);
@@ -65,7 +85,8 @@ public class ChatHandler {
if (!hearers.isEmpty()) {
Component localMessage = Component.empty()
.append(formattedName)
.append(Component.literal(": " + text).withStyle(ChatFormatting.GRAY));
.append(Component.literal(": ").withStyle(ChatFormatting.GRAY))
.append(buildFormattedMessage(text, players).withStyle(ChatFormatting.GRAY));
sender.sendSystemMessage(localMessage);
for (ServerPlayer hearer : hearers) {
@@ -84,21 +105,146 @@ public class ChatHandler {
}
}
return false;
});
}
private static MutableComponent buildFormattedName(ServerPlayer player) {
String luckPermsPrefix = getLuckPermsPrefix(player);
String playerName = player.getName().getString();
String formattedPrefix = luckPermsPrefix.replace("&", "\u00a7");
/**
* Парсить текст між ** і робить його курсивом, поєднуючи з підсвіткою згадок гравців.
* Приклад: "привіт **світе** як справи?" -> "привіт [курсив]світе[/курсив] як справи?"
*/
private static MutableComponent buildFormattedMessage(String text, List<ServerPlayer> players) {
MutableComponent result = Component.empty();
StringBuilder buffer = new StringBuilder();
boolean italic = false;
Style clickStyle = Style.EMPTY
.withClickEvent(new ClickEvent.SuggestCommand("/msg " + playerName + " "))
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) {
List<ServerPlayer> sorted = onlinePlayers.stream()
.sorted(Comparator.comparingInt((ServerPlayer p) -> p.getName().getString().length()).reversed())
.toList();
MutableComponent result = Component.empty();
String remaining = text;
while (!remaining.isEmpty()) {
int earliestIndex = remaining.length();
ServerPlayer mentioned = null;
String lower = remaining.toLowerCase(Locale.ROOT);
for (ServerPlayer player : sorted) {
String nameLower = player.getName().getString().toLowerCase(Locale.ROOT);
int index = indexOfBoundary(lower, nameLower);
if (index >= 0 && index < earliestIndex) {
earliestIndex = index;
mentioned = player;
}
}
if (mentioned == null) {
result.append(Component.literal(remaining));
break;
}
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, withAt));
remaining = remaining.substring(earliestIndex + mentioned.getName().getString().length());
}
return result;
}
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;
}
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("Натисніть щоб написати " + playerName)
.withStyle(ChatFormatting.GRAY)));
Component.literal("Натисніть щоб написати " + name)
.withStyle(ChatFormatting.GRAY)))
);
}
return Component.literal(formattedPrefix + playerName).withStyle(clickStyle);
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();
}
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) {
@@ -115,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));
}
}
+9 -4
View File
@@ -13,7 +13,8 @@ import java.util.stream.Collectors;
public class ChatLogger {
public static void logGlobal(ServerPlayer sender, String message, List<ServerPlayer> recipients) {
Localchat.magicModLog.info(
// Для глобального чату ігноруємо список отримувачів і просто пишемо "everyone"
MSNexus.magicModLog.info(
"[CHAT][GLOBAL] {} said: \"{}\" | heard by: everyone | pos: {}",
sender.getName().getString(),
message,
@@ -22,13 +23,14 @@ public class ChatLogger {
}
public static void logLocal(ServerPlayer sender, String message, List<ServerPlayer> recipients) {
// Для локального чату виводимо лише імена отримувачів, без їхніх координат
String heardBy = recipients.isEmpty()
? "nobody"
: recipients.stream()
.map(p -> p.getName().getString())
.collect(Collectors.joining(", "));
Localchat.magicModLog.info(
MSNexus.magicModLog.info(
"[CHAT][LOCAL] {} said: \"{}\" | heard by: {} | pos: {}",
sender.getName().getString(),
message,
@@ -38,7 +40,7 @@ public class ChatLogger {
}
public static void logUnheard(ServerPlayer sender, String message) {
Localchat.magicModLog.info(
MSNexus.magicModLog.info(
"[CHAT][LOCAL][UNHEARD] {} said: \"{}\" | heard by: nobody | pos: {}",
sender.getName().getString(),
message,
@@ -46,8 +48,11 @@ public class ChatLogger {
);
}
private static String formatPos(ServerPlayer player) {
static String formatPos(ServerPlayer player) {
ServerLevel level = player.level();
// У ResourceKey дістаємо Identifier/ResourceLocation через .identifier(),
// після чого через .getPath() беремо чисту назву ("overworld", "the_nether", "the_end")
String rawDimension = level.dimension().identifier().getPath();
return String.format(
@@ -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)
);
}
}
@@ -7,13 +7,14 @@ import org.examp.john.stealth.StealthStorage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class Localchat implements ModInitializer {
public static final String MOD_ID = "localhost";
public class MSNexus implements ModInitializer {
public static final String MOD_ID = "msnexus";
public static final Logger magicModLog = LoggerFactory.getLogger(MOD_ID);
@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);
}
}
}
@@ -13,7 +13,7 @@ import net.minecraft.world.InteractionHand;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.item.component.WrittenBookContent;
import org.examp.john.Localchat;
import org.examp.john.MSNexus;
import java.util.ArrayList;
import java.util.HashMap;
@@ -77,7 +77,7 @@ public class RulesCommand {
ItemStack originalItem = player.getItemInHand(hand).copy();
ItemStack book = createRuleBook(rules);
Localchat.magicModLog.info("Відкриваємо книгу правил для {}", player.getName().getString());
MSNexus.magicModLog.info("Відкриваємо книгу правил для {}", player.getName().getString());
player.setItemInHand(hand, book);
player.containerMenu.sendAllDataToRemote();
@@ -3,7 +3,7 @@ package org.examp.john.rules;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import org.examp.john.Localchat;
import org.examp.john.MSNexus;
import java.io.IOException;
import java.io.Reader;
@@ -48,16 +48,16 @@ public class RulesLoader {
try (Reader reader = Files.newBufferedReader(CONFIG_PATH, StandardCharsets.UTF_8)) {
RulesData.RulesRoot root = GSON.fromJson(reader, RulesData.RulesRoot.class);
if (root == null || root.sections == null) {
Localchat.magicModLog.warn("rules.json порожній або невалідний, використовую пустий список правил");
MSNexus.magicModLog.warn("rules.json порожній або невалідний, використовую пустий список правил");
return new RulesData.RulesRoot(Collections.emptyList());
}
return root;
}
} catch (IOException e) {
Localchat.magicModLog.error("Не вдалося прочитати rules.json: {}", e.getMessage());
MSNexus.magicModLog.error("Не вдалося прочитати rules.json: {}", e.getMessage());
return new RulesData.RulesRoot(Collections.emptyList());
} catch (JsonSyntaxException e) {
Localchat.magicModLog.error("rules.json містить синтаксичну помилку JSON: {}", e.getMessage());
MSNexus.magicModLog.error("rules.json містить синтаксичну помилку JSON: {}", e.getMessage());
return new RulesData.RulesRoot(Collections.emptyList());
}
}
@@ -71,6 +71,6 @@ public class RulesLoader {
new GsonBuilder().setPrettyPrinting().create().toJson(defaultRoot, writer);
}
Localchat.magicModLog.info("Створено конфіг правил за замовчуванням: {}", CONFIG_PATH.toAbsolutePath());
MSNexus.magicModLog.info("Створено конфіг правил за замовчуванням: {}", CONFIG_PATH.toAbsolutePath());
}
}
@@ -17,6 +17,10 @@ import net.minecraft.server.level.ServerPlayer;
* /stealth on — увімкнути stealth
* /stealth off — вимкнути stealth
* /stealth info — показати інформацію про команду
*
* Коли stealth увімкнено, повідомлення локального чату від гравця
* не доходять до інших — гравець бачить "Вас ніхто не почув..."
* Глобальний чат (! префікс) працює як зазвичай.
*/
public class StealthCommand {
@@ -37,6 +41,7 @@ public class StealthCommand {
private static int executeToggle(CommandContext<CommandSourceStack> context) {
ServerPlayer player = getPlayer(context);
if (player == null) return 0;
boolean newState = StealthStorage.toggle(player.getUUID());
sendStatus(player, newState);
return 1;
@@ -45,6 +50,7 @@ public class StealthCommand {
private static int executeOn(CommandContext<CommandSourceStack> context) {
ServerPlayer player = getPlayer(context);
if (player == null) return 0;
StealthStorage.set(player.getUUID(), true);
sendStatus(player, true);
return 1;
@@ -53,6 +59,7 @@ public class StealthCommand {
private static int executeOff(CommandContext<CommandSourceStack> context) {
ServerPlayer player = getPlayer(context);
if (player == null) return 0;
StealthStorage.set(player.getUUID(), false);
sendStatus(player, false);
return 1;
@@ -2,8 +2,8 @@ package org.examp.john.stealth;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonSyntaxException;
import org.examp.john.Localchat;
import net.fabricmc.loader.api.FabricLoader;
import org.examp.john.MSNexus;
import java.io.IOException;
import java.io.Reader;
@@ -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;
@@ -18,11 +19,14 @@ import java.util.UUID;
/**
* Зберігає стан stealth-режиму для кожного гравця.
* Файл: config/localchat/stealth.json — {"uuid1": true, "uuid2": false, ...}
* Зміни зберігаються на диск при кожному toggle.
*/
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<>();
@@ -34,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) {
Localchat.magicModLog.warn("Не вдалося прочитати stealth.json: {}", e.getMessage());
}
} 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) {
@@ -69,15 +83,22 @@ 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) {
Localchat.magicModLog.error("Не вдалося зберегти stealth.json: {}", e.getMessage());
Files.move(tmp, CONFIG_PATH, StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
MSNexus.magicModLog.error("Не вдалося зберегти stealth.json: {}", e.getMessage());
}
}
}
+6 -11
View File
@@ -1,28 +1,23 @@
{
"schemaVersion": 1,
"id": "localchat",
"id": "msnexus",
"version": "${version}",
"name": "localchat",
"name": "msnexus",
"description": "",
"authors": [],
"contact": {},
"license": "All-Rights-Reserved",
"icon": "assets/localchat/icon.png",
"environment": "*",
"environment": "server",
"entrypoints": {
"fabric-datagen": [
"org.examp.john.client.LocalchatDataGenerator"
"org.examp.john.client.MSNexusDataGenerator"
],
"main": [
"org.examp.john.Localchat"
"org.examp.john.MSNexus"
]
},
"mixins": [
"localchat.mixins.json",
{
"config": "localchat.client.mixins.json",
"environment": "client"
}
"msnexus.mixins.json"
],
"depends": {
"fabricloader": ">=${loader_version}",
@@ -4,6 +4,7 @@
"package": "org.examp.john.mixin",
"compatibilityLevel": "JAVA_25",
"mixins": [
"CommandLogMixin"
],
"injectors": {
"defaultRequire": 1