Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06c9ec4976 | ||
|
|
2175fb1a28 | ||
|
|
3537060ba5 | ||
|
|
a4d2fc5f5e |
+1
-1
@@ -4,5 +4,5 @@ yarn_mappings=1.21.11+build.6
|
|||||||
loader_version=0.19.2
|
loader_version=0.19.2
|
||||||
mod_version=1.0
|
mod_version=1.0
|
||||||
maven_group=org.example1
|
maven_group=org.example1
|
||||||
archives_base_name=msnexus
|
archives_base_name=localhost
|
||||||
fabric_version=0.155.2+26.1.2
|
fabric_version=0.155.2+26.1.2
|
||||||
|
|||||||
Binary file not shown.
+1
-1
@@ -3,7 +3,7 @@ package org.examp.john.client;
|
|||||||
import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint;
|
import net.fabricmc.fabric.api.datagen.v1.DataGeneratorEntrypoint;
|
||||||
import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator;
|
import net.fabricmc.fabric.api.datagen.v1.FabricDataGenerator;
|
||||||
|
|
||||||
public class MSNexusDataGenerator implements DataGeneratorEntrypoint {
|
public class LocalchatDataGenerator implements DataGeneratorEntrypoint {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onInitializeDataGenerator(FabricDataGenerator fabricDataGenerator) {
|
public void onInitializeDataGenerator(FabricDataGenerator fabricDataGenerator) {
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package org.examp.john.client;
|
|
||||||
|
|
||||||
import net.fabricmc.api.ClientModInitializer;
|
|
||||||
|
|
||||||
public class MSNexusClient implements ClientModInitializer {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onInitializeClient() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,16 +11,11 @@ import java.util.UUID;
|
|||||||
/**
|
/**
|
||||||
* Детектить патерн пошуку гравців через локальний чат.
|
* Детектить патерн пошуку гравців через локальний чат.
|
||||||
*
|
*
|
||||||
* Алгоритм:
|
* Якщо одне і те ж повідомлення повторюється >=5 разів за 5 хвилин
|
||||||
* 1. Записує кожне локальне повідомлення (текст + координати + час)
|
* і гравець перемістився >50 блоків — логує в консоль.
|
||||||
* 2. Якщо одне і те ж повідомлення повторюється >=5 разів за 5 хвилин
|
|
||||||
* І гравець перемістився більше ніж на 50 блоків —
|
|
||||||
* це очевидний пошук гравців.
|
|
||||||
* 3. Логує в термінал для адміна.
|
|
||||||
*/
|
*/
|
||||||
public class AntiAbuse {
|
public class AntiAbuse {
|
||||||
|
|
||||||
|
|
||||||
private static final int REPEAT_THRESHOLD = 5;
|
private static final int REPEAT_THRESHOLD = 5;
|
||||||
private static final double DISTANCE_THRESHOLD = 50.0;
|
private static final double DISTANCE_THRESHOLD = 50.0;
|
||||||
private static final long TIME_WINDOW_MS = 5 * 60 * 1000;
|
private static final long TIME_WINDOW_MS = 5 * 60 * 1000;
|
||||||
@@ -66,7 +61,7 @@ public class AntiAbuse {
|
|||||||
double spread = Math.max(b[1] - b[0], b[3] - b[2]);
|
double spread = Math.max(b[1] - b[0], b[3] - b[2]);
|
||||||
|
|
||||||
if (spread > DISTANCE_THRESHOLD) {
|
if (spread > DISTANCE_THRESHOLD) {
|
||||||
MSNexus.magicModLog.warn(
|
Localchat.magicModLog.warn(
|
||||||
"[ANTI-ABUSE] Гравець {} ймовірно шукає інших: "
|
"[ANTI-ABUSE] Гравець {} ймовірно шукає інших: "
|
||||||
+ "'{}' ×{} разів, переміщення ~{} блоків",
|
+ "'{}' ×{} разів, переміщення ~{} блоків",
|
||||||
player.getName().getString(),
|
player.getName().getString(),
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import net.minecraft.server.level.ServerPlayer;
|
|||||||
import org.examp.john.stealth.StealthStorage;
|
import org.examp.john.stealth.StealthStorage;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
public class ChatHandler {
|
public class ChatHandler {
|
||||||
@@ -34,8 +33,7 @@ public class ChatHandler {
|
|||||||
String actualMessage = text.substring(1).trim();
|
String actualMessage = text.substring(1).trim();
|
||||||
Component globalMessage = Component.empty()
|
Component globalMessage = Component.empty()
|
||||||
.append(formattedName)
|
.append(formattedName)
|
||||||
.append(Component.literal(": "))
|
.append(Component.literal(": " + actualMessage));
|
||||||
.append(buildMentionMessage(actualMessage, players));
|
|
||||||
|
|
||||||
for (ServerPlayer player : players) {
|
for (ServerPlayer player : players) {
|
||||||
player.sendSystemMessage(globalMessage);
|
player.sendSystemMessage(globalMessage);
|
||||||
@@ -67,8 +65,7 @@ public class ChatHandler {
|
|||||||
if (!hearers.isEmpty()) {
|
if (!hearers.isEmpty()) {
|
||||||
Component localMessage = Component.empty()
|
Component localMessage = Component.empty()
|
||||||
.append(formattedName)
|
.append(formattedName)
|
||||||
.append(Component.literal(": ").withStyle(ChatFormatting.GRAY))
|
.append(Component.literal(": " + text).withStyle(ChatFormatting.GRAY));
|
||||||
.append(buildMentionMessage(text, players).withStyle(ChatFormatting.GRAY));
|
|
||||||
|
|
||||||
sender.sendSystemMessage(localMessage);
|
sender.sendSystemMessage(localMessage);
|
||||||
for (ServerPlayer hearer : hearers) {
|
for (ServerPlayer hearer : hearers) {
|
||||||
@@ -90,62 +87,10 @@ public class ChatHandler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
for (ServerPlayer player : sorted) {
|
|
||||||
String name = player.getName().getString();
|
|
||||||
int index = remaining.toLowerCase().indexOf(name.toLowerCase());
|
|
||||||
if (index >= 0 && index < earliestIndex) {
|
|
||||||
earliestIndex = index;
|
|
||||||
mentioned = player;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mentioned == null) {
|
|
||||||
result.append(Component.literal(remaining));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (earliestIndex > 0) {
|
|
||||||
result.append(Component.literal(remaining.substring(0, earliestIndex)));
|
|
||||||
}
|
|
||||||
|
|
||||||
result.append(createMentionComponent(mentioned));
|
|
||||||
|
|
||||||
remaining = remaining.substring(earliestIndex + mentioned.getName().getString().length());
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Component createMentionComponent(ServerPlayer player) {
|
|
||||||
String lpPrefix = getLuckPermsPrefix(player);
|
|
||||||
String formattedPrefix = lpPrefix.replace("&", "\u00a7");
|
|
||||||
String name = player.getName().getString();
|
|
||||||
|
|
||||||
return Component.literal(formattedPrefix + name)
|
|
||||||
.withStyle(Style.EMPTY
|
|
||||||
.withClickEvent(new ClickEvent.SuggestCommand("/msg " + name + " "))
|
|
||||||
.withHoverEvent(new HoverEvent.ShowText(
|
|
||||||
Component.literal("Натисніть щоб написати " + name)
|
|
||||||
.withStyle(ChatFormatting.GRAY)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MutableComponent buildFormattedName(ServerPlayer player) {
|
private static MutableComponent buildFormattedName(ServerPlayer player) {
|
||||||
String lpPrefix = getLuckPermsPrefix(player);
|
String luckPermsPrefix = getLuckPermsPrefix(player);
|
||||||
String playerName = player.getName().getString();
|
String playerName = player.getName().getString();
|
||||||
String formattedPrefix = lpPrefix.replace("&", "\u00a7");
|
String formattedPrefix = luckPermsPrefix.replace("&", "\u00a7");
|
||||||
|
|
||||||
Style clickStyle = Style.EMPTY
|
Style clickStyle = Style.EMPTY
|
||||||
.withClickEvent(new ClickEvent.SuggestCommand("/msg " + playerName + " "))
|
.withClickEvent(new ClickEvent.SuggestCommand("/msg " + playerName + " "))
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import java.util.stream.Collectors;
|
|||||||
public class ChatLogger {
|
public class ChatLogger {
|
||||||
|
|
||||||
public static void logGlobal(ServerPlayer sender, String message, List<ServerPlayer> recipients) {
|
public static void logGlobal(ServerPlayer sender, String message, List<ServerPlayer> recipients) {
|
||||||
// Для глобального чату ігноруємо список отримувачів і просто пишемо "everyone"
|
Localchat.magicModLog.info(
|
||||||
MSNexus.magicModLog.info(
|
|
||||||
"[CHAT][GLOBAL] {} said: \"{}\" | heard by: everyone | pos: {}",
|
"[CHAT][GLOBAL] {} said: \"{}\" | heard by: everyone | pos: {}",
|
||||||
sender.getName().getString(),
|
sender.getName().getString(),
|
||||||
message,
|
message,
|
||||||
@@ -23,14 +22,13 @@ public class ChatLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void logLocal(ServerPlayer sender, String message, List<ServerPlayer> recipients) {
|
public static void logLocal(ServerPlayer sender, String message, List<ServerPlayer> recipients) {
|
||||||
// Для локального чату виводимо лише імена отримувачів, без їхніх координат
|
|
||||||
String heardBy = recipients.isEmpty()
|
String heardBy = recipients.isEmpty()
|
||||||
? "nobody"
|
? "nobody"
|
||||||
: recipients.stream()
|
: recipients.stream()
|
||||||
.map(p -> p.getName().getString())
|
.map(p -> p.getName().getString())
|
||||||
.collect(Collectors.joining(", "));
|
.collect(Collectors.joining(", "));
|
||||||
|
|
||||||
MSNexus.magicModLog.info(
|
Localchat.magicModLog.info(
|
||||||
"[CHAT][LOCAL] {} said: \"{}\" | heard by: {} | pos: {}",
|
"[CHAT][LOCAL] {} said: \"{}\" | heard by: {} | pos: {}",
|
||||||
sender.getName().getString(),
|
sender.getName().getString(),
|
||||||
message,
|
message,
|
||||||
@@ -40,7 +38,7 @@ public class ChatLogger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static void logUnheard(ServerPlayer sender, String message) {
|
public static void logUnheard(ServerPlayer sender, String message) {
|
||||||
MSNexus.magicModLog.info(
|
Localchat.magicModLog.info(
|
||||||
"[CHAT][LOCAL][UNHEARD] {} said: \"{}\" | heard by: nobody | pos: {}",
|
"[CHAT][LOCAL][UNHEARD] {} said: \"{}\" | heard by: nobody | pos: {}",
|
||||||
sender.getName().getString(),
|
sender.getName().getString(),
|
||||||
message,
|
message,
|
||||||
@@ -50,9 +48,6 @@ public class ChatLogger {
|
|||||||
|
|
||||||
private static String formatPos(ServerPlayer player) {
|
private static String formatPos(ServerPlayer player) {
|
||||||
ServerLevel level = player.level();
|
ServerLevel level = player.level();
|
||||||
|
|
||||||
// У ResourceKey дістаємо Identifier/ResourceLocation через .identifier(),
|
|
||||||
// після чого через .getPath() беремо чисту назву ("overworld", "the_nether", "the_end")
|
|
||||||
String rawDimension = level.dimension().identifier().getPath();
|
String rawDimension = level.dimension().identifier().getPath();
|
||||||
|
|
||||||
return String.format(
|
return String.format(
|
||||||
|
|||||||
+4
-2
@@ -1,13 +1,14 @@
|
|||||||
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;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
public class MSNexus implements ModInitializer {
|
public class Localchat implements ModInitializer {
|
||||||
public static final String MOD_ID = "msnexus";
|
public static final String MOD_ID = "localhost";
|
||||||
public static final Logger magicModLog = LoggerFactory.getLogger(MOD_ID);
|
public static final Logger magicModLog = LoggerFactory.getLogger(MOD_ID);
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -15,5 +16,6 @@ public class MSNexus implements ModInitializer {
|
|||||||
StealthStorage.load();
|
StealthStorage.load();
|
||||||
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.Localchat;
|
||||||
|
|
||||||
|
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);
|
||||||
|
Localchat.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.Localchat;
|
||||||
|
|
||||||
|
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) {
|
||||||
|
Localchat.magicModLog.warn("rules.json порожній або невалідний, використовую пустий список правил");
|
||||||
|
return new RulesData.RulesRoot(Collections.emptyList());
|
||||||
|
}
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
} catch (IOException e) {
|
||||||
|
Localchat.magicModLog.error("Не вдалося прочитати rules.json: {}", e.getMessage());
|
||||||
|
return new RulesData.RulesRoot(Collections.emptyList());
|
||||||
|
} catch (JsonSyntaxException e) {
|
||||||
|
Localchat.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);
|
||||||
|
}
|
||||||
|
|
||||||
|
Localchat.magicModLog.info("Створено конфіг правил за замовчуванням: {}", CONFIG_PATH.toAbsolutePath());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,10 +17,6 @@ import net.minecraft.server.level.ServerPlayer;
|
|||||||
* /stealth on — увімкнути stealth
|
* /stealth on — увімкнути stealth
|
||||||
* /stealth off — вимкнути stealth
|
* /stealth off — вимкнути stealth
|
||||||
* /stealth info — показати інформацію про команду
|
* /stealth info — показати інформацію про команду
|
||||||
*
|
|
||||||
* Коли stealth увімкнено, повідомлення локального чату від гравця
|
|
||||||
* не доходять до інших — гравець бачить "Вас ніхто не почув..."
|
|
||||||
* Глобальний чат (! префікс) працює як зазвичай.
|
|
||||||
*/
|
*/
|
||||||
public class StealthCommand {
|
public class StealthCommand {
|
||||||
|
|
||||||
@@ -41,7 +37,6 @@ public class StealthCommand {
|
|||||||
private static int executeToggle(CommandContext<CommandSourceStack> context) {
|
private static int executeToggle(CommandContext<CommandSourceStack> context) {
|
||||||
ServerPlayer player = getPlayer(context);
|
ServerPlayer player = getPlayer(context);
|
||||||
if (player == null) return 0;
|
if (player == null) return 0;
|
||||||
|
|
||||||
boolean newState = StealthStorage.toggle(player.getUUID());
|
boolean newState = StealthStorage.toggle(player.getUUID());
|
||||||
sendStatus(player, newState);
|
sendStatus(player, newState);
|
||||||
return 1;
|
return 1;
|
||||||
@@ -50,7 +45,6 @@ public class StealthCommand {
|
|||||||
private static int executeOn(CommandContext<CommandSourceStack> context) {
|
private static int executeOn(CommandContext<CommandSourceStack> context) {
|
||||||
ServerPlayer player = getPlayer(context);
|
ServerPlayer player = getPlayer(context);
|
||||||
if (player == null) return 0;
|
if (player == null) return 0;
|
||||||
|
|
||||||
StealthStorage.set(player.getUUID(), true);
|
StealthStorage.set(player.getUUID(), true);
|
||||||
sendStatus(player, true);
|
sendStatus(player, true);
|
||||||
return 1;
|
return 1;
|
||||||
@@ -59,7 +53,6 @@ public class StealthCommand {
|
|||||||
private static int executeOff(CommandContext<CommandSourceStack> context) {
|
private static int executeOff(CommandContext<CommandSourceStack> context) {
|
||||||
ServerPlayer player = getPlayer(context);
|
ServerPlayer player = getPlayer(context);
|
||||||
if (player == null) return 0;
|
if (player == null) return 0;
|
||||||
|
|
||||||
StealthStorage.set(player.getUUID(), false);
|
StealthStorage.set(player.getUUID(), false);
|
||||||
sendStatus(player, false);
|
sendStatus(player, false);
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package org.examp.john.stealth;
|
|||||||
import com.google.gson.Gson;
|
import com.google.gson.Gson;
|
||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
import com.google.gson.JsonSyntaxException;
|
import com.google.gson.JsonSyntaxException;
|
||||||
import org.examp.john.MSNexus;
|
import org.examp.john.Localchat;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.Reader;
|
import java.io.Reader;
|
||||||
@@ -18,7 +18,6 @@ import java.util.UUID;
|
|||||||
/**
|
/**
|
||||||
* Зберігає стан stealth-режиму для кожного гравця.
|
* Зберігає стан stealth-режиму для кожного гравця.
|
||||||
* Файл: config/localchat/stealth.json — {"uuid1": true, "uuid2": false, ...}
|
* Файл: config/localchat/stealth.json — {"uuid1": true, "uuid2": false, ...}
|
||||||
* Зміни зберігаються на диск при кожному toggle.
|
|
||||||
*/
|
*/
|
||||||
public class StealthStorage {
|
public class StealthStorage {
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ public class StealthStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (IOException | JsonSyntaxException | IllegalArgumentException e) {
|
} catch (IOException | JsonSyntaxException | IllegalArgumentException e) {
|
||||||
MSNexus.magicModLog.warn("Не вдалося прочитати stealth.json: {}", e.getMessage());
|
Localchat.magicModLog.warn("Не вдалося прочитати stealth.json: {}", e.getMessage());
|
||||||
data = new HashMap<>();
|
data = new HashMap<>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +77,7 @@ public class StealthStorage {
|
|||||||
GSON.toJson(raw, writer);
|
GSON.toJson(raw, writer);
|
||||||
}
|
}
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
MSNexus.magicModLog.error("Не вдалося зберегти stealth.json: {}", e.getMessage());
|
Localchat.magicModLog.error("Не вдалося зберегти stealth.json: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,26 @@
|
|||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 1,
|
||||||
"id": "msnexus",
|
"id": "localchat",
|
||||||
"version": "${version}",
|
"version": "${version}",
|
||||||
"name": "msnexus",
|
"name": "localchat",
|
||||||
"description": "",
|
"description": "",
|
||||||
"authors": [],
|
"authors": [],
|
||||||
"contact": {},
|
"contact": {},
|
||||||
"license": "All-Rights-Reserved",
|
"license": "All-Rights-Reserved",
|
||||||
"icon": "assets/msnexus/icon.png",
|
"icon": "assets/localchat/icon.png",
|
||||||
"environment": "*",
|
"environment": "*",
|
||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
"fabric-datagen": [
|
"fabric-datagen": [
|
||||||
"org.examp.john.client.MSNexusDataGenerator"
|
"org.examp.john.client.LocalchatDataGenerator"
|
||||||
],
|
|
||||||
"client": [
|
|
||||||
"org.examp.john.client.MSNexusClient"
|
|
||||||
],
|
],
|
||||||
"main": [
|
"main": [
|
||||||
"org.examp.john.MSNexus"
|
"org.examp.john.Localchat"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"mixins": [
|
"mixins": [
|
||||||
"msnexus.mixins.json",
|
"localchat.mixins.json",
|
||||||
{
|
{
|
||||||
"config": "msnexus.client.mixins.json",
|
"config": "localchat.client.mixins.json",
|
||||||
"environment": "client"
|
"environment": "client"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user