From 170efaab6f1ae4243163d208a16eb90eb06e7bbd Mon Sep 17 00:00:00 2001 From: Git Manager GUI Date: Sun, 31 May 2026 12:41:41 +0200 Subject: [PATCH] Upload folder via GUI - src --- .../main/java/net/viper/status/StatusAPI.java | 121 +++++++++++++- .../java/net/viper/status/UpdateChecker.java | 59 +++---- .../AutoMessage/AutoMessageModule.java | 133 ++++++++++++++- .../viper/status/modules/afk/AfkModule.java | 152 ++++++++++++++---- .../status/modules/antibot/AntiBotModule.java | 90 +++++++---- .../viper/status/modules/chat/ChatLogger.java | 13 +- .../status/modules/chat/ReportManager.java | 6 +- .../modules/scoreboard/ScoreboardModule.java | 107 ++++++++---- .../net/viper/status/stats/StatsStorage.java | 13 +- StatusAPI/src/main/resources/messages.txt | 70 +++++--- 10 files changed, 599 insertions(+), 165 deletions(-) diff --git a/StatusAPI/src/main/java/net/viper/status/StatusAPI.java b/StatusAPI/src/main/java/net/viper/status/StatusAPI.java index 29fdcab..c06fee2 100644 --- a/StatusAPI/src/main/java/net/viper/status/StatusAPI.java +++ b/StatusAPI/src/main/java/net/viper/status/StatusAPI.java @@ -3,7 +3,17 @@ package net.viper.status; import net.md_5.bungee.api.ProxyServer; import net.md_5.bungee.api.config.ListenerInfo; 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.event.EventHandler; import net.viper.status.module.ModuleManager; import net.viper.status.modules.economy.EconomyModule; import net.viper.status.modules.tablist.TablistModule; @@ -48,7 +58,7 @@ import net.md_5.bungee.api.scheduler.ScheduledTask; /** * StatusAPI - zentraler BungeeCord HTTP-Status- und Broadcast-Endpunkt */ -public class StatusAPI extends Plugin implements Runnable { +public class StatusAPI extends Plugin implements Runnable, Listener { // Welt pro Spieler (UUID -> Weltname), wird von StatusAPIBridge gepusht public static final ConcurrentHashMap playerWorlds = new ConcurrentHashMap<>(); @@ -168,6 +178,9 @@ public class StatusAPI extends Plugin implements Runnable { 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) try { net.viper.status.modules.scoreboard.ScoreboardModule sbMod = @@ -291,17 +304,107 @@ public class StatusAPI extends Plugin implements Runnable { updateChecker.checkNow(); String currentVersion = getDescription() != null ? getDescription().getVersion() : "0.0.0"; if (updateChecker.isUpdateAvailable(currentVersion)) { - String newVersion = updateChecker.getLatestVersion(); - getLogger().warning("----------------------------------------"); - getLogger().warning("Neue Version verf\u00fcgbar: " + newVersion); - getLogger().warning("Download: " + updateChecker.getLatestUrl()); - getLogger().warning("----------------------------------------"); + String newVersion = updateChecker.getLatestVersion(); + String releaseUrl = updateChecker.getLatestUrl(); + boolean preRelease = updateChecker.isLatestPreRelease(); + // Konsolen-Log + getLogger().warning((preRelease ? "[Pre-Release] " : "") + "Update verfuegbar: " + newVersion + " -> " + releaseUrl); + // Ingame-Meldung an alle Spieler mit statusapi.update.notify + broadcastUpdateNotice(newVersion, releaseUrl, preRelease); } } catch (Exception e) { 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 --- @Override public void run() { @@ -1118,13 +1221,19 @@ public class StatusAPI extends Plugin implements Runnable { /** * 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 headers) throws IOException { int contentLength = 0; if (headers.containsKey("content-length")) { try { contentLength = Integer.parseInt(headers.get("content-length")); } catch (NumberFormatException ignored) {} } 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]; int read = 0; while (read < contentLength) { diff --git a/StatusAPI/src/main/java/net/viper/status/UpdateChecker.java b/StatusAPI/src/main/java/net/viper/status/UpdateChecker.java index 00c6c6f..cd4c491 100644 --- a/StatusAPI/src/main/java/net/viper/status/UpdateChecker.java +++ b/StatusAPI/src/main/java/net/viper/status/UpdateChecker.java @@ -19,12 +19,13 @@ public class UpdateChecker { // 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 volatile String latestVersion = ""; - private volatile String latestUrl = ""; + private volatile String latestVersion = ""; + private volatile String latestUrl = ""; + private volatile boolean latestPreRelease = false; - private static final Pattern ASSET_NAME_PATTERN = Pattern.compile("\"name\"\\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 TAG_NAME_PATTERN = Pattern.compile("\"tag_name\"\\s*:\\s*\"([^\"]+)\"", Pattern.CASE_INSENSITIVE); + private static final Pattern TAG_NAME_PATTERN = Pattern.compile("\"tag_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 PRERELEASE_PATTERN = Pattern.compile("\"prerelease\"\\s*:\\s*(true|false)", Pattern.CASE_INSENSITIVE); public UpdateChecker(Plugin plugin, String currentVersion, int intervalHours) { this.plugin = plugin; @@ -71,39 +72,23 @@ public class UpdateChecker { foundVersion = foundVersion.substring(1); } - String foundUrl = null; - - // Wir suchen im gesamten Body nach der JAR-Datei "StatusAPI.jar" - // Da das neueste Release zuerst kommt, brechen wir ab, sobald wir eine passende JAR finden - Matcher nameMatcher = ASSET_NAME_PATTERN.matcher(body); - Matcher downloadMatcher = DOWNLOAD_PATTERN.matcher(body); - - java.util.List names = new java.util.ArrayList<>(); - java.util.List urls = new java.util.ArrayList<>(); - - while (nameMatcher.find()) { - names.add(nameMatcher.group(1)); - } - while (downloadMatcher.find()) { - urls.add(downloadMatcher.group(1)); + // Release-Seiten-URL fuer die Ingame-Meldung + String foundUrl = "https://git.viper.ipv64.net/M_Viper/StatusAPI/releases"; + Matcher urlM = HTML_URL_PATTERN.matcher(body); + if (urlM.find()) { + foundUrl = urlM.group(1).trim(); } - int pairs = Math.min(names.size(), urls.size()); - for (int i = 0; i < pairs; i++) { - String name = names.get(i).trim(); - String url = urls.get(i); - if ("StatusAPI.jar".equalsIgnoreCase(name)) { - foundUrl = url; - break; // Erste (also neueste) passende JAR nehmen - } + // prerelease-Flag aus dem ersten Release-Block lesen + boolean foundPreRelease = false; + Matcher preM = PRERELEASE_PATTERN.matcher(body); + if (preM.find()) { + foundPreRelease = Boolean.parseBoolean(preM.group(1).trim()); } - if (foundUrl == null) { - plugin.getLogger().warning("Keine StatusAPI.jar im neuesten Release gefunden."); - return; - } - latestVersion = foundVersion; - latestUrl = foundUrl; + latestVersion = foundVersion; + latestUrl = foundUrl; + latestPreRelease = foundPreRelease; } catch (Exception e) { plugin.getLogger().log(Level.SEVERE, "Fehler beim Update-Check: " + e.getMessage(), e); @@ -118,9 +103,15 @@ public class UpdateChecker { return latestUrl != null ? latestUrl : ""; } + public boolean isLatestPreRelease() { + return latestPreRelease; + } + public boolean isUpdateAvailable(String currentVer) { String lv = getLatestVersion(); 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; } diff --git a/StatusAPI/src/main/java/net/viper/status/modules/AutoMessage/AutoMessageModule.java b/StatusAPI/src/main/java/net/viper/status/modules/AutoMessage/AutoMessageModule.java index 29b9636..2f396b3 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/AutoMessage/AutoMessageModule.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/AutoMessage/AutoMessageModule.java @@ -3,6 +3,7 @@ package net.viper.status.modules.AutoMessage; import net.md_5.bungee.api.ChatColor; import net.md_5.bungee.api.CommandSender; 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.plugin.Command; import net.md_5.bungee.api.plugin.Plugin; @@ -13,6 +14,7 @@ import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.ArrayList; import java.util.List; import java.util.Properties; import java.util.concurrent.TimeUnit; @@ -145,12 +147,13 @@ public class AutoMessageModule implements Module { if (idx >= messages.size()) idx = 0; String raw = messages.get(idx); - String prefixPart = prefix.isEmpty() ? "" : ChatColor.translateAlternateColorCodes('&', prefix) + " "; - // Fix: §-Codes direkt \u00fcbersetzen (messages.txt nutzt §-Codes) - String text = prefixPart + ChatColor.translateAlternateColorCodes('&', - raw.replace("\u00a7", "&").replace("§", "&")); + String prefixPart = prefix.isEmpty() ? "" : ChatColor.translateAlternateColorCodes('&', applyGradients(prefix)) + " "; + // Gradient zuerst auflösen, dann §/&-Codes übersetzen + String normalized = raw.replace("\u00a7", "&").replace("§", "&"); + String text = prefixPart + ChatColor.translateAlternateColorCodes('&', applyGradients(normalized)); - ProxyServer.getInstance().broadcast(new TextComponent(text)); + BaseComponent[] components = TextComponent.fromLegacyText(text); + ProxyServer.getInstance().broadcast(components); }, intervalSeconds, intervalSeconds, TimeUnit.SECONDS).getId(); } @@ -160,4 +163,122 @@ public class AutoMessageModule implements Module { 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 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 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; + } + } +} \ No newline at end of file diff --git a/StatusAPI/src/main/java/net/viper/status/modules/afk/AfkModule.java b/StatusAPI/src/main/java/net/viper/status/modules/afk/AfkModule.java index 504458e..52fcb62 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/afk/AfkModule.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/afk/AfkModule.java @@ -20,6 +20,7 @@ import net.viper.status.module.Module; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.TimeUnit; +import java.util.logging.Level; /** * AfkModule – /afk Befehl + automatische AFK-Erkennung nach Inaktivit\u00e4t. @@ -91,6 +92,8 @@ public class AfkModule implements Module, Listener { if (!enabled) { plugin.getLogger().info("[AfkModule] Deaktiviert."); return; } ProxyServer.getInstance().getPluginManager().registerListener(plugin, this); + // Plugin-Message-Channel registrieren (BungeeCord → Spigot) + ProxyServer.getInstance().registerChannel("statusapi:afk"); ProxyServer.getInstance().getPluginManager().registerCommand(plugin, new Command("afk") { @Override @@ -122,6 +125,7 @@ public class AfkModule implements Module, Listener { StatusAPI.playerAfk.clear(); lastActivity.clear(); INSTANCE = null; + try { ProxyServer.getInstance().unregisterChannel("statusapi:afk"); } catch (Exception ignored) {} } // ── Events ─────────────────────────────────────────────────────────────── @@ -131,7 +135,9 @@ public class AfkModule implements Module, Listener { if (!(e.getSender() instanceof ProxiedPlayer)) return; ProxiedPlayer p = (ProxiedPlayer) e.getSender(); recordActivity(p.getUniqueId()); - if (!e.getMessage().toLowerCase().startsWith("/afk") && isAfk(p.getUniqueId())) + String msg = e.getMessage(); + if (msg == null) return; + if (!msg.toLowerCase(Locale.ROOT).startsWith("/afk") && isAfk(p.getUniqueId())) setAfk(p, false); } @@ -191,23 +197,56 @@ public class AfkModule implements Module, Listener { } else { StatusAPI.playerAfk.remove(id); lastActivity.put(id, System.currentTimeMillis()); - TitlePair pair = activePair.get(id); + TitlePair pair = activePair.remove(id); stopTitleTask(id); clearTitle(p); 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 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() { long now = System.currentTimeMillis(); long thresholdMs = idleSeconds * 1000L; for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { - if (!p.isConnected()) continue; - UUID id = p.getUniqueId(); - if (p.hasPermission(bypassPerm) || isAfk(id)) continue; - Long last = lastActivity.get(id); - if (last == null) { lastActivity.put(id, now); continue; } - if (now - last >= thresholdMs) setAfk(p, true); + try { + if (!p.isConnected()) continue; + UUID id = p.getUniqueId(); + if (p.hasPermission(bypassPerm) || isAfk(id)) continue; + Long last = lastActivity.get(id); + if (last == null) { lastActivity.put(id, now); continue; } + if (now - last >= thresholdMs) setAfk(p, true); + } catch (Exception ex) { + plugin.getLogger().log(Level.FINE, "[AfkModule] Fehler in checkIdle fuer Spieler.", ex); + } } } @@ -224,17 +263,29 @@ public class AfkModule implements Module, Listener { TitlePair pair = titlePairs.get(random.nextInt(titlePairs.size())); activePair.put(id, pair); activeTitleEntry.put(id, pair.set); - sendTitleEntry(p, pair.set); + if (!sendTitleEntry(p, pair.set, true)) { // firstSend=true → TitleTimes mitsenden + stopTitleTask(id); + return; + } - long intervalMs = Math.max(500, (titleStay - 20) * 50L); + // Nicht zu aggressiv senden, um Verbindungsprobleme bei langen AFK-Phasen zu vermeiden. + long intervalMs = Math.max(1500, (titleStay - 20) * 50L); ScheduledTask task = ProxyServer.getInstance().getScheduler().schedule(plugin, () -> { - ProxiedPlayer online = ProxyServer.getInstance().getPlayer(id); - if (online == null || !online.isConnected() || !isAfk(id)) { + try { + ProxiedPlayer online = ProxyServer.getInstance().getPlayer(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); - return; } - String[] current = activeTitleEntry.get(id); - if (current != null) sendTitleEntry(online, current); }, intervalMs, intervalMs, TimeUnit.MILLISECONDS); titleTasks.put(id, task); } @@ -243,10 +294,15 @@ public class AfkModule implements Module, Listener { ScheduledTask old = titleTasks.remove(id); if (old != null) old.cancel(); activeTitleEntry.remove(id); - // activePair bleibt bis setAfk(false) es ausliest, danach: + // BUGFIX: activePair hier NICHT entfernen wenn der Spieler noch AFK ist – + // 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. */ private void clearTitle(ProxiedPlayer p) { try { @@ -259,20 +315,37 @@ public class AfkModule implements Module, Listener { * Sendet Title + Subtitle als raw Packets (wie ScoreboardModule) – * dadurch werden Hex-Farben korrekt \u00fcbertragen, ohne durch TextComponent.fromArray() zu laufen. */ - private void sendTitleEntry(ProxiedPlayer p, String[] entry) { - String titleRaw = ChatColor.translateAlternateColorCodes('&', applyGradients(entry[0])); - String subtitleRaw = entry.length > 1 ? ChatColor.translateAlternateColorCodes('&', applyGradients(entry[1])) : ""; + /** + * Sendet Title + Subtitle an den Spieler. + * @param firstSend true = TitleTimes-Packet mitsenden (erster Aufruf). + * 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 { if (sendPkt == null) throw new IllegalStateException("sendPkt not initialized"); - // Times zuerst senden - net.md_5.bungee.protocol.packet.TitleTimes times = new net.md_5.bungee.protocol.packet.TitleTimes(); - times.setFadeIn(titleFadeIn); - times.setStay(titleStay); - times.setFadeOut(titleFadeOut); - sendPkt.invoke(p, times); + // TitleTimes NUR beim ersten Senden schicken. + // Wird das Packet bei jedem Repeat-Tick gesendet, startet der Client + // jedes Mal eine neue fade-in-Animation → sichtbares Blinken. + if (firstSend) { + net.md_5.bungee.protocol.packet.TitleTimes times = new net.md_5.bungee.protocol.packet.TitleTimes(); + times.setFadeIn(titleFadeIn); + times.setStay(titleStay); + times.setFadeOut(titleFadeOut); + sendPkt.invoke(p, times); + } - // Title-Packet (Action = TITLE, ordinal 0) + // Title-Packet 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.setText(mergeComponents(buildComponents(titleRaw))); @@ -283,20 +356,37 @@ public class AfkModule implements Module, Listener { subPkt.setText(mergeComponents(buildComponents(subtitleRaw))); sendPkt.invoke(p, subPkt); + return true; + } catch (Exception e) { // Fallback auf Title-API try { Title title = ProxyServer.getInstance().createTitle(); title.title(buildComponents(titleRaw)); title.subTitle(buildComponents(subtitleRaw)); - title.fadeIn(titleFadeIn); - title.stay(titleStay); - title.fadeOut(titleFadeOut); + if (firstSend) { + title.fadeIn(titleFadeIn); + title.stay(titleStay); + title.fadeOut(titleFadeOut); + } else { + // Kein Fade → bleibt stabil stehen ohne Blink-Effekt + title.fadeIn(0); + title.stay(titleStay); + title.fadeOut(0); + } p.sendTitle(title); - } catch (Exception ignored) {} + return true; + } 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). */ private static net.md_5.bungee.api.chat.BaseComponent mergeComponents(BaseComponent[] parts) { TextComponent root = new TextComponent(""); diff --git a/StatusAPI/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java b/StatusAPI/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java index 0a1ce97..f7b6c55 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java @@ -246,28 +246,73 @@ 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) { - VpnCheckResult info = getVpnInfo(ip, now); - if (info != null) { - 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, now, learningVpnProxyPoints, "vpn-proxy", false); - if (vpnBlockHosting && info.hosting) addLearningScore(ip, now, learningVpnHostingPoints, "vpn-hosting", false); - int current = getLearningScore(ip, now); - if (current >= learningScoreThreshold) { + // Cache-Treffer: sofort prüfen, kein async nötig + VpnCacheEntry cached = vpnCache.get(ip); + if (cached != null && cached.expiresAt > now) { + VpnCheckResult info = cached.result; + if (info != null) { + 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, now, learningVpnProxyPoints, "vpn-proxy", false); + 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); - logSecurityEvent("learning_threshold_block", ip, event.getConnection(), "reason=vpn, score=" + current); - recordLearningEvent("BLOCK " + ip + " reason=vpn score=" + current); + logSecurityEvent("vpn_block", ip, event.getConnection(), "mode=direct, proxy=" + info.proxy + ", hosting=" + info.hosting); 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); + } + }); } } } @@ -723,19 +768,6 @@ public class AntiBotModule implements Module, Listener { 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) { HttpURLConnection conn = null; try { diff --git a/StatusAPI/src/main/java/net/viper/status/modules/chat/ChatLogger.java b/StatusAPI/src/main/java/net/viper/status/modules/chat/ChatLogger.java index 70e2e6f..a943558 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/chat/ChatLogger.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/chat/ChatLogger.java @@ -22,8 +22,11 @@ public class ChatLogger { private final int retentionDays; private final AtomicInteger counter = new AtomicInteger(0); - private static final SimpleDateFormat DATE_FMT = new SimpleDateFormat("yyyy-MM-dd"); - private static final SimpleDateFormat TIME_FMT = new SimpleDateFormat("HH:mm:ss"); + // ThreadLocal: SimpleDateFormat ist NICHT thread-sicher – jeder Thread bekommt seine eigene Instanz. + private static final ThreadLocal DATE_FMT = + ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd")); + private static final ThreadLocal TIME_FMT = + ThreadLocal.withInitial(() -> new SimpleDateFormat("HH:mm:ss")); public ChatLogger(File dataFolder, Logger logger, int retentionDays) { this.logDir = new File(dataFolder, "chatlogs"); @@ -58,8 +61,8 @@ public class ChatLogger { * @param message Nachrichtentext (Rohtext, ohne Farbcodes) */ public void log(String msgId, String server, String channel, String player, String message) { - String date = DATE_FMT.format(new Date()); - String time = TIME_FMT.format(new Date()); + String date = DATE_FMT.get().format(new Date()); + String time = TIME_FMT.get().format(new Date()); // Minecraft-Farbcodes aus dem Log entfernen String cleanMsg = stripColor(message); @@ -119,7 +122,7 @@ public class ChatLogger { * @return Liste der Logzeilen (\u00e4lteste zuerst) */ public List readLastLines(String playerFilter, int maxLines) { - String date = DATE_FMT.format(new Date()); + String date = DATE_FMT.get().format(new Date()); File logFile = new File(logDir, "chatlog_" + date + ".log"); if (!logFile.exists()) return Collections.emptyList(); diff --git a/StatusAPI/src/main/java/net/viper/status/modules/chat/ReportManager.java b/StatusAPI/src/main/java/net/viper/status/modules/chat/ReportManager.java index 73be0e3..8b31815 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/chat/ReportManager.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/chat/ReportManager.java @@ -31,7 +31,9 @@ public class ReportManager { /** Z\u00e4hler f\u00fcr Report-IDs. Wird beim Laden synchronisiert. */ private final AtomicInteger idCounter = new AtomicInteger(0); - private static final SimpleDateFormat DATE_FMT = new SimpleDateFormat("dd.MM.yyyy HH:mm:ss"); + // ThreadLocal: SimpleDateFormat ist NICHT thread-sicher. + private static final ThreadLocal DATE_FMT = + ThreadLocal.withInitial(() -> new SimpleDateFormat("dd.MM.yyyy HH:mm:ss")); // ===== Report-Datenklasse ===== @@ -48,7 +50,7 @@ public class ReportManager { public String closedBy; // Name des schlie\u00dfenden Admins (oder leer) public String getFormattedTime() { - return DATE_FMT.format(new Date(timestamp)); + return DATE_FMT.get().format(new Date(timestamp)); } } diff --git a/StatusAPI/src/main/java/net/viper/status/modules/scoreboard/ScoreboardModule.java b/StatusAPI/src/main/java/net/viper/status/modules/scoreboard/ScoreboardModule.java index ba7552f..adcd5d0 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/scoreboard/ScoreboardModule.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/scoreboard/ScoreboardModule.java @@ -91,7 +91,11 @@ public class ScoreboardModule implements Module, Listener { private boolean nametagEnabled = true; // Spieler, f\u00fcr die bereits ein Nametag-Team gesetzt wurde (Teamname = "afk_" + player.getName() abgek\u00fcrzt) - private final Set nametagCreated = ConcurrentHashMap.newKeySet(); + // Pro Target: welche Viewer haben bereits ein CREATE-Packet bekommen. + // Fix: verhindert REMOVE ohne CREATE → "Player is either on another team" Crash. + private final ConcurrentHashMap> nametagViewers = new ConcurrentHashMap<>(); + // Dirty-Flag-Cache: letzter gesendeter Prefix pro Spieler (UUID → prefixStr) + private final ConcurrentHashMap nametagLastPrefix = new ConcurrentHashMap<>(); private int updateInterval = 500; // Millisekunden private int tickerSpeed = 1; private boolean rainbowEnabled = true; @@ -130,8 +134,10 @@ public class ScoreboardModule implements Module, Listener { private ScheduledTask updateTask; private ScheduledTask titleTask; private ScheduledTask newsTask; - private java.text.SimpleDateFormat sdf; - private java.text.SimpleDateFormat sdfDate; + // DateTimeFormatter ist thread-sicher (im Gegensatz zu SimpleDateFormat) + private java.time.format.DateTimeFormatter sdf; + private java.time.format.DateTimeFormatter sdfDate; + private java.time.ZoneId sdfZone = java.time.ZoneId.systemDefault(); private DecimalFormat df; private Method sendPkt; private boolean ready = false; @@ -210,6 +216,7 @@ public class ScoreboardModule implements Module, Listener { } created.clear(); createdAdmin.clear(); createdSupporter.clear(); tickerPos.clear(); rainbowIdx.clear(); hiddenPlayers.clear(); forceAdminView.clear(); forcePlayerView.clear(); forceSupporterView.clear(); newsPos.clear(); + nametagViewers.clear(); nametagLastPrefix.clear(); } @EventHandler @@ -229,7 +236,11 @@ public class ScoreboardModule implements Module, Listener { if (nametagEnabled) { ProxyServer.getInstance().getScheduler().schedule(plugin, () -> { // Alle bestehenden Spieler → neuer Spieler bekommt ihre Nametags - nametagCreated.remove(id); // Reset damit CREATE statt UPDATE gesendet wird + // Viewer-Eintrag des neuen Spielers aus allen anderen Target-Sets löschen + // damit sie beim nächsten updateNametag() CREATE (nicht UPDATE) bekommen. + nametagViewers.remove(id); + for (Set vs : nametagViewers.values()) { vs.remove(id); } + nametagLastPrefix.remove(id); for (ProxiedPlayer existing : ProxyServer.getInstance().getPlayers()) { if (existing.isConnected()) updateNametag(existing); } @@ -258,8 +269,12 @@ public class ScoreboardModule implements Module, Listener { playerX.remove(id); playerY.remove(id); playerZ.remove(id); playerWorld.remove(id); playerGamemode.remove(id); playerExp.remove(id); playerFood.remove(id); playerSpeed.remove(id); - joinTimes.remove(id); hiddenPlayers.remove(id); forceAdminView.remove(id); forcePlayerView.remove(id); newsPos.remove(id); + joinTimes.remove(id); hiddenPlayers.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 + nametagLastPrefix.remove(id); + // Viewer-Einträge dieses Spielers aus allen anderen Target-Sets entfernen + for (Set viewerSet : nametagViewers.values()) { viewerSet.remove(id); } if (nametagEnabled) removeNametag(e.getPlayer()); } @@ -342,6 +357,9 @@ public class ScoreboardModule implements Module, Listener { && (p.hasPermission(adminPermission) || forceAdminView.contains(id)); boolean isSupporter = !isAdmin && (forceSupporterView.contains(id) || (!forcePlayerView.contains(id) && p.hasPermission(supporterPermission))); Set 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 { int rIdx = (rainbowIdx.getOrDefault(id, 0) + 1) % 10000; rainbowIdx.put(id, rIdx); @@ -384,28 +402,42 @@ public class ScoreboardModule implements Module, Listener { /** * Sendet ein Team-Packet an alle online Spieler, das den Prefix - * \u00fcber dem Kopf des 'target'-Spielers setzt. - * AFK-Spieler bekommen §7[AFK] §r als Prefix, alle anderen ihren LuckPerms-Prefix. + * über dem Kopf des 'target'-Spielers setzt. + * + * 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) { if (!ready || !target.isConnected()) return; try { - boolean isAfk = Boolean.TRUE.equals(net.viper.status.StatusAPI.playerAfk.get(target.getUniqueId())); + UUID id = target.getUniqueId(); + boolean isAfk = Boolean.TRUE.equals(net.viper.status.StatusAPI.playerAfk.get(id)); 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 prefixStr = isAfk ? "§7[AFK] §r" : (lpPrefix.isEmpty() ? "" : lpPrefix + "§r "); - // Packet an alle Online-Spieler senden (damit alle den ge\u00e4nderten Prefix sehen) + String lastPrefix = nametagLastPrefix.get(id); + boolean prefixChanged = !prefixStr.equals(lastPrefix); + + // Viewer-Set für diesen Target holen (oder neu anlegen) + Set 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()) { 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 { Team team = new Team(); team.setName(teamName); - boolean firstTime = !nametagCreated.contains(target.getUniqueId()); - team.setMode(firstTime ? (byte) 0 : (byte) 2); // 0=CREATE, 2=UPDATE + team.setMode(viewerIsNew ? (byte) 0 : (byte) 2); // 0=CREATE, 2=UPDATE net.md_5.bungee.api.chat.TextComponent pfxComp = new net.md_5.bungee.api.chat.TextComponent(""); for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(c(prefixStr))) @@ -417,13 +449,14 @@ public class ScoreboardModule implements Module, Listener { team.setCollisionRule(Either.right(CollisionRule.ALWAYS)); team.setColor(Optional.of(21)); // RESET team.setFriendlyFire((byte) 3); - if (firstTime) team.setPlayers(new String[]{ target.getName() }); + if (viewerIsNew) team.setPlayers(new String[]{ target.getName() }); sendPkt.invoke(viewer, team); + viewers.add(vid); // Viewer als "hat CREATE" markieren } catch (Exception ignored) {} } - nametagCreated.add(target.getUniqueId()); + nametagLastPrefix.put(id, prefixStr); } catch (Exception e) { - plugin.getLogger().warning("[ScoreboardModule] Nametag-Fehler f\u00fcr " + target.getName() + ": " + e.getMessage()); + plugin.getLogger().warning("[ScoreboardModule] Nametag-Fehler für " + target.getName() + ": " + e.getMessage()); } } @@ -431,17 +464,25 @@ public class ScoreboardModule implements Module, Listener { * Entfernt das Nametag-Team beim Disconnect sauber vom Client aller Spieler. */ private void removeNametag(ProxiedPlayer target) { + UUID id = target.getUniqueId(); String teamName = "nt_" + target.getName().substring(0, Math.min(13, target.getName().length())); - for (ProxiedPlayer viewer : ProxyServer.getInstance().getPlayers()) { - if (!viewer.isConnected() || viewer.getUniqueId().equals(target.getUniqueId())) continue; - try { - Team team = new Team(); - team.setName(teamName); - team.setMode((byte) 1); // REMOVE - sendPkt.invoke(viewer, team); - } catch (Exception ignored) {} + // REMOVE nur an Viewer senden die auch CREATE erhalten haben (per-Viewer-Tracking). + // Sonst: "Player is either on another team or not on any team" → Client-Crash. + Set viewers = nametagViewers.remove(id); + if (viewers != null) { + for (UUID vid : viewers) { + ProxiedPlayer viewer = ProxyServer.getInstance().getPlayer(vid); + if (viewer == null || !viewer.isConnected()) continue; + if (vid.equals(id)) continue; // Den Target selbst überspringen + try { + Team team = new Team(); + team.setName(teamName); + team.setMode((byte) 1); // REMOVE + sendPkt.invoke(viewer, team); + } catch (Exception ignored) {} + } } - nametagCreated.remove(target.getUniqueId()); + nametagLastPrefix.remove(id); } /** @@ -520,8 +561,8 @@ public class ScoreboardModule implements Module, Listener { String maxpl = rawLimit > 0 ? String.valueOf(rawLimit) : "∞"; String tps = isAdmin ? getTps(id) : ""; String ram = isAdmin ? getRam() : ""; - String time = sdf.format(new Date()); - String date = sdfDate.format(new Date()); + String time = sdf.format(java.time.LocalDateTime.now(sdfZone)); + String date = sdfDate.format(java.time.LocalDateTime.now(sdfZone)); String playtime = formatPlaytime(id); // Neue Placeholders String xCoord = String.valueOf(playerX.getOrDefault(id, 0)); @@ -1842,13 +1883,13 @@ public class ScoreboardModule implements Module, Listener { decimalSeparator = g.apply("scoreboard.money_decimal_separator",","); separator = g.apply("scoreboard.separator", "&8&m--------------------"); try { - sdf = new java.text.SimpleDateFormat(timeFormat); - sdf.setTimeZone(java.util.TimeZone.getTimeZone(timeZone)); - sdfDate = new java.text.SimpleDateFormat(dateFormat); - sdfDate.setTimeZone(java.util.TimeZone.getTimeZone(timeZone)); + sdfZone = java.time.ZoneId.of(timeZone); + sdf = java.time.format.DateTimeFormatter.ofPattern(timeFormat).withZone(sdfZone); + sdfDate = java.time.format.DateTimeFormatter.ofPattern(dateFormat).withZone(sdfZone); } catch (Exception e) { - sdf = new java.text.SimpleDateFormat("HH:mm"); - sdfDate = new java.text.SimpleDateFormat("dd.MM.yyyy"); + sdfZone = java.time.ZoneId.systemDefault(); + sdf = java.time.format.DateTimeFormatter.ofPattern("HH:mm").withZone(sdfZone); + sdfDate = java.time.format.DateTimeFormatter.ofPattern("dd.MM.yyyy").withZone(sdfZone); } try { df = new DecimalFormat(moneyFormat); diff --git a/StatusAPI/src/main/java/net/viper/status/stats/StatsStorage.java b/StatusAPI/src/main/java/net/viper/status/stats/StatsStorage.java index 7913271..3f9d6a9 100644 --- a/StatusAPI/src/main/java/net/viper/status/stats/StatsStorage.java +++ b/StatusAPI/src/main/java/net/viper/status/stats/StatsStorage.java @@ -1,6 +1,8 @@ package net.viper.status.stats; import java.io.*; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; /** * Fix #9: save() und load() sind jetzt synchronized um Race Conditions @@ -17,12 +19,21 @@ public class StatsStorage { public void save(StatsManager manager) { synchronized (fileLock) { - try (BufferedWriter bw = new BufferedWriter(new FileWriter(file))) { + // Atomares Schreiben: erst in .tmp, dann umbenennen. + // 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()) { bw.write(ps.toLine()); bw.newLine(); } 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) { e.printStackTrace(); } diff --git a/StatusAPI/src/main/resources/messages.txt b/StatusAPI/src/main/resources/messages.txt index 8c1f95a..7277ccf 100644 --- a/StatusAPI/src/main/resources/messages.txt +++ b/StatusAPI/src/main/resources/messages.txt @@ -1,18 +1,52 @@ -§8[§2Viper-Netzwerk§8] §7Der Server läuft 24/7 – also keine Hektik beim Spielen :) -§8[§2Viper-Netzwerk§8] §7Dies ist ein privater Server – hier zählt der Zusammenhalt. -§8[§dTipp§8] §7Wenn du denkst, du bist sicher… schau nochmal nach. Creeper machen keine Geräusche beim Tippen. -§8[§2Viper-Netzwerk§8] §7Wähle einen Server, leg los – der Rest ergibt sich. Oder explodiert. -§8[§2Viper-Netzwerk§8] §7Mehr Server. Mehr Blöcke. Mehr Unfälle. Willkommen! -§8[§dTipp§8] §7Halte eine Spitzhacke mit Glück bereit. Man weiß nie, wann das nächste Erz kommt. -§8[§dTipp§8] §7Mit §e/home§7 kannst du dich jederzeit nach Hause teleportieren. -§8[§2Viper-Netzwerk§8] §7Das wichtigste Plugin? Du selbst. Spiel fair, sei kreativ! -§8[§2Viper-Netzwerk§8] §7Redstone ist keine Magie – aber fast. -§8[§dTipp§8] §7Schilde sind cool. Besonders wenn Skelette zielen. -§8[§2Viper-Netzwerk§8] §7Wenn du in Lava fällst, bist du nicht der Erste. Nur der Nächste. -§8[§dTipp§8] §7Villager sind nicht dumm – nur sehr… eigen. -§8[§2Viper-Netzwerk§8] §7Bau groß, bau sicher – oder bau eine Treppe zur Nachbarschaftsklage. -§8[§2Viper-Netzwerk§8] §7Gras wächst. Spieler auch. Gib jedem eine Chance! -§8[§2Viper-Netzwerk§8] §7Ein Creeper ist keine Begrüßung. Es sei denn, du willst es spannend machen. -§8[§dTipp§8] §7Ein voller Magen ist halbe Miete. Farmen lohnt sich! -§8[§2Viper-Netzwerk§8] §7Wir haben keine Probleme – nur Redstone-Schaltungen mit Charakter. -§8[§dTipp§8] §7Markiere dein Grundstück mit §e/p claim§7, bevor es jemand anderes tut! \ No newline at end of file +§8[%gradient:&b:&f:&b:&lViper-Network%§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[%gradient:&b:&f:&b:&lTipp%§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[%gradient:&b:&f:&b:&lViper-Network%§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[%gradient:&b:&f:&b:&lTipp%§8] §7Dein Bett ist dein Ankerpunkt – platziere es weise! +§8[%gradient:&b:&f:&b:&lViper-Network%§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[%gradient:&b:&f:&b:&lTipp%§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[%gradient:&b:&f:&b:&lTipp%§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[%gradient:&b:&f:&b:&lViper-Network%§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[%gradient:&b:&f:&b:&lTipp%§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[%gradient:&b:&f:&b:&lTipp%§8] §7Markiere dein Grundstück frühzeitig, 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! \ No newline at end of file