Upload folder via GUI - src

This commit is contained in:
Git Manager GUI
2026-06-01 21:50:15 +02:00
parent bd0418aa42
commit 1a53977db0
10 changed files with 165 additions and 599 deletions

View File

@@ -3,17 +3,7 @@ package net.viper.status;
import net.md_5.bungee.api.ProxyServer; import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.config.ListenerInfo; import net.md_5.bungee.api.config.ListenerInfo;
import net.md_5.bungee.api.connection.ProxiedPlayer; import net.md_5.bungee.api.connection.ProxiedPlayer;
import net.md_5.bungee.api.chat.ClickEvent;
import net.md_5.bungee.api.chat.HoverEvent;
import net.md_5.bungee.api.chat.hover.content.Text;
import net.md_5.bungee.api.chat.ClickEvent;
import net.md_5.bungee.api.chat.HoverEvent;
import net.md_5.bungee.api.chat.hover.content.Text;
import net.md_5.bungee.api.event.PlayerDisconnectEvent;
import net.md_5.bungee.api.event.PostLoginEvent;
import net.md_5.bungee.api.plugin.Listener;
import net.md_5.bungee.api.plugin.Plugin; import net.md_5.bungee.api.plugin.Plugin;
import net.md_5.bungee.event.EventHandler;
import net.viper.status.module.ModuleManager; import net.viper.status.module.ModuleManager;
import net.viper.status.modules.economy.EconomyModule; import net.viper.status.modules.economy.EconomyModule;
import net.viper.status.modules.tablist.TablistModule; import net.viper.status.modules.tablist.TablistModule;
@@ -58,7 +48,7 @@ import net.md_5.bungee.api.scheduler.ScheduledTask;
/** /**
* StatusAPI - zentraler BungeeCord HTTP-Status- und Broadcast-Endpunkt * StatusAPI - zentraler BungeeCord HTTP-Status- und Broadcast-Endpunkt
*/ */
public class StatusAPI extends Plugin implements Runnable, Listener { public class StatusAPI extends Plugin implements Runnable {
// Welt pro Spieler (UUID -> Weltname), wird von StatusAPIBridge gepusht // Welt pro Spieler (UUID -> Weltname), wird von StatusAPIBridge gepusht
public static final ConcurrentHashMap<UUID, String> playerWorlds = new ConcurrentHashMap<>(); public static final ConcurrentHashMap<UUID, String> playerWorlds = new ConcurrentHashMap<>();
@@ -178,9 +168,6 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
getLogger().info("[PlayerLoginLogger] Login-Logging aktiv -> " + getDataFolder() + "/player-logins.log (Zeitzone: " + loginZone + ")"); getLogger().info("[PlayerLoginLogger] Login-Logging aktiv -> " + getDataFolder() + "/player-logins.log (Zeitzone: " + loginZone + ")");
} }
// Memory-Leak-Fix: playerWorlds/playerPapi beim Disconnect bereinigen
ProxyServer.getInstance().getPluginManager().registerListener(this, this);
// FIX: ScoreboardModule mit NetworkInfoModule verbinden (TPS-Fallback) // FIX: ScoreboardModule mit NetworkInfoModule verbinden (TPS-Fallback)
try { try {
net.viper.status.modules.scoreboard.ScoreboardModule sbMod = net.viper.status.modules.scoreboard.ScoreboardModule sbMod =
@@ -304,107 +291,17 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
updateChecker.checkNow(); updateChecker.checkNow();
String currentVersion = getDescription() != null ? getDescription().getVersion() : "0.0.0"; String currentVersion = getDescription() != null ? getDescription().getVersion() : "0.0.0";
if (updateChecker.isUpdateAvailable(currentVersion)) { if (updateChecker.isUpdateAvailable(currentVersion)) {
String newVersion = updateChecker.getLatestVersion(); String newVersion = updateChecker.getLatestVersion();
String releaseUrl = updateChecker.getLatestUrl(); getLogger().warning("----------------------------------------");
boolean preRelease = updateChecker.isLatestPreRelease(); getLogger().warning("Neue Version verf\u00fcgbar: " + newVersion);
// Konsolen-Log getLogger().warning("Download: " + updateChecker.getLatestUrl());
getLogger().warning((preRelease ? "[Pre-Release] " : "") + "Update verfuegbar: " + newVersion + " -> " + releaseUrl); getLogger().warning("----------------------------------------");
// Ingame-Meldung an alle Spieler mit statusapi.update.notify
broadcastUpdateNotice(newVersion, releaseUrl, preRelease);
} }
} catch (Exception e) { } catch (Exception e) {
getLogger().severe("Fehler beim Update-Check: " + e.getMessage()); getLogger().severe("Fehler beim Update-Check: " + e.getMessage());
} }
} }
/** Sendet die Update-Meldung an alle online Spieler mit statusapi.update.notify */
private void broadcastUpdateNotice(String newVersion, String releaseUrl, boolean preRelease) {
String current = getDescription() != null ? getDescription().getVersion() : "?";
sendUpdateMessage(ProxyServer.getInstance().getConsole(), newVersion, current, releaseUrl, preRelease);
for (net.md_5.bungee.api.connection.ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) {
if (p.hasPermission("statusapi.update.notify")) {
sendUpdateMessage(p, newVersion, current, releaseUrl, preRelease);
}
}
}
private static final String RELEASES_URL = "https://git.viper.ipv64.net/M_Viper/StatusAPI/releases";
/** Formatiert und schickt die Update-Meldung an einen einzelnen Empfaenger */
private static void sendUpdateMessage(net.md_5.bungee.api.CommandSender target,
String newVersion, String currentVersion, String releaseUrl,
boolean preRelease) {
// Einfache Textzeilen
String typeLabel = preRelease ? "&e&lPre-Release" : "&a&lRelease";
String typeNotice = preRelease ? "&eVorsicht: Dies ist ein Pre-Release und kann instabil sein!" : "";
String[] plainLines = preRelease ? new String[]{
"&8&m" + repeat("-", 44),
"&6&l StatusAPI &7\u2013 &e&lPre-Release verf\u00fcgbar!",
"&7Aktuelle Version: &c" + currentVersion,
"&7Neue Version: &e" + newVersion + " &7[Pre-Release]",
"&eVorsicht: &7Kann instabil sein, bitte testen!",
} : new String[]{
"&8&m" + repeat("-", 44),
"&6&l StatusAPI &7\u2013 Update verf\u00fcgbar!",
"&7Aktuelle Version: &c" + currentVersion,
"&7Neue Version: &a" + newVersion,
};
for (String line : plainLines) {
target.sendMessage(new net.md_5.bungee.api.chat.TextComponent(
net.md_5.bungee.api.ChatColor.translateAlternateColorCodes('&', line)));
}
// Klickbare Link-Zeile (nur fuer ProxiedPlayer, Konsole bekommt Plaintext)
if (target instanceof net.md_5.bungee.api.connection.ProxiedPlayer) {
net.md_5.bungee.api.chat.TextComponent prefix = new net.md_5.bungee.api.chat.TextComponent(
net.md_5.bungee.api.ChatColor.translateAlternateColorCodes('&', "&7Releases: "));
net.md_5.bungee.api.chat.TextComponent link = new net.md_5.bungee.api.chat.TextComponent(
net.md_5.bungee.api.ChatColor.translateAlternateColorCodes('&', "&b&n" + RELEASES_URL));
link.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, RELEASES_URL));
link.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT,
new Text(net.md_5.bungee.api.ChatColor.translateAlternateColorCodes('&',
"&7Klicke um die Release-Seite zu \u00f6ffnen"))));
prefix.addExtra(link);
target.sendMessage(prefix);
} else {
target.sendMessage(new net.md_5.bungee.api.chat.TextComponent(
net.md_5.bungee.api.ChatColor.translateAlternateColorCodes('&', "&7Releases: &b" + RELEASES_URL)));
}
target.sendMessage(new net.md_5.bungee.api.chat.TextComponent(
net.md_5.bungee.api.ChatColor.translateAlternateColorCodes('&', "&8&m" + repeat("-", 44))));
}
private static String repeat(String s, int n) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) sb.append(s);
return sb.toString();
}
// Memory-Leak-Fix: playerWorlds und playerPapi beim Spieler-Logout bereinigen.
// Diese Maps werden über HTTP von der Bridge befüllt und haben sonst keinen
// Disconnect-Handler, was zu unbegrenztem Wachstum führt.
@EventHandler
public void onPlayerDisconnect(PlayerDisconnectEvent event) {
UUID id = event.getPlayer().getUniqueId();
playerWorlds.remove(id);
playerPapi.remove(id);
}
/** Update-Hinweis beim Login für Spieler mit statusapi.update.notify */
@EventHandler
public void onPostLogin(PostLoginEvent event) {
net.md_5.bungee.api.connection.ProxiedPlayer p = event.getPlayer();
if (!p.hasPermission("statusapi.update.notify")) return;
String current = getDescription() != null ? getDescription().getVersion() : "0.0.0";
if (updateChecker != null && updateChecker.isUpdateAvailable(current)) {
// Kurze Verzögerung damit der Spieler erst spawnt
ProxyServer.getInstance().getScheduler().schedule(this, () ->
sendUpdateMessage(p, updateChecker.getLatestVersion(), current, updateChecker.getLatestUrl(), updateChecker.isLatestPreRelease()),
3, TimeUnit.SECONDS);
}
}
// --- WebServer --- // --- WebServer ---
@Override @Override
public void run() { public void run() {
@@ -1221,19 +1118,13 @@ public class StatusAPI extends Plugin implements Runnable, Listener {
/** /**
* Liest den HTTP-Body basierend auf Content-Length. * Liest den HTTP-Body basierend auf Content-Length.
* Max. 1 MB schützt vor überdimensionierten Requests (DoS via Content-Length).
*/ */
private static final int MAX_BODY_SIZE = 1024 * 1024; // 1 MB
private String readBody(BufferedReader in, Map<String, String> headers) throws IOException { private String readBody(BufferedReader in, Map<String, String> headers) throws IOException {
int contentLength = 0; int contentLength = 0;
if (headers.containsKey("content-length")) { if (headers.containsKey("content-length")) {
try { contentLength = Integer.parseInt(headers.get("content-length")); } catch (NumberFormatException ignored) {} try { contentLength = Integer.parseInt(headers.get("content-length")); } catch (NumberFormatException ignored) {}
} }
if (contentLength <= 0) return ""; if (contentLength <= 0) return "";
if (contentLength > MAX_BODY_SIZE) {
getLogger().warning("[StatusAPI] Request abgelehnt: Content-Length " + contentLength + " > " + MAX_BODY_SIZE + " Bytes.");
return "";
}
char[] bodyChars = new char[contentLength]; char[] bodyChars = new char[contentLength];
int read = 0; int read = 0;
while (read < contentLength) { while (read < contentLength) {

View File

@@ -19,13 +19,12 @@ public class UpdateChecker {
// Neue Domain und korrekter API-Pfad f\u00fcr Releases // Neue Domain und korrekter API-Pfad f\u00fcr Releases
private final String apiUrl = "https://git.viper.ipv64.net/api/v1/repos/M_Viper/StatusAPI/releases"; private final String apiUrl = "https://git.viper.ipv64.net/api/v1/repos/M_Viper/StatusAPI/releases";
private volatile String latestVersion = ""; private volatile String latestVersion = "";
private volatile String latestUrl = ""; private volatile String latestUrl = "";
private volatile boolean latestPreRelease = false;
private static final Pattern TAG_NAME_PATTERN = Pattern.compile("\"tag_name\"\\s*:\\s*\"([^\"]+)\"", Pattern.CASE_INSENSITIVE); private static final Pattern ASSET_NAME_PATTERN = Pattern.compile("\"name\"\\s*:\\s*\"([^\"]+)\"", Pattern.CASE_INSENSITIVE);
private static final Pattern HTML_URL_PATTERN = Pattern.compile("\"html_url\"\\s*:\\s*\"([^\"]+)\"", Pattern.CASE_INSENSITIVE); private static final Pattern DOWNLOAD_PATTERN = Pattern.compile("\"browser_download_url\"\\s*:\\s*\"([^\"]+)\"", Pattern.CASE_INSENSITIVE);
private static final Pattern PRERELEASE_PATTERN = Pattern.compile("\"prerelease\"\\s*:\\s*(true|false)", Pattern.CASE_INSENSITIVE); private static final Pattern TAG_NAME_PATTERN = Pattern.compile("\"tag_name\"\\s*:\\s*\"([^\"]+)\"", Pattern.CASE_INSENSITIVE);
public UpdateChecker(Plugin plugin, String currentVersion, int intervalHours) { public UpdateChecker(Plugin plugin, String currentVersion, int intervalHours) {
this.plugin = plugin; this.plugin = plugin;
@@ -72,23 +71,39 @@ public class UpdateChecker {
foundVersion = foundVersion.substring(1); foundVersion = foundVersion.substring(1);
} }
// Release-Seiten-URL fuer die Ingame-Meldung String foundUrl = null;
String foundUrl = "https://git.viper.ipv64.net/M_Viper/StatusAPI/releases";
Matcher urlM = HTML_URL_PATTERN.matcher(body); // Wir suchen im gesamten Body nach der JAR-Datei "StatusAPI.jar"
if (urlM.find()) { // Da das neueste Release zuerst kommt, brechen wir ab, sobald wir eine passende JAR finden
foundUrl = urlM.group(1).trim(); Matcher nameMatcher = ASSET_NAME_PATTERN.matcher(body);
Matcher downloadMatcher = DOWNLOAD_PATTERN.matcher(body);
java.util.List<String> names = new java.util.ArrayList<>();
java.util.List<String> urls = new java.util.ArrayList<>();
while (nameMatcher.find()) {
names.add(nameMatcher.group(1));
}
while (downloadMatcher.find()) {
urls.add(downloadMatcher.group(1));
} }
// prerelease-Flag aus dem ersten Release-Block lesen int pairs = Math.min(names.size(), urls.size());
boolean foundPreRelease = false; for (int i = 0; i < pairs; i++) {
Matcher preM = PRERELEASE_PATTERN.matcher(body); String name = names.get(i).trim();
if (preM.find()) { String url = urls.get(i);
foundPreRelease = Boolean.parseBoolean(preM.group(1).trim()); if ("StatusAPI.jar".equalsIgnoreCase(name)) {
foundUrl = url;
break; // Erste (also neueste) passende JAR nehmen
}
} }
latestVersion = foundVersion; if (foundUrl == null) {
latestUrl = foundUrl; plugin.getLogger().warning("Keine StatusAPI.jar im neuesten Release gefunden.");
latestPreRelease = foundPreRelease; return;
}
latestVersion = foundVersion;
latestUrl = foundUrl;
} catch (Exception e) { } catch (Exception e) {
plugin.getLogger().log(Level.SEVERE, "Fehler beim Update-Check: " + e.getMessage(), e); plugin.getLogger().log(Level.SEVERE, "Fehler beim Update-Check: " + e.getMessage(), e);
@@ -103,15 +118,9 @@ public class UpdateChecker {
return latestUrl != null ? latestUrl : ""; return latestUrl != null ? latestUrl : "";
} }
public boolean isLatestPreRelease() {
return latestPreRelease;
}
public boolean isUpdateAvailable(String currentVer) { public boolean isUpdateAvailable(String currentVer) {
String lv = getLatestVersion(); String lv = getLatestVersion();
if (lv.isEmpty()) return false; if (lv.isEmpty()) return false;
// Nur melden wenn latest STRIKT groesser als current ist.
// Laeuft eine Dev-Version (current > latest), kein Hinweis.
return compareVersions(lv, currentVer) > 0; return compareVersions(lv, currentVer) > 0;
} }

View File

@@ -3,7 +3,6 @@ package net.viper.status.modules.AutoMessage;
import net.md_5.bungee.api.ChatColor; import net.md_5.bungee.api.ChatColor;
import net.md_5.bungee.api.CommandSender; import net.md_5.bungee.api.CommandSender;
import net.md_5.bungee.api.ProxyServer; import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.chat.BaseComponent;
import net.md_5.bungee.api.chat.TextComponent; import net.md_5.bungee.api.chat.TextComponent;
import net.md_5.bungee.api.plugin.Command; import net.md_5.bungee.api.plugin.Command;
import net.md_5.bungee.api.plugin.Plugin; import net.md_5.bungee.api.plugin.Plugin;
@@ -14,7 +13,6 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Properties; import java.util.Properties;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@@ -147,13 +145,12 @@ public class AutoMessageModule implements Module {
if (idx >= messages.size()) idx = 0; if (idx >= messages.size()) idx = 0;
String raw = messages.get(idx); String raw = messages.get(idx);
String prefixPart = prefix.isEmpty() ? "" : ChatColor.translateAlternateColorCodes('&', applyGradients(prefix)) + " "; String prefixPart = prefix.isEmpty() ? "" : ChatColor.translateAlternateColorCodes('&', prefix) + " ";
// Gradient zuerst auflösen, dann §/&-Codes übersetzen // Fix: §-Codes direkt \u00fcbersetzen (messages.txt nutzt §-Codes)
String normalized = raw.replace("\u00a7", "&").replace("§", "&"); String text = prefixPart + ChatColor.translateAlternateColorCodes('&',
String text = prefixPart + ChatColor.translateAlternateColorCodes('&', applyGradients(normalized)); raw.replace("\u00a7", "&").replace("§", "&"));
BaseComponent[] components = TextComponent.fromLegacyText(text); ProxyServer.getInstance().broadcast(new TextComponent(text));
ProxyServer.getInstance().broadcast(components);
}, intervalSeconds, intervalSeconds, TimeUnit.SECONDS).getId(); }, intervalSeconds, intervalSeconds, TimeUnit.SECONDS).getId();
} }
@@ -163,122 +160,4 @@ public class AutoMessageModule implements Module {
taskId = -1; taskId = -1;
} }
} }
}
// ── Gradient-Support (%gradient:FARBE1:FARBE2:...:TEXT%) ─────────────────
private String applyGradients(String input) {
if (input == null || !input.contains("%gradient:")) return input;
StringBuilder result = new StringBuilder();
int i = 0;
while (i < input.length()) {
int start = input.indexOf("%gradient:", i);
if (start < 0) { result.append(input.substring(i)); break; }
result.append(input, i, start);
int end = input.indexOf("%", start + 10);
if (end < 0) { result.append(input.substring(start)); break; }
String inner = input.substring(start + 10, end);
List<int[]> stops = new ArrayList<>();
int colonIdx = 0;
while (colonIdx < inner.length()) {
int nextColon = inner.indexOf(':', colonIdx);
if (nextColon < 0) break;
String candidate = inner.substring(colonIdx, nextColon);
int[] rgb = parseGradientColor(candidate);
if (rgb != null) { stops.add(rgb); colonIdx = nextColon + 1; }
else break;
}
if (stops.size() < 2) { result.append(input, start, end + 1); i = end + 1; continue; }
String text = inner.substring(colonIdx);
String plain = ChatColor.stripColor(ChatColor.translateAlternateColorCodes('&', text));
boolean bold = text.contains("&l") || text.contains("\u00A7l");
boolean italic = text.contains("&o") || text.contains("\u00A7o");
boolean underline = text.contains("&n") || text.contains("\u00A7n");
boolean strike = text.contains("&m") || text.contains("\u00A7m");
String fmt = (bold ? "\u00A7l" : "") + (italic ? "\u00A7o" : "")
+ (underline ? "\u00A7n" : "") + (strike ? "\u00A7m" : "");
int visLen = 0;
for (char ch : plain.toCharArray()) if (ch != ' ') visLen++;
if (visLen == 0) visLen = 1;
int charIdx = 0;
int[] lastRgb = stops.get(0);
for (char ch : plain.toCharArray()) {
if (ch == ' ') {
result.append('\u00A7').append('x');
result.append('\u00A7').append(String.format("%02X", lastRgb[0]).charAt(0));
result.append('\u00A7').append(String.format("%02X", lastRgb[0]).charAt(1));
result.append('\u00A7').append(String.format("%02X", lastRgb[1]).charAt(0));
result.append('\u00A7').append(String.format("%02X", lastRgb[1]).charAt(1));
result.append('\u00A7').append(String.format("%02X", lastRgb[2]).charAt(0));
result.append('\u00A7').append(String.format("%02X", lastRgb[2]).charAt(1));
result.append(fmt).append(ch);
continue;
}
float pos = visLen <= 1 ? 0f : (float) charIdx / (visLen - 1);
lastRgb = interpolateGradient(stops, pos);
result.append('\u00A7').append('x');
result.append('\u00A7').append(String.format("%02X", lastRgb[0]).charAt(0));
result.append('\u00A7').append(String.format("%02X", lastRgb[0]).charAt(1));
result.append('\u00A7').append(String.format("%02X", lastRgb[1]).charAt(0));
result.append('\u00A7').append(String.format("%02X", lastRgb[1]).charAt(1));
result.append('\u00A7').append(String.format("%02X", lastRgb[2]).charAt(0));
result.append('\u00A7').append(String.format("%02X", lastRgb[2]).charAt(1));
result.append(fmt).append(ch);
charIdx++;
}
// §r nach dem Gradient-Block, damit nachfolgende §8/§7 etc. wieder greifen
result.append('\u00A7').append('r');
i = end + 1;
}
return result.toString();
}
private int[] parseGradientColor(String s) {
s = s.trim();
if (s.startsWith("&#")) s = s.substring(1);
if (s.startsWith("#") && s.length() == 7) {
try {
return new int[]{
Integer.parseInt(s.substring(1,3),16),
Integer.parseInt(s.substring(3,5),16),
Integer.parseInt(s.substring(5,7),16)
};
} catch (Exception ignored) {}
}
if (s.startsWith("&") && s.length() == 2) return mcColorToRgb(s.charAt(1));
return null;
}
private int[] interpolateGradient(List<int[]> stops, float pos) {
if (stops.size() == 1) return stops.get(0);
float scaled = pos * (stops.size() - 1);
int i0 = Math.min((int) scaled, stops.size() - 2);
float t = scaled - i0;
return new int[]{
(int)(stops.get(i0)[0] * (1-t) + stops.get(i0+1)[0] * t),
(int)(stops.get(i0)[1] * (1-t) + stops.get(i0+1)[1] * t),
(int)(stops.get(i0)[2] * (1-t) + stops.get(i0+1)[2] * t)
};
}
private static int[] mcColorToRgb(char code) {
switch (Character.toLowerCase(code)) {
case '0': return new int[]{ 0, 0, 0};
case '1': return new int[]{ 0, 0, 170};
case '2': return new int[]{ 0, 170, 0};
case '3': return new int[]{ 0, 170, 170};
case '4': return new int[]{170, 0, 0};
case '5': return new int[]{170, 0, 170};
case '6': return new int[]{255, 170, 0};
case '7': return new int[]{170, 170, 170};
case '8': return new int[]{ 85, 85, 85};
case '9': return new int[]{ 85, 85, 255};
case 'a': return new int[]{ 85, 255, 85};
case 'b': return new int[]{ 85, 255, 255};
case 'c': return new int[]{255, 85, 85};
case 'd': return new int[]{255, 85, 255};
case 'e': return new int[]{255, 255, 85};
case 'f': return new int[]{255, 255, 255};
default: return null;
}
}
}

