diff --git a/StatusAPI/pom.xml b/StatusAPI/pom.xml
index 8f775ad..bf1d685 100644
--- a/StatusAPI/pom.xml
+++ b/StatusAPI/pom.xml
@@ -7,15 +7,21 @@
net.viper.bungee
StatusAPI
- 4.1.4
+ 4.1.5
jar
StatusAPI
BungeeCord Status API Plugin
- 8
- 8
+
+ 21
+ 21
+ 21
UTF-8
@@ -42,7 +48,7 @@
com.zaxxer
HikariCP
- 5.1.0
+ 6.2.1
compile
@@ -50,7 +56,7 @@
com.mysql
mysql-connector-j
- 9.1.0
+ 9.2.0
compile
@@ -77,10 +83,21 @@
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.14.0
+
+ 21
+ 21
+ 21
+ UTF-8
+
+
org.apache.maven.plugins
maven-shade-plugin
- 3.5.0
+ 3.6.0
package
@@ -104,4 +121,4 @@
-
\ No newline at end of file
+
diff --git a/StatusAPI/src/main/java/net/viper/status/StatusAPI.java b/StatusAPI/src/main/java/net/viper/status/StatusAPI.java
index 29fdcab..11ea4b5 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) {
@@ -1435,21 +1544,28 @@ public class StatusAPI extends Plugin implements Runnable {
* Alle anderen Module (Chat, AntiBot, etc.) bleiben unber\u00fchrt.
*/
public void reloadModules() {
- getLogger().info("[StatusAPI] Reload von Scoreboard und Tablist...");
+ getLogger().info("[StatusAPI] Reload von Scoreboard, Tablist und AFK...");
net.viper.status.module.Module sbMod = moduleManager.getModule("ScoreboardModule");
net.viper.status.module.Module tabMod = moduleManager.getModule("TablistModule");
+ net.viper.status.module.Module afkMod = moduleManager.getModule("AfkModule");
if (sbMod != null) sbMod.onDisable(this);
if (tabMod != null) tabMod.onDisable(this);
+ // AfkModule meldet Listener, Command und Plugin-Channel in onDisable sauber ab
+ // und setzt laufende AFK-Zustaende auf den Spigot-Servern zurueck.
+ if (afkMod != null) afkMod.onDisable(this);
// Neue Instanzen erstellen und registrieren
net.viper.status.modules.scoreboard.ScoreboardModule newSb = new net.viper.status.modules.scoreboard.ScoreboardModule();
net.viper.status.modules.tablist.TablistModule newTab = new net.viper.status.modules.tablist.TablistModule();
+ net.viper.status.modules.afk.AfkModule newAfk = new net.viper.status.modules.afk.AfkModule();
moduleManager.replaceModule("ScoreboardModule", newSb);
moduleManager.replaceModule("TablistModule", newTab);
+ moduleManager.replaceModule("AfkModule", newAfk);
+ newAfk.onEnable(this);
newSb.onEnable(this);
newTab.onEnable(this);
@@ -1483,7 +1599,7 @@ public class StatusAPI extends Plugin implements Runnable {
send(sender, "&8&m──────────────────────────────────────────");
send(sender, "&6&lStatusAPI &7| Befehle");
if (isAdmin) {
- send(sender, "&e/statusapi reload &7– Scoreboard & Tablist neu laden");
+ send(sender, "&e/statusapi reload &7– Scoreboard, Tablist & AFK neu laden");
} else {
send(sender, "&7Keine weiteren Unterbefehle verf\u00fcgbar.");
}
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..7fdb98a 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.
@@ -39,6 +40,7 @@ public class AfkModule implements Module, Listener {
private Plugin plugin;
private ScheduledTask idleCheckTask;
+ private Command afkCommand;
private static AfkModule INSTANCE;
private java.lang.reflect.Method sendPkt;
@@ -91,17 +93,19 @@ public class AfkModule implements Module, Listener {
if (!enabled) { plugin.getLogger().info("[AfkModule] Deaktiviert."); return; }
ProxyServer.getInstance().getPluginManager().registerListener(plugin, this);
- ProxyServer.getInstance().getPluginManager().registerCommand(plugin,
- new Command("afk") {
- @Override
- public void execute(CommandSender sender, String[] args) {
- if (!(sender instanceof ProxiedPlayer)) {
- sender.sendMessage(plain("&cNur f\u00fcr Spieler."));
- return;
- }
- toggleAfk((ProxiedPlayer) sender);
+ // Plugin-Message-Channel registrieren (BungeeCord → Spigot)
+ ProxyServer.getInstance().registerChannel("statusapi:afk");
+ afkCommand = new Command("afk") {
+ @Override
+ public void execute(CommandSender sender, String[] args) {
+ if (!(sender instanceof ProxiedPlayer)) {
+ sender.sendMessage(plain("&cNur f\u00fcr Spieler."));
+ return;
}
- });
+ toggleAfk((ProxiedPlayer) sender);
+ }
+ };
+ ProxyServer.getInstance().getPluginManager().registerCommand(plugin, afkCommand);
if (idleEnabled) {
idleCheckTask = ProxyServer.getInstance().getScheduler().schedule(
@@ -119,9 +123,28 @@ public class AfkModule implements Module, Listener {
titleTasks.clear();
activeTitleEntry.clear();
activePair.clear();
+
+ // Die Spigot-Seite (StatusAPIBridge) fuehrt eine eigene afkState-Map und
+ // setzt daraus den [AFK]-Nametag. Wird hier nur lokal geleert, bleibt der
+ // Prefix nach einem Reload ueber dem Kopf haengen, weil die Bridge nie ein
+ // Update auf false bekommt. Darum vor dem Leeren explizit zuruecksetzen.
+ for (UUID id : new ArrayList<>(StatusAPI.playerAfk.keySet())) {
+ ProxiedPlayer p = ProxyServer.getInstance().getPlayer(id);
+ if (p != null) { try { clearTitle(p); } catch (Exception ignored) {} }
+ try { broadcastAfkState(id, false); } catch (Exception ignored) {}
+ }
StatusAPI.playerAfk.clear();
lastActivity.clear();
INSTANCE = null;
+
+ // Listener und Command wieder abmelden, damit eine neue Instanz (Reload)
+ // sauber registrieren kann und keine Doppel-Registrierung entsteht.
+ try { ProxyServer.getInstance().getPluginManager().unregisterListener(this); } catch (Exception ignored) {}
+ if (afkCommand != null) {
+ try { ProxyServer.getInstance().getPluginManager().unregisterCommand(afkCommand); } catch (Exception ignored) {}
+ afkCommand = null;
+ }
+ try { ProxyServer.getInstance().unregisterChannel("statusapi:afk"); } catch (Exception ignored) {}
}
// ── Events ───────────────────────────────────────────────────────────────
@@ -131,7 +154,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);
}
@@ -146,7 +171,21 @@ public class AfkModule implements Module, Listener {
// ── Public API ───────────────────────────────────────────────────────────
+ /**
+ * Setzt den AFK-Status aus einer externen Quelle (POST /player/afk der Bridge).
+ *
+ * Laeuft ueber denselben Weg wie /afk und die Idle-Erkennung, sonst waere der
+ * Status zwar gesetzt, aber ohne Title und ohne Broadcast an die Spigot-Server –
+ * der [AFK]-Nametag wuerde dann nicht mitziehen.
+ */
public static void setBridgeAfk(UUID uuid, boolean afk) {
+ AfkModule inst = INSTANCE;
+ ProxiedPlayer p = ProxyServer.getInstance().getPlayer(uuid);
+ if (inst != null && p != null) {
+ inst.setAfk(p, afk);
+ return;
+ }
+ // Spieler nicht auf dem Proxy erreichbar: nur den Status nachfuehren.
if (afk) {
StatusAPI.playerAfk.put(uuid, true);
} else {
@@ -184,30 +223,66 @@ public class AfkModule implements Module, Listener {
private void setAfk(ProxiedPlayer p, boolean afk) {
UUID id = p.getUniqueId();
- if (isAfk(id) == afk) return;
+ // Zustandswechsel atomar: setAfk wird sowohl vom Scheduler (checkIdle) als
+ // auch von den HTTP-Threads der Bridge (Bewegung, Compass) aufgerufen. Ein
+ // getrenntes isAfk()-Vorabpruefen liess beide Threads durchlaufen – Folge
+ // waren doppelte Title-Tasks und doppelte Broadcasts.
if (afk) {
- StatusAPI.playerAfk.put(id, true);
+ if (StatusAPI.playerAfk.putIfAbsent(id, Boolean.TRUE) != null) return;
startTitleTask(p);
} else {
- StatusAPI.playerAfk.remove(id);
+ if (StatusAPI.playerAfk.remove(id) == null) return;
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 +299,43 @@ 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);
+ // Kurz vor Ablauf der Standzeit erneut senden, damit der Title nie ausblendet.
+ // Fruehere Rechnung (titleStay - 20) * 50 lief bei kleinem afk.title.stay ins
+ // Leere: unter 30 Ticks war das Intervall laenger als die Standzeit selbst und
+ // der Title verschwand zwischen zwei Ticks.
+ long stayMs = Math.max(1L, titleStay) * 50L;
+ long intervalMs = Math.max(500L, stayMs - 1000L);
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);
+ if (current == null) return;
+ // firstSend=false → kein TitleTimes → kein Blinken beim Repeat
+ if (!sendTitleEntry(online, current, false)) {
+ stopTitleTask(id);
+ return;
+ }
+ // Der Spieler kann sich waehrend des Sendens bewegt haben – setAfk(false)
+ // laeuft auf dem HTTP-Thread der Bridge. Dann klebt jetzt wieder der
+ // AFK-Title auf dem Bildschirm, obwohl der Spieler zurueck ist.
+ if (!isAfk(id)) {
+ clearTitle(online);
+ sendTitleEntry(online, pair.unset);
+ 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 +344,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 +365,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 +406,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("");
@@ -304,11 +444,6 @@ public class AfkModule implements Module, Listener {
return root;
}
- /** Fasst ein BaseComponent[]-Array in eine einzelne TextComponent zusammen. */
- private static BaseComponent wrap(BaseComponent[] parts) {
- return mergeComponents(parts);
- }
-
// ── Gradient-Verarbeitung ─────────────────────────────────────────────────
// Identische Implementierung wie im ScoreboardModule – verarbeitet
// %gradient:FARBE1:FARBE2:...:TEXT% mit beliebig vielen Farb-Stopps.
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..9ac30ac 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 teamName = nametagTeamName(target);
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,31 +449,64 @@ 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);
+
+ // Eintrag zurueckholen: StatusAPIBridge setzt serverseitig ein
+ // eigenes Nametag-Team (vnt...). Sobald das den Spieler aufnimmt,
+ // faellt er clientseitig aus unserem Team - ein UPDATE (mode=2)
+ // traegt keine Eintraege und liefe danach dauerhaft ins Leere.
+ // ADD_PLAYER (mode=3) holt ihn bei jeder Prefix-Aenderung zurueck.
+ if (!viewerIsNew) {
+ Team claim = new Team();
+ claim.setName(teamName);
+ claim.setMode((byte) 3); // ADD_PLAYER
+ claim.setPlayers(new String[]{ target.getName() });
+ sendPkt.invoke(viewer, claim);
+ }
+ 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());
}
}
+ /**
+ * Team-Name fuer den Nametag eines Spielers.
+ * Basiert auf der UUID statt auf dem Namen: bei namensbasierten Teams
+ * (erste 13 Zeichen) landeten Spieler mit gleichem Namensanfang – etwa
+ * "ViperNetworkA" und "ViperNetworkB" – im selben Team und haben sich
+ * gegenseitig den Prefix ueberschrieben. Minecraft-Limit: 16 Zeichen.
+ */
+ private static String nametagTeamName(ProxiedPlayer target) {
+ return "nt" + target.getUniqueId().toString().replace("-", "").substring(0, 13);
+ }
+
/**
* Entfernt das Nametag-Team beim Disconnect sauber vom Client aller Spieler.
*/
private void removeNametag(ProxiedPlayer target) {
- 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) {}
+ UUID id = target.getUniqueId();
+ String teamName = nametagTeamName(target);
+ // 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 +585,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 +1907,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
diff --git a/StatusAPI/src/main/resources/plugin.yml b/StatusAPI/src/main/resources/plugin.yml
index c9d84f9..c18d7b6 100644
--- a/StatusAPI/src/main/resources/plugin.yml
+++ b/StatusAPI/src/main/resources/plugin.yml
@@ -1,6 +1,6 @@
name: StatusAPI
main: net.viper.status.StatusAPI
-version: 4.1.4
+version: 4.1.5
author: M_Viper
description: StatusAPI für BungeeCord inkl. Update-Checker, Modul-System und ChatModule
# Mindestanforderung: Minecraft 1.20 / BungeeCord mit PlayerChatEvent-Unterstützung