View File

@@ -20,7 +20,6 @@ import net.viper.status.module.Module;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
/** /**
* AfkModule /afk Befehl + automatische AFK-Erkennung nach Inaktivit\u00e4t. * AfkModule /afk Befehl + automatische AFK-Erkennung nach Inaktivit\u00e4t.
@@ -92,8 +91,6 @@ public class AfkModule implements Module, Listener {
if (!enabled) { plugin.getLogger().info("[AfkModule] Deaktiviert."); return; } if (!enabled) { plugin.getLogger().info("[AfkModule] Deaktiviert."); return; }
ProxyServer.getInstance().getPluginManager().registerListener(plugin, this); ProxyServer.getInstance().getPluginManager().registerListener(plugin, this);
// Plugin-Message-Channel registrieren (BungeeCord → Spigot)
ProxyServer.getInstance().registerChannel("statusapi:afk");
ProxyServer.getInstance().getPluginManager().registerCommand(plugin, ProxyServer.getInstance().getPluginManager().registerCommand(plugin,
new Command("afk") { new Command("afk") {
@Override @Override
@@ -125,7 +122,6 @@ public class AfkModule implements Module, Listener {
StatusAPI.playerAfk.clear(); StatusAPI.playerAfk.clear();
lastActivity.clear(); lastActivity.clear();
INSTANCE = null; INSTANCE = null;
try { ProxyServer.getInstance().unregisterChannel("statusapi:afk"); } catch (Exception ignored) {}
} }
// ── Events ─────────────────────────────────────────────────────────────── // ── Events ───────────────────────────────────────────────────────────────
@@ -135,9 +131,7 @@ public class AfkModule implements Module, Listener {
if (!(e.getSender() instanceof ProxiedPlayer)) return; if (!(e.getSender() instanceof ProxiedPlayer)) return;
ProxiedPlayer p = (ProxiedPlayer) e.getSender(); ProxiedPlayer p = (ProxiedPlayer) e.getSender();
recordActivity(p.getUniqueId()); recordActivity(p.getUniqueId());
String msg = e.getMessage(); if (!e.getMessage().toLowerCase().startsWith("/afk") && isAfk(p.getUniqueId()))
if (msg == null) return;
if (!msg.toLowerCase(Locale.ROOT).startsWith("/afk") && isAfk(p.getUniqueId()))
setAfk(p, false); setAfk(p, false);
} }
@@ -197,56 +191,23 @@ public class AfkModule implements Module, Listener {
} else { } else {
StatusAPI.playerAfk.remove(id); StatusAPI.playerAfk.remove(id);
lastActivity.put(id, System.currentTimeMillis()); lastActivity.put(id, System.currentTimeMillis());
TitlePair pair = activePair.remove(id); TitlePair pair = activePair.get(id);
stopTitleTask(id); stopTitleTask(id);
clearTitle(p); clearTitle(p);
if (pair != null) sendTitleEntry(p, pair.unset); if (pair != null) sendTitleEntry(p, pair.unset);
} }
// AFK-Status an alle Spigot-Server broadcasten damit die Bridge
// den Nametag-Prefix sofort aktualisieren kann.
broadcastAfkState(id, afk);
}
/**
* Schickt eine Plugin-Message (Channel "statusapi:afk") an alle Spigot-Server,
* auf denen der betroffene Spieler ODER irgendein anderer Spieler eingeloggt ist.
* Format: "UUID:true" / "UUID:false"
*/
private void broadcastAfkState(UUID uuid, boolean afk) {
byte[] payload = (uuid.toString() + ":" + afk).getBytes(java.nio.charset.StandardCharsets.UTF_8);
Set<net.md_5.bungee.api.config.ServerInfo> reached = new java.util.HashSet<>();
// Zuerst: Server des betroffenen Spielers direkt ansprechen
ProxiedPlayer self = ProxyServer.getInstance().getPlayer(uuid);
if (self != null && self.isConnected() && self.getServer() != null) {
net.md_5.bungee.api.config.ServerInfo srv = self.getServer().getInfo();
if (srv != null) { srv.sendData("statusapi:afk", payload); reached.add(srv); }
}
// Dann: alle anderen Online-Spieler deren Server brauchen das Update
// damit der Prefix über dem AFK-Spieler-Kopf auch bei Anderen stimmt.
for (ProxiedPlayer other : ProxyServer.getInstance().getPlayers()) {
if (!other.isConnected() || other.getServer() == null) continue;
net.md_5.bungee.api.config.ServerInfo srv = other.getServer().getInfo();
if (srv != null && !reached.contains(srv)) {
srv.sendData("statusapi:afk", payload);
reached.add(srv);
}
}
} }
private void checkIdle() { private void checkIdle() {
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
long thresholdMs = idleSeconds * 1000L; long thresholdMs = idleSeconds * 1000L;
for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) {
try { if (!p.isConnected()) continue;
if (!p.isConnected()) continue; UUID id = p.getUniqueId();
UUID id = p.getUniqueId(); if (p.hasPermission(bypassPerm) || isAfk(id)) continue;
if (p.hasPermission(bypassPerm) || isAfk(id)) continue; Long last = lastActivity.get(id);
Long last = lastActivity.get(id); if (last == null) { lastActivity.put(id, now); continue; }
if (last == null) { lastActivity.put(id, now); continue; } if (now - last >= thresholdMs) setAfk(p, true);
if (now - last >= thresholdMs) setAfk(p, true);
} catch (Exception ex) {
plugin.getLogger().log(Level.FINE, "[AfkModule] Fehler in checkIdle fuer Spieler.", ex);
}
} }
} }
@@ -263,29 +224,17 @@ public class AfkModule implements Module, Listener {
TitlePair pair = titlePairs.get(random.nextInt(titlePairs.size())); TitlePair pair = titlePairs.get(random.nextInt(titlePairs.size()));
activePair.put(id, pair); activePair.put(id, pair);
activeTitleEntry.put(id, pair.set); activeTitleEntry.put(id, pair.set);
if (!sendTitleEntry(p, pair.set, true)) { // firstSend=true → TitleTimes mitsenden sendTitleEntry(p, pair.set);
stopTitleTask(id);
return;
}
// Nicht zu aggressiv senden, um Verbindungsprobleme bei langen AFK-Phasen zu vermeiden. long intervalMs = Math.max(500, (titleStay - 20) * 50L);
long intervalMs = Math.max(1500, (titleStay - 20) * 50L);
ScheduledTask task = ProxyServer.getInstance().getScheduler().schedule(plugin, () -> { ScheduledTask task = ProxyServer.getInstance().getScheduler().schedule(plugin, () -> {
try { ProxiedPlayer online = ProxyServer.getInstance().getPlayer(id);
ProxiedPlayer online = ProxyServer.getInstance().getPlayer(id); if (online == null || !online.isConnected() || !isAfk(id)) {
if (online == null || !online.isConnected() || !isAfk(id)) {
stopTitleTask(id);
return;
}
String[] current = activeTitleEntry.get(id);
// firstSend=false → kein TitleTimes → kein Blinken beim Repeat
if (current != null && !sendTitleEntry(online, current, false)) {
stopTitleTask(id);
}
} catch (Exception ex) {
plugin.getLogger().log(Level.FINE, "[AfkModule] Fehler im Title-Repeat-Task.", ex);
stopTitleTask(id); stopTitleTask(id);
return;
} }
String[] current = activeTitleEntry.get(id);
if (current != null) sendTitleEntry(online, current);
}, intervalMs, intervalMs, TimeUnit.MILLISECONDS); }, intervalMs, intervalMs, TimeUnit.MILLISECONDS);
titleTasks.put(id, task); titleTasks.put(id, task);
} }
@@ -294,15 +243,10 @@ public class AfkModule implements Module, Listener {
ScheduledTask old = titleTasks.remove(id); ScheduledTask old = titleTasks.remove(id);
if (old != null) old.cancel(); if (old != null) old.cancel();
activeTitleEntry.remove(id); activeTitleEntry.remove(id);
// BUGFIX: activePair hier NICHT entfernen wenn der Spieler noch AFK ist // activePair bleibt bis setAfk(false) es ausliest, danach:
// setAfk(false) liest activePair noch aus (für den Unset-Title) und entfernt es selbst.
// Wenn der Spieler aber NICHT mehr AFK ist (z.B. Exception im Task), muss activePair
// hier bereinigt werden, sonst Memory Leak.
if (!Boolean.TRUE.equals(StatusAPI.playerAfk.get(id))) {
activePair.remove(id);
}
} }
/** Entfernt den Title sofort vom Bildschirm. */
/** Entfernt den Title sofort vom Bildschirm via ClearTitles-Packet. */ /** Entfernt den Title sofort vom Bildschirm via ClearTitles-Packet. */
private void clearTitle(ProxiedPlayer p) { private void clearTitle(ProxiedPlayer p) {
try { try {
@@ -315,37 +259,20 @@ public class AfkModule implements Module, Listener {
* Sendet Title + Subtitle als raw Packets (wie ScoreboardModule) * Sendet Title + Subtitle als raw Packets (wie ScoreboardModule)
* dadurch werden Hex-Farben korrekt \u00fcbertragen, ohne durch TextComponent.fromArray() zu laufen. * dadurch werden Hex-Farben korrekt \u00fcbertragen, ohne durch TextComponent.fromArray() zu laufen.
*/ */
/** private void sendTitleEntry(ProxiedPlayer p, String[] entry) {
* Sendet Title + Subtitle an den Spieler. String titleRaw = ChatColor.translateAlternateColorCodes('&', applyGradients(entry[0]));
* @param firstSend true = TitleTimes-Packet mitsenden (erster Aufruf). String subtitleRaw = entry.length > 1 ? ChatColor.translateAlternateColorCodes('&', applyGradients(entry[1])) : "";
* false = nur Title/Subtitle refreshen, KEIN TitleTimes →
* verhindert das Blinken (fade-in/-out) bei jedem Repeat-Tick.
*/
private boolean sendTitleEntry(ProxiedPlayer p, String[] entry, boolean firstSend) {
if (p == null || !p.isConnected() || entry == null || entry.length == 0) return false;
String titleRaw = ChatColor.translateAlternateColorCodes('&', applyGradients(
entry[0] == null ? "" : entry[0]
));
String subtitleRaw = entry.length > 1
? ChatColor.translateAlternateColorCodes('&', applyGradients(entry[1] == null ? "" : entry[1]))
: "";
try { try {
if (sendPkt == null) throw new IllegalStateException("sendPkt not initialized"); if (sendPkt == null) throw new IllegalStateException("sendPkt not initialized");
// TitleTimes NUR beim ersten Senden schicken. // Times zuerst senden
// Wird das Packet bei jedem Repeat-Tick gesendet, startet der Client net.md_5.bungee.protocol.packet.TitleTimes times = new net.md_5.bungee.protocol.packet.TitleTimes();
// jedes Mal eine neue fade-in-Animation → sichtbares Blinken. times.setFadeIn(titleFadeIn);
if (firstSend) { times.setStay(titleStay);
net.md_5.bungee.protocol.packet.TitleTimes times = new net.md_5.bungee.protocol.packet.TitleTimes(); times.setFadeOut(titleFadeOut);
times.setFadeIn(titleFadeIn); sendPkt.invoke(p, times);
times.setStay(titleStay);
times.setFadeOut(titleFadeOut);
sendPkt.invoke(p, times);
}
// Title-Packet // Title-Packet (Action = TITLE, ordinal 0)
net.md_5.bungee.protocol.packet.Title titlePkt = new net.md_5.bungee.protocol.packet.Title(); net.md_5.bungee.protocol.packet.Title titlePkt = new net.md_5.bungee.protocol.packet.Title();
titlePkt.setAction(net.md_5.bungee.protocol.packet.Title.Action.TITLE); titlePkt.setAction(net.md_5.bungee.protocol.packet.Title.Action.TITLE);
titlePkt.setText(mergeComponents(buildComponents(titleRaw))); titlePkt.setText(mergeComponents(buildComponents(titleRaw)));
@@ -356,37 +283,20 @@ public class AfkModule implements Module, Listener {
subPkt.setText(mergeComponents(buildComponents(subtitleRaw))); subPkt.setText(mergeComponents(buildComponents(subtitleRaw)));
sendPkt.invoke(p, subPkt); sendPkt.invoke(p, subPkt);
return true;
} catch (Exception e) { } catch (Exception e) {
// Fallback auf Title-API // Fallback auf Title-API
try { try {
Title title = ProxyServer.getInstance().createTitle(); Title title = ProxyServer.getInstance().createTitle();
title.title(buildComponents(titleRaw)); title.title(buildComponents(titleRaw));
title.subTitle(buildComponents(subtitleRaw)); title.subTitle(buildComponents(subtitleRaw));
if (firstSend) { title.fadeIn(titleFadeIn);
title.fadeIn(titleFadeIn); title.stay(titleStay);
title.stay(titleStay); title.fadeOut(titleFadeOut);
title.fadeOut(titleFadeOut);
} else {
// Kein Fade → bleibt stabil stehen ohne Blink-Effekt
title.fadeIn(0);
title.stay(titleStay);
title.fadeOut(0);
}
p.sendTitle(title); p.sendTitle(title);
return true; } catch (Exception ignored) {}
} catch (Exception ignored) {
return false;
}
} }
} }
/** Overload für Aufrufe ohne firstSend-Flag (z.B. unset-Title beim Zurückkommen). */
private boolean sendTitleEntry(ProxiedPlayer p, String[] entry) {
return sendTitleEntry(p, entry, true);
}
/** Fasst BaseComponent[] in eine TextComponent zusammen (f\u00fcr Title/Subtitle setText). */ /** Fasst BaseComponent[] in eine TextComponent zusammen (f\u00fcr Title/Subtitle setText). */
private static net.md_5.bungee.api.chat.BaseComponent mergeComponents(BaseComponent[] parts) { private static net.md_5.bungee.api.chat.BaseComponent mergeComponents(BaseComponent[] parts) {
TextComponent root = new TextComponent(""); TextComponent root = new TextComponent("");

View File

@@ -246,73 +246,28 @@ public class AntiBotModule implements Module, Listener {
} }
} }
// FIX Netzwerk-Timeout: VPN-Check asynchron ausführen damit der BungeeCord-
// Netzwerk-Thread nicht blockiert wird. Ein blockierender HTTP-Request (bis zu
// vpnTimeoutMs = 2500 ms) im PreLoginEvent-Handler verhindert, dass KeepAlive-
// Pakete an bereits eingeloggte Spieler verarbeitet werden → Disconnect.
if (vpnCheckEnabled) { if (vpnCheckEnabled) {
// Cache-Treffer: sofort prüfen, kein async nötig VpnCheckResult info = getVpnInfo(ip, now);
VpnCacheEntry cached = vpnCache.get(ip); if (info != null) {
if (cached != null && cached.expiresAt > now) { boolean shouldBlock = (vpnBlockProxy && info.proxy) || (vpnBlockHosting && info.hosting);
VpnCheckResult info = cached.result; if (shouldBlock) {
if (info != null) { logSecurityEvent("vpn_detected", ip, event.getConnection(), "proxy=" + info.proxy + ", hosting=" + info.hosting);
boolean shouldBlock = (vpnBlockProxy && info.proxy) || (vpnBlockHosting && info.hosting); if (learningModeEnabled) {
if (shouldBlock) { if (vpnBlockProxy && info.proxy) addLearningScore(ip, now, learningVpnProxyPoints, "vpn-proxy", false);
logSecurityEvent("vpn_detected", ip, event.getConnection(), "proxy=" + info.proxy + ", hosting=" + info.hosting); if (vpnBlockHosting && info.hosting) addLearningScore(ip, now, learningVpnHostingPoints, "vpn-hosting", false);
if (learningModeEnabled) { int current = getLearningScore(ip, now);
if (vpnBlockProxy && info.proxy) addLearningScore(ip, now, learningVpnProxyPoints, "vpn-proxy", false); if (current >= learningScoreThreshold) {
if (vpnBlockHosting && info.hosting) addLearningScore(ip, now, learningVpnHostingPoints, "vpn-hosting", false);
int current = getLearningScore(ip, now);
if (current >= learningScoreThreshold) {
blockIp(ip, now);
logSecurityEvent("learning_threshold_block", ip, event.getConnection(), "reason=vpn, score=" + current);
recordLearningEvent("BLOCK " + ip + " reason=vpn score=" + current);
blockEvent(event);
}
} else {
blockIp(ip, now); blockIp(ip, now);
logSecurityEvent("vpn_block", ip, event.getConnection(), "mode=direct, proxy=" + info.proxy + ", hosting=" + info.hosting); logSecurityEvent("learning_threshold_block", ip, event.getConnection(), "reason=vpn, score=" + current);
recordLearningEvent("BLOCK " + ip + " reason=vpn score=" + current);
blockEvent(event); blockEvent(event);
} }
} else {
blockIp(ip, now);
logSecurityEvent("vpn_block", ip, event.getConnection(), "mode=direct, proxy=" + info.proxy + ", hosting=" + info.hosting);
blockEvent(event);
} }
} }
} else {
// Kein Cache-Treffer → HTTP-Request async ausführen
event.registerIntent(plugin);
final long nowFinal = now;
ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
try {
VpnCheckResult info = requestIpApi(ip);
if (info != null) {
VpnCacheEntry entry = new VpnCacheEntry();
entry.result = info;
entry.expiresAt = nowFinal + Math.max(1, vpnCacheMinutes) * 60_000L;
vpnCache.put(ip, entry);
boolean shouldBlock = (vpnBlockProxy && info.proxy) || (vpnBlockHosting && info.hosting);
if (shouldBlock) {
logSecurityEvent("vpn_detected", ip, event.getConnection(), "proxy=" + info.proxy + ", hosting=" + info.hosting);
if (learningModeEnabled) {
if (vpnBlockProxy && info.proxy) addLearningScore(ip, nowFinal, learningVpnProxyPoints, "vpn-proxy", false);
if (vpnBlockHosting && info.hosting) addLearningScore(ip, nowFinal, learningVpnHostingPoints, "vpn-hosting", false);
int current = getLearningScore(ip, nowFinal);
if (current >= learningScoreThreshold) {
blockIp(ip, nowFinal);
logSecurityEvent("learning_threshold_block", ip, event.getConnection(), "reason=vpn, score=" + current);
recordLearningEvent("BLOCK " + ip + " reason=vpn score=" + current);
blockEvent(event);
}
} else {
blockIp(ip, nowFinal);
logSecurityEvent("vpn_block", ip, event.getConnection(), "mode=direct, proxy=" + info.proxy + ", hosting=" + info.hosting);
blockEvent(event);
}
}
}
} finally {
event.completeIntent(plugin);
}
});
} }
} }
} }
@@ -768,6 +723,19 @@ public class AntiBotModule implements Module, Listener {
try { return Integer.parseInt(s == null ? "" : s.trim()); } catch (Exception ignored) { return fallback; } try { return Integer.parseInt(s == null ? "" : s.trim()); } catch (Exception ignored) { return fallback; }
} }
private VpnCheckResult getVpnInfo(String ip, long now) {
VpnCacheEntry cached = vpnCache.get(ip);
if (cached != null && cached.expiresAt > now) return cached.result;
VpnCheckResult fresh = requestIpApi(ip);
if (fresh != null) {
VpnCacheEntry entry = new VpnCacheEntry();
entry.result = fresh;
entry.expiresAt = now + Math.max(1, vpnCacheMinutes) * 60_000L;
vpnCache.put(ip, entry);
}
return fresh;
}
private VpnCheckResult requestIpApi(String ip) { private VpnCheckResult requestIpApi(String ip) {
HttpURLConnection conn = null; HttpURLConnection conn = null;
try { try {

View File

@@ -22,11 +22,8 @@ public class ChatLogger {
private final int retentionDays; private final int retentionDays;
private final AtomicInteger counter = new AtomicInteger(0); private final AtomicInteger counter = new AtomicInteger(0);
// ThreadLocal: SimpleDateFormat ist NICHT thread-sicher jeder Thread bekommt seine eigene Instanz. private static final SimpleDateFormat DATE_FMT = new SimpleDateFormat("yyyy-MM-dd");
private static final ThreadLocal<SimpleDateFormat> DATE_FMT = private static final SimpleDateFormat TIME_FMT = new SimpleDateFormat("HH:mm:ss");
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));
private static final ThreadLocal<SimpleDateFormat> TIME_FMT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("HH:mm:ss"));
public ChatLogger(File dataFolder, Logger logger, int retentionDays) { public ChatLogger(File dataFolder, Logger logger, int retentionDays) {
this.logDir = new File(dataFolder, "chatlogs"); this.logDir = new File(dataFolder, "chatlogs");
@@ -61,8 +58,8 @@ public class ChatLogger {
* @param message Nachrichtentext (Rohtext, ohne Farbcodes) * @param message Nachrichtentext (Rohtext, ohne Farbcodes)
*/ */
public void log(String msgId, String server, String channel, String player, String message) { public void log(String msgId, String server, String channel, String player, String message) {
String date = DATE_FMT.get().format(new Date()); String date = DATE_FMT.format(new Date());
String time = TIME_FMT.get().format(new Date()); String time = TIME_FMT.format(new Date());
// Minecraft-Farbcodes aus dem Log entfernen // Minecraft-Farbcodes aus dem Log entfernen
String cleanMsg = stripColor(message); String cleanMsg = stripColor(message);
@@ -122,7 +119,7 @@ public class ChatLogger {
* @return Liste der Logzeilen (\u00e4lteste zuerst) * @return Liste der Logzeilen (\u00e4lteste zuerst)
*/ */
public List<String> readLastLines(String playerFilter, int maxLines) { public List<String> readLastLines(String playerFilter, int maxLines) {
String date = DATE_FMT.get().format(new Date()); String date = DATE_FMT.format(new Date());
File logFile = new File(logDir, "chatlog_" + date + ".log"); File logFile = new File(logDir, "chatlog_" + date + ".log");
if (!logFile.exists()) return Collections.emptyList(); if (!logFile.exists()) return Collections.emptyList();

View File

@@ -31,9 +31,7 @@ public class ReportManager {
/** Z\u00e4hler f\u00fcr Report-IDs. Wird beim Laden synchronisiert. */ /** Z\u00e4hler f\u00fcr Report-IDs. Wird beim Laden synchronisiert. */
private final AtomicInteger idCounter = new AtomicInteger(0); private final AtomicInteger idCounter = new AtomicInteger(0);
// ThreadLocal: SimpleDateFormat ist NICHT thread-sicher. private static final SimpleDateFormat DATE_FMT = new SimpleDateFormat("dd.MM.yyyy HH:mm:ss");
private static final ThreadLocal<SimpleDateFormat> DATE_FMT =
ThreadLocal.withInitial(() -> new SimpleDateFormat("dd.MM.yyyy HH:mm:ss"));
// ===== Report-Datenklasse ===== // ===== Report-Datenklasse =====
@@ -50,7 +48,7 @@ public class ReportManager {
public String closedBy; // Name des schlie\u00dfenden Admins (oder leer) public String closedBy; // Name des schlie\u00dfenden Admins (oder leer)
public String getFormattedTime() { public String getFormattedTime() {
return DATE_FMT.get().format(new Date(timestamp)); return DATE_FMT.format(new Date(timestamp));
} }
} }

View File

@@ -91,11 +91,7 @@ public class ScoreboardModule implements Module, Listener {
private boolean nametagEnabled = true; private boolean nametagEnabled = true;
// Spieler, f\u00fcr die bereits ein Nametag-Team gesetzt wurde (Teamname = "afk_" + player.getName() abgek\u00fcrzt) // Spieler, f\u00fcr die bereits ein Nametag-Team gesetzt wurde (Teamname = "afk_" + player.getName() abgek\u00fcrzt)
// Pro Target: welche Viewer haben bereits ein CREATE-Packet bekommen. private final Set<UUID> nametagCreated = ConcurrentHashMap.newKeySet();
// Fix: verhindert REMOVE ohne CREATE → "Player is either on another team" Crash.
private final ConcurrentHashMap<UUID, Set<UUID>> nametagViewers = new ConcurrentHashMap<>();
// Dirty-Flag-Cache: letzter gesendeter Prefix pro Spieler (UUID → prefixStr)
private final ConcurrentHashMap<UUID, String> nametagLastPrefix = new ConcurrentHashMap<>();
private int updateInterval = 500; // Millisekunden private int updateInterval = 500; // Millisekunden
private int tickerSpeed = 1; private int tickerSpeed = 1;
private boolean rainbowEnabled = true; private boolean rainbowEnabled = true;
@@ -134,10 +130,8 @@ public class ScoreboardModule implements Module, Listener {
private ScheduledTask updateTask; private ScheduledTask updateTask;
private ScheduledTask titleTask; private ScheduledTask titleTask;
private ScheduledTask newsTask; private ScheduledTask newsTask;
// DateTimeFormatter ist thread-sicher (im Gegensatz zu SimpleDateFormat) private java.text.SimpleDateFormat sdf;
private java.time.format.DateTimeFormatter sdf; private java.text.SimpleDateFormat sdfDate;
private java.time.format.DateTimeFormatter sdfDate;
private java.time.ZoneId sdfZone = java.time.ZoneId.systemDefault();
private DecimalFormat df; private DecimalFormat df;
private Method sendPkt; private Method sendPkt;
private boolean ready = false; private boolean ready = false;
@@ -216,7 +210,6 @@ public class ScoreboardModule implements Module, Listener {
} }
created.clear(); createdAdmin.clear(); createdSupporter.clear(); tickerPos.clear(); rainbowIdx.clear(); created.clear(); createdAdmin.clear(); createdSupporter.clear(); tickerPos.clear(); rainbowIdx.clear();
hiddenPlayers.clear(); forceAdminView.clear(); forcePlayerView.clear(); forceSupporterView.clear(); newsPos.clear(); hiddenPlayers.clear(); forceAdminView.clear(); forcePlayerView.clear(); forceSupporterView.clear(); newsPos.clear();
nametagViewers.clear(); nametagLastPrefix.clear();
} }
@EventHandler @EventHandler
@@ -236,11 +229,7 @@ public class ScoreboardModule implements Module, Listener {
if (nametagEnabled) { if (nametagEnabled) {
ProxyServer.getInstance().getScheduler().schedule(plugin, () -> { ProxyServer.getInstance().getScheduler().schedule(plugin, () -> {
// Alle bestehenden Spieler → neuer Spieler bekommt ihre Nametags // Alle bestehenden Spieler → neuer Spieler bekommt ihre Nametags
// Viewer-Eintrag des neuen Spielers aus allen anderen Target-Sets löschen nametagCreated.remove(id); // Reset damit CREATE statt UPDATE gesendet wird
// damit sie beim nächsten updateNametag() CREATE (nicht UPDATE) bekommen.
nametagViewers.remove(id);
for (Set<UUID> vs : nametagViewers.values()) { vs.remove(id); }
nametagLastPrefix.remove(id);
for (ProxiedPlayer existing : ProxyServer.getInstance().getPlayers()) { for (ProxiedPlayer existing : ProxyServer.getInstance().getPlayers()) {
if (existing.isConnected()) updateNametag(existing); if (existing.isConnected()) updateNametag(existing);
} }
@@ -269,12 +258,8 @@ public class ScoreboardModule implements Module, Listener {
playerX.remove(id); playerY.remove(id); playerZ.remove(id); playerX.remove(id); playerY.remove(id); playerZ.remove(id);
playerWorld.remove(id); playerGamemode.remove(id); playerWorld.remove(id); playerGamemode.remove(id);
playerExp.remove(id); playerFood.remove(id); playerSpeed.remove(id); playerExp.remove(id); playerFood.remove(id); playerSpeed.remove(id);
joinTimes.remove(id); hiddenPlayers.remove(id); newsPos.remove(id); joinTimes.remove(id); hiddenPlayers.remove(id); forceAdminView.remove(id); forcePlayerView.remove(id); newsPos.remove(id);
ticketMyOpen.remove(id); // Memory-Leak-Fix: Ticket-Daten beim Logout bereinigen
// Nametag-Team f\u00fcr diesen Spieler bei allen anderen entfernen // Nametag-Team f\u00fcr diesen Spieler bei allen anderen entfernen
nametagLastPrefix.remove(id);
// Viewer-Einträge dieses Spielers aus allen anderen Target-Sets entfernen
for (Set<UUID> viewerSet : nametagViewers.values()) { viewerSet.remove(id); }
if (nametagEnabled) removeNametag(e.getPlayer()); if (nametagEnabled) removeNametag(e.getPlayer());
} }
@@ -357,9 +342,6 @@ public class ScoreboardModule implements Module, Listener {
&& (p.hasPermission(adminPermission) || forceAdminView.contains(id)); && (p.hasPermission(adminPermission) || forceAdminView.contains(id));
boolean isSupporter = !isAdmin && (forceSupporterView.contains(id) || (!forcePlayerView.contains(id) && p.hasPermission(supporterPermission))); boolean isSupporter = !isAdmin && (forceSupporterView.contains(id) || (!forcePlayerView.contains(id) && p.hasPermission(supporterPermission)));
Set<UUID> activeCreated = isAdmin ? createdAdmin : isSupporter ? createdSupporter : created; Set<UUID> activeCreated = isAdmin ? createdAdmin : isSupporter ? createdSupporter : created;
// BUG-FIX: UPDATE_TITLE (mode=2) darf nur gesendet werden wenn das Objective
// bereits mit CREATE (mode=0) angelegt wurde. Sonst: "Fehler im Netzwerkprotokoll".
if (!activeCreated.contains(id)) continue;
try { try {
int rIdx = (rainbowIdx.getOrDefault(id, 0) + 1) % 10000; int rIdx = (rainbowIdx.getOrDefault(id, 0) + 1) % 10000;
rainbowIdx.put(id, rIdx); rainbowIdx.put(id, rIdx);
@@ -402,42 +384,28 @@ public class ScoreboardModule implements Module, Listener {
/** /**
* Sendet ein Team-Packet an alle online Spieler, das den Prefix * Sendet ein Team-Packet an alle online Spieler, das den Prefix
* über dem Kopf des 'target'-Spielers setzt. * \u00fcber dem Kopf des 'target'-Spielers setzt.
* * AFK-Spieler bekommen §7[AFK] §r als Prefix, alle anderen ihren LuckPerms-Prefix.
* FIX: Pro-Viewer-Tracking via nametagViewers.
* Jeder Viewer bekommt beim ersten Mal CREATE (mode=0), danach UPDATE (mode=2).
* Nur wenn sich der Prefix geändert hat werden überhaupt Packets gesendet (Dirty-Flag).
* removeNametag() sendet REMOVE nur an Viewer die CREATE bekommen haben → kein Crash.
*/ */
private void updateNametag(ProxiedPlayer target) { private void updateNametag(ProxiedPlayer target) {
if (!ready || !target.isConnected()) return; if (!ready || !target.isConnected()) return;
try { try {
UUID id = target.getUniqueId(); boolean isAfk = Boolean.TRUE.equals(net.viper.status.StatusAPI.playerAfk.get(target.getUniqueId()));
boolean isAfk = Boolean.TRUE.equals(net.viper.status.StatusAPI.playerAfk.get(id));
String lpPrefix = getLpPrefix(target); String lpPrefix = getLpPrefix(target);
// Teamname: "nt_" + erste 13 Zeichen des Playernamens (max 16 Zeichen insgesamt)
String teamName = "nt_" + target.getName().substring(0, Math.min(13, target.getName().length())); String teamName = "nt_" + target.getName().substring(0, Math.min(13, target.getName().length()));
String prefixStr = isAfk ? "§7[AFK] §r" : (lpPrefix.isEmpty() ? "" : lpPrefix + "§r "); String prefixStr = isAfk ? "§7[AFK] §r" : (lpPrefix.isEmpty() ? "" : lpPrefix + "§r ");
String lastPrefix = nametagLastPrefix.get(id); // Packet an alle Online-Spieler senden (damit alle den ge\u00e4nderten Prefix sehen)
boolean prefixChanged = !prefixStr.equals(lastPrefix);
// Viewer-Set für diesen Target holen (oder neu anlegen)
Set<UUID> viewers = nametagViewers.computeIfAbsent(id, k -> ConcurrentHashMap.newKeySet());
// Nur senden wenn Prefix sich geändert hat ODER es neue Viewer gibt
for (ProxiedPlayer viewer : ProxyServer.getInstance().getPlayers()) { for (ProxiedPlayer viewer : ProxyServer.getInstance().getPlayers()) {
if (!viewer.isConnected()) continue; if (!viewer.isConnected()) continue;
UUID vid = viewer.getUniqueId();
boolean viewerIsNew = !viewers.contains(vid);
// Kein Update nötig wenn Prefix gleich und Viewer schon CREATE hatte
if (!prefixChanged && !viewerIsNew) continue;
try { try {
Team team = new Team(); Team team = new Team();
team.setName(teamName); team.setName(teamName);
team.setMode(viewerIsNew ? (byte) 0 : (byte) 2); // 0=CREATE, 2=UPDATE boolean firstTime = !nametagCreated.contains(target.getUniqueId());
team.setMode(firstTime ? (byte) 0 : (byte) 2); // 0=CREATE, 2=UPDATE
net.md_5.bungee.api.chat.TextComponent pfxComp = net.md_5.bungee.api.chat.TextComponent pfxComp =
new net.md_5.bungee.api.chat.TextComponent(""); new net.md_5.bungee.api.chat.TextComponent("");
for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(c(prefixStr))) for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(c(prefixStr)))
@@ -449,14 +417,13 @@ public class ScoreboardModule implements Module, Listener {
team.setCollisionRule(Either.right(CollisionRule.ALWAYS)); team.setCollisionRule(Either.right(CollisionRule.ALWAYS));
team.setColor(Optional.of(21)); // RESET team.setColor(Optional.of(21)); // RESET
team.setFriendlyFire((byte) 3); team.setFriendlyFire((byte) 3);
if (viewerIsNew) team.setPlayers(new String[]{ target.getName() }); if (firstTime) team.setPlayers(new String[]{ target.getName() });
sendPkt.invoke(viewer, team); sendPkt.invoke(viewer, team);
viewers.add(vid); // Viewer als "hat CREATE" markieren
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
nametagLastPrefix.put(id, prefixStr); nametagCreated.add(target.getUniqueId());
} catch (Exception e) { } catch (Exception e) {
plugin.getLogger().warning("[ScoreboardModule] Nametag-Fehler für " + target.getName() + ": " + e.getMessage()); plugin.getLogger().warning("[ScoreboardModule] Nametag-Fehler f\u00fcr " + target.getName() + ": " + e.getMessage());
} }
} }
@@ -464,25 +431,17 @@ public class ScoreboardModule implements Module, Listener {
* Entfernt das Nametag-Team beim Disconnect sauber vom Client aller Spieler. * Entfernt das Nametag-Team beim Disconnect sauber vom Client aller Spieler.
*/ */
private void removeNametag(ProxiedPlayer target) { private void removeNametag(ProxiedPlayer target) {
UUID id = target.getUniqueId();
String teamName = "nt_" + target.getName().substring(0, Math.min(13, target.getName().length())); String teamName = "nt_" + target.getName().substring(0, Math.min(13, target.getName().length()));
// REMOVE nur an Viewer senden die auch CREATE erhalten haben (per-Viewer-Tracking). for (ProxiedPlayer viewer : ProxyServer.getInstance().getPlayers()) {
// Sonst: "Player is either on another team or not on any team" → Client-Crash. if (!viewer.isConnected() || viewer.getUniqueId().equals(target.getUniqueId())) continue;
Set<UUID> viewers = nametagViewers.remove(id); try {
if (viewers != null) { Team team = new Team();
for (UUID vid : viewers) { team.setName(teamName);
ProxiedPlayer viewer = ProxyServer.getInstance().getPlayer(vid); team.setMode((byte) 1); // REMOVE
if (viewer == null || !viewer.isConnected()) continue; sendPkt.invoke(viewer, team);
if (vid.equals(id)) continue; // Den Target selbst überspringen } catch (Exception ignored) {}
try {
Team team = new Team();
team.setName(teamName);
team.setMode((byte) 1); // REMOVE
sendPkt.invoke(viewer, team);
} catch (Exception ignored) {}
}
} }
nametagLastPrefix.remove(id); nametagCreated.remove(target.getUniqueId());
} }
/** /**
@@ -561,8 +520,8 @@ public class ScoreboardModule implements Module, Listener {
String maxpl = rawLimit > 0 ? String.valueOf(rawLimit) : ""; String maxpl = rawLimit > 0 ? String.valueOf(rawLimit) : "";
String tps = isAdmin ? getTps(id) : ""; String tps = isAdmin ? getTps(id) : "";
String ram = isAdmin ? getRam() : ""; String ram = isAdmin ? getRam() : "";
String time = sdf.format(java.time.LocalDateTime.now(sdfZone)); String time = sdf.format(new Date());
String date = sdfDate.format(java.time.LocalDateTime.now(sdfZone)); String date = sdfDate.format(new Date());
String playtime = formatPlaytime(id); String playtime = formatPlaytime(id);
// Neue Placeholders // Neue Placeholders
String xCoord = String.valueOf(playerX.getOrDefault(id, 0)); String xCoord = String.valueOf(playerX.getOrDefault(id, 0));
@@ -1883,13 +1842,13 @@ public class ScoreboardModule implements Module, Listener {
decimalSeparator = g.apply("scoreboard.money_decimal_separator",","); decimalSeparator = g.apply("scoreboard.money_decimal_separator",",");
separator = g.apply("scoreboard.separator", "&8&m--------------------"); separator = g.apply("scoreboard.separator", "&8&m--------------------");
try { try {
sdfZone = java.time.ZoneId.of(timeZone); sdf = new java.text.SimpleDateFormat(timeFormat);
sdf = java.time.format.DateTimeFormatter.ofPattern(timeFormat).withZone(sdfZone); sdf.setTimeZone(java.util.TimeZone.getTimeZone(timeZone));
sdfDate = java.time.format.DateTimeFormatter.ofPattern(dateFormat).withZone(sdfZone); sdfDate = new java.text.SimpleDateFormat(dateFormat);
sdfDate.setTimeZone(java.util.TimeZone.getTimeZone(timeZone));
} catch (Exception e) { } catch (Exception e) {
sdfZone = java.time.ZoneId.systemDefault(); sdf = new java.text.SimpleDateFormat("HH:mm");
sdf = java.time.format.DateTimeFormatter.ofPattern("HH:mm").withZone(sdfZone); sdfDate = new java.text.SimpleDateFormat("dd.MM.yyyy");
sdfDate = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy").withZone(sdfZone);
} }
try { try {
df = new DecimalFormat(moneyFormat); df = new DecimalFormat(moneyFormat);

View File

@@ -1,8 +1,6 @@
package net.viper.status.stats; package net.viper.status.stats;
import java.io.*; import java.io.*;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
/** /**
* Fix #9: save() und load() sind jetzt synchronized um Race Conditions * Fix #9: save() und load() sind jetzt synchronized um Race Conditions
@@ -19,21 +17,12 @@ public class StatsStorage {
public void save(StatsManager manager) { public void save(StatsManager manager) {
synchronized (fileLock) { synchronized (fileLock) {
// Atomares Schreiben: erst in .tmp, dann umbenennen. try (BufferedWriter bw = new BufferedWriter(new FileWriter(file))) {
// Verhindert korrupte stats.dat bei Server-Absturz während des Schreibvorgangs.
File tmp = new File(file.getParentFile(), "stats.dat.tmp");
try (BufferedWriter bw = new BufferedWriter(new FileWriter(tmp))) {
for (PlayerStats ps : manager.all()) { for (PlayerStats ps : manager.all()) {
bw.write(ps.toLine()); bw.write(ps.toLine());
bw.newLine(); bw.newLine();
} }
bw.flush(); bw.flush();
} catch (IOException e) {
e.printStackTrace();
return; // Nicht umbenennen bei Fehler
}
try {
Files.move(tmp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); e.printStackTrace();
} }

View File

@@ -1,52 +1,18 @@
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Der Server läuft 24/7 also keine Hektik beim Spielen :) §8[§2Viper-Netzwerk§8] §7Der Server läuft 24/7 also keine Hektik beim Spielen :)
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Dies ist ein privater Server hier zählt der Zusammenhalt. §8[§2Viper-Netzwerk§8] §7Dies ist ein privater Server hier zählt der Zusammenhalt.
§8[%gradient:&b:&f:&b:&lTipp%§8] §7Wenn du denkst, du bist sicher… schau nochmal nach. Creeper machen keine Geräusche beim Tippen. §8[§dTipp§8] §7Wenn du denkst, du bist sicher… schau nochmal nach. Creeper machen keine Geräusche beim Tippen.
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Wähle einen Server, leg los der Rest ergibt sich. Oder explodiert. §8[§2Viper-Netzwerk§8] §7Wähle einen Server, leg los der Rest ergibt sich. Oder explodiert.
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Mehr Server. Mehr Blöcke. Mehr Unfälle. Willkommen! §8[§2Viper-Netzwerk§8] §7Mehr Server. Mehr Blöcke. Mehr Unfälle. Willkommen!
§8[%gradient:&b:&f:&b:&lTipp%§8] §7Halte eine Spitzhacke mit Glück bereit. Man weiß nie, wann das nächste Erz kommt. §8[§dTipp§8] §7Halte eine Spitzhacke mit Glück bereit. Man weiß nie, wann das nächste Erz kommt.
§8[%gradient:&b:&f:&b:&lTipp%§8] §7Dein Bett ist dein Ankerpunkt platziere es weise! §8[§dTipp§8] §7Mit §e/home§7 kannst du dich jederzeit nach Hause teleportieren.
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Das wichtigste Plugin? Du selbst. Spiel fair, sei kreativ! §8[§2Viper-Netzwerk§8] §7Das wichtigste Plugin? Du selbst. Spiel fair, sei kreativ!
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Redstone ist keine Magie aber fast. §8[§2Viper-Netzwerk§8] §7Redstone ist keine Magie aber fast.
§8[%gradient:&b:&f:&b:&lTipp%§8] §7Schilde sind cool. Besonders wenn Skelette zielen. §8[§dTipp§8] §7Schilde sind cool. Besonders wenn Skelette zielen.
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Wenn du in Lava fällst, bist du nicht der Erste. Nur der Nächste. §8[§2Viper-Netzwerk§8] §7Wenn du in Lava fällst, bist du nicht der Erste. Nur der Nächste.
§8[%gradient:&b:&f:&b:&lTipp%§8] §7Villager sind nicht dumm nur sehr… eigen. §8[§dTipp§8] §7Villager sind nicht dumm nur sehr… eigen.
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Bau groß, bau sicher oder bau eine Treppe zur Nachbarschaftsklage. §8[§2Viper-Netzwerk§8] §7Bau groß, bau sicher oder bau eine Treppe zur Nachbarschaftsklage.
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Gras wächst. Spieler auch. Gib jedem eine Chance! §8[§2Viper-Netzwerk§8] §7Gras wächst. Spieler auch. Gib jedem eine Chance!
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Ein Creeper ist keine Begrüßung. Es sei denn, du willst es spannend machen. §8[§2Viper-Netzwerk§8] §7Ein Creeper ist keine Begrüßung. Es sei denn, du willst es spannend machen.
§8[%gradient:&b:&f:&b:&lTipp%§8] §7Ein voller Magen ist halbe Miete. Farmen lohnt sich! §8[§dTipp§8] §7Ein voller Magen ist halbe Miete. Farmen lohnt sich!
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Wir haben keine Probleme nur Redstone-Schaltungen mit Charakter. §8[§2Viper-Netzwerk§8] §7Wir haben keine Probleme nur Redstone-Schaltungen mit Charakter.
§8[%gradient:&b:&f:&b:&lTipp%§8] §7Markiere dein Grundstück frühzeitig, bevor es jemand anderes tut! §8[§dTipp§8] §7Markiere dein Grundstück mit §e/p claim§7, bevor es jemand anderes tut!
§8[%gradient:&b:&f:&b:&lLobby%§8] §7Hier beginnt alles such dir deinen Server aus und leg los!
§8[%gradient:&b:&f:&b:&lLobby%§8] §7Die Lobby ist kein Ziel, sondern der Start. Oder ein Treffpunkt. Oder beides.
§8[%gradient:&b:&f:&b:&lLobby%§8] §7Noch unentschlossen? Einfach mal reinschauen auf allen Servern!
§8[%gradient:&b:&f:&b:&lCitybuild%§8] §7Auf Citybuild baust du deine eigene Stadt Block für Block.
§8[%gradient:&b:&f:&b:&lCitybuild%§8] §7Grundstück sichern nicht vergessen sonst baut jemand anderes drauf!
§8[%gradient:&b:&f:&b:&lCitybuild%§8] §7Gute Nachbarn bauen gute Städte. Oder zumindest hübschere.
§8[%gradient:&b:&f:&b:&lCitybuild%§8] §7Je größer die Stadt, desto mehr Redstone geht schief. Viel Erfolg!
§8[%gradient:&b:&f:&b:&lTipp%§8] §7Auf Citybuild kannst du dein Grundstück mit Freunden teilen baut zusammen mehr!
§8[%gradient:&b:&f:&b:&lFreebuild%§8] §7Auf Freebuild gibt es keine Grenzen außer deiner Fantasie.
§8[%gradient:&b:&f:&b:&lFreebuild%§8] §7Bauen ohne Regeln. Na ja, fast. Sei trotzdem nett zu deinen Nachbarn.
§8[%gradient:&b:&f:&b:&lFreebuild%§8] §7Riesige Projekte, verrückte Ideen Freebuild macht's möglich.
§8[%gradient:&b:&f:&b:&lTipp%§8] §7Auf Freebuild gilt: Erst messen, dann bauen. Oder erst bauen und dann bereuen.
§8[%gradient:&b:&f:&b:&lSurvival%§8] §7Auf Survival zählt jeder Block den du abbaust und den, der auf dich fällt.
§8[%gradient:&b:&f:&b:&lSurvival%§8] §7Tag 1: Holz hacken. Tag 2: Höhle finden. Tag 3: Creeper ärgert dich. Klassiker.
§8[%gradient:&b:&f:&b:&lSurvival%§8] §7Die Nether-Festung wartet. Bring Feuerresistenz mit und Mut.
§8[%gradient:&b:&f:&b:&lSurvival%§8] §7Ein Bett in der Nähe spart lange Laufwege nach dem Tod. Spar dir den Fußmarsch!
§8[%gradient:&b:&f:&b:&lTipp%§8] §7Auf Survival lohnt sich ein Außenposten Ressourcen gibt's nie genug.
§8[%gradient:&b:&f:&b:&lTipp%§8] §7Verzaubere deine Ausrüstung so früh wie möglich. Schutzverzauberung rettet Leben!
§8[%gradient:&b:&f:&b:&lSkyblock%§8] §7Auf Skyblock fängst du mit einer Insel an. Was du draus machst, liegt bei dir.
§8[%gradient:&b:&f:&b:&lSkyblock%§8] §7Wasser + Lava = Kobblestone. Das Fundament jeder Skyblock-Karriere.
§8[%gradient:&b:&f:&b:&lSkyblock%§8] §7Deine Insel, deine Regeln aber Creeper kennen keine Regeln. Und keine Ränder.
§8[%gradient:&b:&f:&b:&lSkyblock%§8] §7Tipp vom Profi: Nie rückwärts auf einer Skyblock-Insel laufen.
§8[%gradient:&b:&f:&b:&lTipp%§8] §7Auf Skyblock ist ein Kompostierer Gold wert Essen ist alles!
§8[%gradient:&b:&f:&b:&lTipp%§8] §7Baue deine Skyblock-Insel nach außen aus, bevor du in die Höhe gehst.
§8[%gradient:&b:&f:&b:&lMinigames%§8] §7Auf Minigames zählt Schnelligkeit, Köpfchen und manchmal einfach Glück.
§8[%gradient:&b:&f:&b:&lMinigames%§8] §7Gewonnen oder verloren beim nächsten Spiel ist alles wieder offen!
§8[%gradient:&b:&f:&b:&lMinigames%§8] §7Minigames sind der perfekte Ort, um Freunde zu finden. Oder Rivalen.
§8[%gradient:&b:&f:&b:&lMinigames%§8] §7Kurze Runden, viel Spaß einfach Minigames starten und loslegen!
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Sechs Server, ein Netzwerk such dir deinen Lieblingsplatz!
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Egal ob Bauen, Überleben oder Kämpfen hier ist für jeden was dabei.
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Neue Spieler sind immer willkommen. Zeig ihnen, wie es geht!
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Respekt kostet nichts macht aber den Server für alle besser.
§8[%gradient:&b:&f:&b:&lViper-Network%§8] §7Probleme oder Fragen? Wende dich ans Team wir helfen gerne!
§8[%gradient:&b:&f:&b:&lTipp%§8] §7Du kannst zwischen allen Servern wechseln einfach den Kompass nutzen!