From a32f08735365cac9c6d91935bb63c64e2e25ec42 Mon Sep 17 00:00:00 2001 From: Git Manager GUI Date: Sun, 10 May 2026 15:06:25 +0200 Subject: [PATCH] Upload folder via GUI - src --- .../main/java/net/viper/status/StatusAPI.java | 122 ++ .../AutoMessage/AutoMessageModule.java | 31 + .../modules/network/NetworkInfoModule.java | 3 + .../modules/scoreboard/ScoreboardModule.java | 1531 +++++++++++++++++ .../serverswitcher/ServerSwitcherModule.java | 2 +- .../net/viper/status/stats/PlayerStats.java | 109 +- .../net/viper/status/stats/StatsModule.java | 72 +- StatusAPI/src/main/resources/plugin.yml | 6 + .../src/main/resources/scoreboard.properties | 115 ++ 9 files changed, 1957 insertions(+), 34 deletions(-) create mode 100644 StatusAPI/src/main/java/net/viper/status/modules/scoreboard/ScoreboardModule.java create mode 100644 StatusAPI/src/main/resources/scoreboard.properties diff --git a/StatusAPI/src/main/java/net/viper/status/StatusAPI.java b/StatusAPI/src/main/java/net/viper/status/StatusAPI.java index 23c6dc5..d47c27d 100644 --- a/StatusAPI/src/main/java/net/viper/status/StatusAPI.java +++ b/StatusAPI/src/main/java/net/viper/status/StatusAPI.java @@ -7,6 +7,7 @@ import net.md_5.bungee.api.plugin.Plugin; import net.viper.status.module.ModuleManager; import net.viper.status.modules.economy.EconomyModule; import net.viper.status.modules.tablist.TablistModule; +import net.viper.status.modules.scoreboard.ScoreboardModule; import net.viper.status.modules.antibot.AntiBotModule; import net.viper.status.modules.network.NetworkInfoModule; import net.viper.status.modules.AutoMessage.AutoMessageModule; @@ -111,6 +112,7 @@ public class StatusAPI extends Plugin implements Runnable { moduleManager.registerModule(new ServerSwitcherModule()); moduleManager.registerModule(new EconomyModule()); moduleManager.registerModule(new TablistModule()); + moduleManager.registerModule(new ScoreboardModule()); try { Class forumBridge = Class.forName("net.viper.status.modules.forum.ForumBridgeModule"); @@ -122,6 +124,20 @@ public class StatusAPI extends Plugin implements Runnable { moduleManager.enableAll(this); + // FIX: ScoreboardModule mit NetworkInfoModule verbinden (TPS-Fallback) + try { + net.viper.status.modules.scoreboard.ScoreboardModule sbMod = + (net.viper.status.modules.scoreboard.ScoreboardModule) moduleManager.getModule("ScoreboardModule"); + net.viper.status.modules.network.NetworkInfoModule nimMod = + (net.viper.status.modules.network.NetworkInfoModule) moduleManager.getModule("NetworkInfoModule"); + if (sbMod != null && nimMod != null) { + sbMod.setNetworkInfoModule(nimMod); + getLogger().info("[StatusAPI] ScoreboardModule → NetworkInfoModule TPS-Fallback verbunden."); + } + } catch (Exception e) { + getLogger().warning("[StatusAPI] TPS-Fallback konnte nicht verbunden werden: " + e.getMessage()); + } + // WebServer starten shuttingDown = false; requestExecutor = Executors.newFixedThreadPool(4, r -> { @@ -528,6 +544,8 @@ public class StatusAPI extends Plugin implements Runnable { playerMap.put("last_seen", ps.lastSeen); playerMap.put("playtime", ps.getPlaytimeWithCurrentSession()); playerMap.put("joins", ps.joins); + playerMap.put("kills", ps.kills); + playerMap.put("deaths", ps.deaths); playerMap.put("online", ProxyServer.getInstance().getPlayer(ps.uuid) != null); // Balance direkt aus MySQL (serverübergreifend) EconomyModule ecoModPlayer = (EconomyModule) moduleManager.getModule("EconomyModule"); @@ -683,6 +701,79 @@ public class StatusAPI extends Plugin implements Runnable { return; } + // POST /stats/update – Kills und Deaths eines Spielers aktualisieren (von Backend-Server via StatusAPIBridge) + if ("POST".equalsIgnoreCase(method) && "/stats/update".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + StatsModule statsModUpd = (StatsModule) moduleManager.getModule("StatsModule"); + if (statsModUpd == null) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"stats_module_unavailable\"}", 503); + return; + } + String uuidStr = extractJsonString(body, "uuid"); + String nameStr = extractJsonString(body, "name"); + PlayerStats psUpd = resolvePlayer(uuidStr, nameStr, statsModUpd); + if (psUpd == null) { + // Spieler noch nicht bekannt → ignorieren (er hat sich noch nicht eingeloggt) + sendHttpResponse(out, "{\"success\":false,\"error\":\"player_not_found\"}", 404); + return; + } + String killsStr = extractJsonString(body, "kills"); + String deathsStr = extractJsonString(body, "deaths"); + synchronized (psUpd) { + try { if (killsStr != null && !killsStr.isEmpty()) psUpd.kills = Integer.parseInt(killsStr.trim()); } catch (Exception ignored) {} + try { if (deathsStr != null && !deathsStr.isEmpty()) psUpd.deaths = Integer.parseInt(deathsStr.trim()); } catch (Exception ignored) {} + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + + // POST /scoreboard/health – Leben eines Spielers aktualisieren (von StatusAPIBridge) + if ("POST".equalsIgnoreCase(method) && "/scoreboard/health".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + String uuidStr = extractJsonString(body, "uuid"); + String healthStr = extractJsonString(body, "health"); + if (uuidStr != null && !uuidStr.isEmpty() && healthStr != null && !healthStr.isEmpty()) { + try { + UUID hUuid = UUID.fromString(uuidStr.trim()); + double health = Double.parseDouble(healthStr.trim()); + net.viper.status.modules.scoreboard.ScoreboardModule.playerHealth.put(hUuid, health); + } catch (Exception ignored) {} + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + + // POST /scoreboard/compass – Himmelsrichtung eines Spielers (von StatusAPIBridge) + if ("POST".equalsIgnoreCase(method) && "/scoreboard/compass".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + String uuidStr = extractJsonString(body, "uuid"); + String compassStr = extractJsonString(body, "compass"); + if (uuidStr != null && !uuidStr.isEmpty() && compassStr != null && !compassStr.isEmpty()) { + try { + UUID cUuid = UUID.fromString(uuidStr.trim()); + net.viper.status.modules.scoreboard.ScoreboardModule.playerCompass.put(cUuid, compassStr.trim()); + } catch (Exception ignored) {} + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + + // POST /scoreboard/tps – TPS des Spieler-Servers (von StatusAPIBridge) + if ("POST".equalsIgnoreCase(method) && "/scoreboard/tps".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + String uuidStr = extractJsonString(body, "uuid"); + String tpsStr = extractJsonString(body, "tps"); + if (uuidStr != null && !uuidStr.isEmpty() && tpsStr != null && !tpsStr.isEmpty()) { + try { + UUID tUuid = UUID.fromString(uuidStr.trim()); + double tps = Double.parseDouble(tpsStr.trim()); + net.viper.status.modules.scoreboard.ScoreboardModule.playerTps.put(tUuid, tps); + } catch (Exception ignored) {} + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + // POST /player/world – Welt eines Spielers aktualisieren (von StatusAPIBridge) if ("POST".equalsIgnoreCase(method) && "/player/world".equalsIgnoreCase(pathOnly)) { String body = readBody(in, headers); @@ -697,6 +788,35 @@ public class StatusAPI extends Plugin implements Runnable { return; } + // POST /player/data – Koordinaten, Gamemode, Exp, Food, Speed + if ("POST".equalsIgnoreCase(method) && "/player/data".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + String uuidStr = extractJsonString(body, "uuid"); + if (uuidStr != null && !uuidStr.isEmpty()) { + try { + UUID uid = UUID.fromString(uuidStr.trim()); + String xS = extractJsonString(body, "x"); + String yS = extractJsonString(body, "y"); + String zS = extractJsonString(body, "z"); + String gm = extractJsonString(body, "gamemode"); + String expS= extractJsonString(body, "exp"); + String fdS = extractJsonString(body, "food"); + String spS = extractJsonString(body, "speed"); + String wld = extractJsonString(body, "world"); + if (xS != null) net.viper.status.modules.scoreboard.ScoreboardModule.playerX.put(uid, (int)Double.parseDouble(xS)); + if (yS != null) net.viper.status.modules.scoreboard.ScoreboardModule.playerY.put(uid, (int)Double.parseDouble(yS)); + if (zS != null) net.viper.status.modules.scoreboard.ScoreboardModule.playerZ.put(uid, (int)Double.parseDouble(zS)); + if (gm != null) net.viper.status.modules.scoreboard.ScoreboardModule.playerGamemode.put(uid, gm); + if (expS!= null) net.viper.status.modules.scoreboard.ScoreboardModule.playerExp.put(uid, (int)Double.parseDouble(expS)); + if (fdS != null) net.viper.status.modules.scoreboard.ScoreboardModule.playerFood.put(uid, (int)Double.parseDouble(fdS)); + if (spS != null) net.viper.status.modules.scoreboard.ScoreboardModule.playerSpeed.put(uid, Double.parseDouble(spS)); + if (wld != null) net.viper.status.modules.scoreboard.ScoreboardModule.playerWorld.put(uid, wld); + } catch (Exception ignored) {} + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + // GET – Status-Endpunkt if (inputLine.startsWith("GET")) { Map data = new LinkedHashMap<>(); @@ -775,6 +895,8 @@ public class StatusAPI extends Plugin implements Runnable { if (ps != null) { playerInfo.put("playtime", ps.getPlaytimeWithCurrentSession()); playerInfo.put("joins", ps.joins); + playerInfo.put("kills", ps.kills); + playerInfo.put("deaths", ps.deaths); playerInfo.put("first_seen", ps.firstSeen); playerInfo.put("last_seen", ps.lastSeen); // Balance direkt aus MySQL (serverübergreifend) 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 7f2f1f6..7c2a3f4 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 @@ -47,6 +47,7 @@ public class AutoMessageModule implements Module { public void onEnable(Plugin plugin) { this.api = (StatusAPI) plugin; loadSettings(); + ensureMessagesFileExists(); if (!enabled) return; @@ -59,6 +60,36 @@ public class AutoMessageModule implements Module { cancelTask(); } + private void ensureMessagesFileExists() { + File dataFolder = api.getDataFolder(); + if (!dataFolder.exists()) dataFolder.mkdirs(); + + File target = new File(dataFolder, fileName); + if (target.exists()) return; + + // Datei aus den Plugin-Ressourcen kopieren + try (java.io.InputStream in = api.getResourceAsStream(fileName)) { + if (in != null) { + Files.copy(in, target.toPath()); + api.getLogger().info("[AutoMessage] " + fileName + " wurde aus den Ressourcen erstellt."); + return; + } + } catch (IOException e) { + api.getLogger().warning("[AutoMessage] Konnte " + fileName + " nicht aus Ressourcen kopieren: " + e.getMessage()); + } + + // Fallback: leere Datei mit Hinweis anlegen + try { + Files.write(target.toPath(), + ("# AutoMessage – eine Nachricht pro Zeile\n" + + "# Farben mit & oder §-Codes, z.B. &aGrüner Text\n" + + "# Kommentarzeilen (# ...) und Leerzeilen werden ignoriert\n").getBytes(StandardCharsets.UTF_8)); + api.getLogger().info("[AutoMessage] " + fileName + " wurde als leere Vorlage erstellt."); + } catch (IOException e) { + api.getLogger().severe("[AutoMessage] Konnte " + fileName + " nicht erstellen: " + e.getMessage()); + } + } + private void loadSettings() { Properties props = api.getVerifyProperties(); enabled = Boolean.parseBoolean(props.getProperty("automessage.enabled", "false")); diff --git a/StatusAPI/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java b/StatusAPI/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java index 83ababd..f2ab4e3 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java @@ -65,6 +65,9 @@ public class NetworkInfoModule implements Module { private long lastPlayerAlertAt = 0L; private long lastTpsAlertAt = 0L; private volatile double currentProxyTps = 20.0D; + + /** FIX: Öffentlicher Getter damit ScoreboardModule als TPS-Fallback darauf zugreifen kann */ + public double getProxyTps() { return currentProxyTps; } private long lastTpsSampleAtMs = 0L; private ScheduledTask alertTask; private ScheduledTask tpsSamplerTask; 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 new file mode 100644 index 0000000..246c446 --- /dev/null +++ b/StatusAPI/src/main/java/net/viper/status/modules/scoreboard/ScoreboardModule.java @@ -0,0 +1,1531 @@ +package net.viper.status.modules.scoreboard; + +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.connection.ProxiedPlayer; +import net.md_5.bungee.api.event.PlayerDisconnectEvent; +import net.md_5.bungee.api.event.PostLoginEvent; +import net.md_5.bungee.api.event.ServerSwitchEvent; +import net.md_5.bungee.api.plugin.Command; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.api.scheduler.ScheduledTask; +import net.md_5.bungee.event.EventHandler; +import net.md_5.bungee.protocol.packet.ScoreboardDisplay; +import net.md_5.bungee.protocol.packet.ScoreboardObjective; +import net.md_5.bungee.protocol.packet.ScoreboardObjective.HealthDisplay; +import net.md_5.bungee.protocol.packet.ScoreboardScore; +import net.md_5.bungee.protocol.packet.Team; +import net.md_5.bungee.protocol.packet.Team.NameTagVisibility; +import net.md_5.bungee.protocol.packet.Team.CollisionRule; +import net.md_5.bungee.protocol.util.Either; +import net.md_5.bungee.protocol.data.NumberFormat; +import net.viper.status.StatusAPI; +import net.viper.status.module.Module; + +import java.io.*; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.text.DecimalFormat; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +public class ScoreboardModule implements Module, Listener { + + private static final String CONFIG_FILE = "scoreboard.properties"; + private static final String OBJ_NAME = "vpsb"; + private static final String OBJ_NAME_ADMIN = "vpsbadmin"; + + public static final ConcurrentHashMap playerHealth = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerCompass = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerTps = new ConcurrentHashMap<>(); + // Neue Placeholder-Daten + public static final ConcurrentHashMap playerX = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerY = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerZ = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerWorld = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerGamemode= new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerExp = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerFood = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerSpeed = new ConcurrentHashMap<>(); + + private final ConcurrentHashMap joinTimes = new ConcurrentHashMap<>(); + // Spieler, die das Scoreboard ausgeblendet haben + /** FIX: Referenz auf NetworkInfoModule für TPS-Fallback */ + private net.viper.status.modules.network.NetworkInfoModule networkInfoModule = null; + + /** Wird von StatusAPI nach dem Registrieren aller Module aufgerufen */ + public void setNetworkInfoModule(net.viper.status.modules.network.NetworkInfoModule nim) { + this.networkInfoModule = nim; + } + + private final Set hiddenPlayers = ConcurrentHashMap.newKeySet(); + // Spieler, die manuell auf Player-Board gezwungen wurden (Override) + private final Set forcePlayerView = ConcurrentHashMap.newKeySet(); + // Spieler, die manuell auf Admin-Board gezwungen wurden (ohne Perm) + private final Set forceAdminView = ConcurrentHashMap.newKeySet(); + + private boolean enabled = true; + private int updateInterval = 500; // Millisekunden + private int tickerSpeed = 1; + private boolean rainbowEnabled = true; + private String rainbowMode = "wave"; + // Wellen-Farben als RGB-Arrays (vorberechnet aus Config) + private int[][] waveColors = null; // null = HSB-Fallback + private float waveSpeed = 0.05f; // Bewegung pro Tick + private String title = "&6&lViper Network"; + private String adminTitle = "&c&l[Admin] &4&lPanel"; + private String adminPermission = "statusapi.scoreboard.admin"; + private String timeFormat = "HH:mm"; + private String dateFormat = "dd.MM.yyyy"; + private String timeZone = "Europe/Berlin"; + private String moneyFormat = "#,##0.00"; + private String decimalSeparator = ","; + private String tickerText = " Viper Network "; + private String separator = "&8&m--------------------"; + private int tickerWidth = 24; + // Key = Zeilennummer (1-based), Value = Liste der Inhalte pro Page + // Zeilen mit nur 1 Eintrag rotieren nicht, Zeilen mit 2+ Eintraegen wechseln + private Map> playerLineMap = new LinkedHashMap<>(); + private Map> adminLineMap = new LinkedHashMap<>(); + private int rotationInterval = 4; // Sekunden pro Page + // News-Ticker + private String newsText = ""; + private String newsPrefix = "&8[&6News&8] &r"; + private int newsWidth = 20; + private int newsSpeed = 1; + private final ConcurrentHashMap newsPos = new ConcurrentHashMap<>(); + private int maxLineNum = 0; // hoechste definierte Zeilennummer + + private Plugin plugin; + private ScheduledTask updateTask; + private ScheduledTask titleTask; + private ScheduledTask newsTask; + private java.text.SimpleDateFormat sdf; + private java.text.SimpleDateFormat sdfDate; + private DecimalFormat df; + private Method sendPkt; + private boolean ready = false; + + private static final String[] ENTRIES = { + "§0","§1","§2","§3","§4","§5","§6","§7", + "§8","§9","§a","§b","§c","§d","§e", + "§f§0","§f§1","§f§2","§f§3","§f§4" + }; + private static final int MAX_LINES = 15; // Minecraft Client zeigt max 15 Scoreboard-Einträge + + private final ConcurrentHashMap tickerPos = new ConcurrentHashMap<>(); + private final ConcurrentHashMap rainbowIdx = new ConcurrentHashMap<>(); + private final Set created = ConcurrentHashMap.newKeySet(); + private final Set createdAdmin = ConcurrentHashMap.newKeySet(); + + private static final ChatColor[] RAINBOW = { + ChatColor.RED, ChatColor.GOLD, ChatColor.YELLOW, + ChatColor.GREEN, ChatColor.AQUA, ChatColor.BLUE, ChatColor.LIGHT_PURPLE + }; + + // ── Compass Tape ───────────────────────────────────────────────────────── + // Kontinuierlicher Kompass mit Sub-Zeichen-Interpolation. + // Jeder Grad entspricht einem eigenen Slot → 360 Slots, kein Springen. + // Bridge sendet normYaw (0..360, float mit 1 Dezimalstelle). + private static final int COMPASS_SLOTS = 360; // 1 Slot = 1 Grad + private static final int COMPASS_WIN = 19; // sichtbare Slots (ungerade) + private static final int COMPASS_DEG_PER_SLOT = 9; // muss 90 teilen: 1,2,3,5,6,9,10,15,18,30,45,90 + // Labels an festen Grad-Positionen (N=0, E=90, S=180, W=270) + private static final int[] COMPASS_LABEL_DEG = { 0, 90, 180, 270 }; + private static final char[] COMPASS_LABEL_CH = { 'N', 'E', 'S', 'W' }; + + @Override public String getName() { return "ScoreboardModule"; } + + @Override + public void onEnable(Plugin plugin) { + this.plugin = plugin; + plugin.getLogger().info("[ScoreboardModule] Starte..."); + ensureConfigExists(); + loadConfig(); + if (!enabled) { plugin.getLogger().info("[ScoreboardModule] Deaktiviert."); return; } + try { + Class uc = Class.forName("net.md_5.bungee.UserConnection"); + sendPkt = uc.getMethod("sendPacketQueued", net.md_5.bungee.protocol.DefinedPacket.class); + sendPkt.setAccessible(true); + ready = true; + plugin.getLogger().info("[ScoreboardModule] Aktiviert. Interval=" + updateInterval + "ms (mind. 250 empfohlen)"); + } catch (Exception e) { + plugin.getLogger().severe("[ScoreboardModule] sendPacketQueued nicht gefunden: " + e); + return; + } + ProxyServer.getInstance().getPluginManager().registerListener(plugin, this); + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, new ScoreboardToggleCommand()); + // updateInterval in ms für Daten-Updates (Kompass, Zeilen, etc.) + updateTask = ProxyServer.getInstance().getScheduler().schedule( + plugin, this::tickAll, updateInterval, updateInterval, TimeUnit.MILLISECONDS); + // Separater schneller Task nur für den Titel (Wave-Animation, 100ms = 10fps) + titleTask = ProxyServer.getInstance().getScheduler().schedule( + plugin, this::tickTitle, 100, 100, TimeUnit.MILLISECONDS); + // Separater Task für News-Ticker (100ms = flüssiges Scrollen) + newsTask = ProxyServer.getInstance().getScheduler().schedule( + plugin, this::tickNews, 100, 100, TimeUnit.MILLISECONDS); + } + + @Override + public void onDisable(Plugin plugin) { + if (updateTask != null) { updateTask.cancel(); updateTask = null; } + if (titleTask != null) { titleTask.cancel(); titleTask = null; } + if (newsTask != null) { newsTask.cancel(); newsTask = null; } + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (created.contains(p.getUniqueId())) removeObjectiveAndTeams(p, OBJ_NAME, "vt"); + if (createdAdmin.contains(p.getUniqueId())) removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); + } + created.clear(); createdAdmin.clear(); tickerPos.clear(); rainbowIdx.clear(); + hiddenPlayers.clear(); forceAdminView.clear(); forcePlayerView.clear(); newsPos.clear(); + } + + @EventHandler + public void onJoin(PostLoginEvent e) { + if (!ready) return; + UUID id = e.getPlayer().getUniqueId(); + tickerPos.put(id, 0); + rainbowIdx.put(id, 0); + newsPos.put(id, 0); + joinTimes.put(id, System.currentTimeMillis()); + // Nur State initialisieren – onSwitch baut das Scoreboard auf + created.remove(id); + createdAdmin.remove(id); + } + + @EventHandler + public void onSwitch(ServerSwitchEvent e) { + if (!ready) return; + ProxiedPlayer p = e.getPlayer(); + UUID id = p.getUniqueId(); + // Altes Objective sauber entfernen – tickAll übernimmt den Neuaufbau + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + // Kein verzögerter sendAll-Call mehr – tickAll baut nach max. 500ms neu auf + } + + @EventHandler + public void onQuit(PlayerDisconnectEvent e) { + UUID id = e.getPlayer().getUniqueId(); + tickerPos.remove(id); rainbowIdx.remove(id); created.remove(id); createdAdmin.remove(id); + playerHealth.remove(id); playerCompass.remove(id); playerTps.remove(id); + 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); + } + + /** Schneller Task: aktualisiert News-Position und sendet nur die betroffene Team-Zeile */ + private void tickNews() { + if (newsText == null || newsText.isEmpty()) return; + String newsPlain = ChatColor.stripColor(c(newsText)); + int gap = 4; + int nCycle = Math.max(1, newsPlain.length()) + gap; + + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (!p.isConnected()) continue; + UUID id = p.getUniqueId(); + if (hiddenPlayers.contains(id)) continue; + boolean isAdmin = !forcePlayerView.contains(id) + && (p.hasPermission(adminPermission) || forceAdminView.contains(id)); + Set activeCreated = isAdmin ? createdAdmin : created; + if (!activeCreated.contains(id)) continue; + + // Position vorrücken + int nOff = (newsPos.getOrDefault(id, 0) + newsSpeed) % nCycle; + newsPos.put(id, nOff); + + // Nur die News-Zeilen neu senden (nicht das ganze Scoreboard) + try { + String activeObjName = isAdmin ? OBJ_NAME_ADMIN : OBJ_NAME; + String newsStr = buildNewsTicker(nOff); + // Finde welche Zeilennummer(n) %news% enthält und sende nur diese + java.util.Map> lineMap = + isAdmin ? adminLineMap : playerLineMap; + for (java.util.Map.Entry> entry : lineMap.entrySet()) { + boolean hasNews = false; + for (String v : entry.getValue()) { + if (v.contains("%news%")) { hasNews = true; break; } + } + if (!hasNews) continue; + int lineNum = entry.getKey(); + int lineIdx = lineNum - 1; // 0-based index for ENTRIES + if (lineIdx < 0 || lineIdx >= ENTRIES.length) continue; + + // Aktuellen Varianten-Index berechnen + int pageIdx = (rotationInterval > 0) + ? (int)((System.currentTimeMillis() / 1000) / rotationInterval) : 0; + java.util.List variants = entry.getValue(); + String tpl = variants.get(pageIdx % variants.size()); + if (!tpl.contains("%news%")) continue; + + String lineText = c(tpl.replace("%news%", newsStr)); + + // Team-Packet nur für diese Zeile senden + net.md_5.bungee.protocol.packet.Team team = new net.md_5.bungee.protocol.packet.Team(); + team.setName((isAdmin ? "vta" : "vt") + lineIdx); + team.setMode((byte) 2); // UPDATE + net.md_5.bungee.api.chat.TextComponent tc = + new net.md_5.bungee.api.chat.TextComponent(""); + for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(lineText)) + tc.addExtra(bc); + team.setPrefix(Either.right(tc)); + team.setSuffix(Either.right(new net.md_5.bungee.api.chat.TextComponent(""))); + team.setDisplayName(Either.right(new net.md_5.bungee.api.chat.TextComponent(""))); + team.setNameTagVisibility(Either.right(net.md_5.bungee.protocol.packet.Team.NameTagVisibility.ALWAYS)); + team.setCollisionRule(Either.right(net.md_5.bungee.protocol.packet.Team.CollisionRule.ALWAYS)); + team.setColor(21); + team.setFriendlyFire((byte) 3); + sendPkt.invoke(p, team); + } + } catch (Exception ignored) {} + } + } + + /** Schneller Task: aktualisiert nur den Objective-Titel für flüssige Wave-Animation */ + private void tickTitle() { + if (!rainbowEnabled || !"wave".equals(rainbowMode)) return; + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (!p.isConnected()) continue; + UUID id = p.getUniqueId(); + if (hiddenPlayers.contains(id)) continue; + boolean isAdmin = !forcePlayerView.contains(id) + && (p.hasPermission(adminPermission) || forceAdminView.contains(id)); + Set activeCreated = isAdmin ? createdAdmin : created; + if (!activeCreated.contains(id)) continue; // noch nicht aufgebaut + try { + int rIdx = (rainbowIdx.getOrDefault(id, 0) + 1) % 10000; + rainbowIdx.put(id, rIdx); + String activeObjName = isAdmin ? OBJ_NAME_ADMIN : OBJ_NAME; + String rawTitle = isAdmin ? adminTitle : title; + String titleStr = rainbow(c(rawTitle), rIdx); + ScoreboardObjective obj = new ScoreboardObjective(); + obj.setName(activeObjName); + obj.setAction((byte) 2); // UPDATE_TITLE + net.md_5.bungee.api.chat.TextComponent tc = + new net.md_5.bungee.api.chat.TextComponent(""); + for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(titleStr)) + tc.addExtra(bc); + obj.setValue(Either.right(tc)); + obj.setType(HealthDisplay.INTEGER); + sendPkt.invoke(p, obj); + } catch (Exception ignored) {} + } + } + + private void tickAll() { + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (!p.isConnected()) continue; + UUID id = p.getUniqueId(); + try { + sendAll(p); + } catch (Exception e) { + plugin.getLogger().warning("[ScoreboardModule] " + p.getName() + ": " + e); + created.add(id); + } + } + } + + private void sendAll(ProxiedPlayer p) throws Exception { + if (!ready || !p.isConnected()) return; + UUID id = p.getUniqueId(); + + // Scoreboard ausgeblendet → Objective + Teams sauber entfernen (einmalig) und raus + if (hiddenPlayers.contains(id)) { + if (created.contains(id)) { + removeObjectiveAndTeams(p, OBJ_NAME, "vt"); + created.remove(id); + } + if (createdAdmin.contains(id)) { + removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); + createdAdmin.remove(id); + } + return; + } + + // forcePlayerView hat Vorrang vor Perm und forceAdminView + boolean isAdmin = !forcePlayerView.contains(id) + && (p.hasPermission(adminPermission) || forceAdminView.contains(id)); + + String rawTicker = stripColors(tickerText); + int tLen = Math.max(1, rawTicker.length()); + int tOff = (tickerPos.getOrDefault(id, 0) + tickerSpeed) % tLen; + tickerPos.put(id, tOff); + int rIdx = rainbowIdx.getOrDefault(id, 0); // wird von tickTitle aktualisiert + + String pn = p.getName(); + String rank = getRank(p); + String money = getMoney(p); + String srvRaw = p.getServer() != null ? p.getServer().getInfo().getName() : "?"; + String srv = srvRaw.isEmpty() ? srvRaw + : Character.toUpperCase(srvRaw.charAt(0)) + srvRaw.substring(1); + String comp = buildCompass(playerCompass.getOrDefault(id, "0")); + String hp = formatHealth(playerHealth.getOrDefault(id, 20.0)); + String hpNum = String.valueOf((int) Math.ceil(playerHealth.getOrDefault(id, 20.0) / 2.0)); + String ping = String.valueOf(p.getPing()); + String online = String.valueOf(ProxyServer.getInstance().getOnlineCount()); + // FIX: getPlayerLimit() gibt -1 wenn kein Limit gesetzt → "∞" anzeigen + int rawLimit = ProxyServer.getInstance().getConfig().getPlayerLimit(); + if (rawLimit <= 0) { + // Listener-Limit als Fallback + try { + java.util.Iterator limIt = + ProxyServer.getInstance().getConfig().getListeners().iterator(); + if (limIt.hasNext()) { + int listenerMax = limIt.next().getMaxPlayers(); + if (listenerMax > 0) rawLimit = listenerMax; + } + } catch (Exception ignored) {} + } + 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 playtime = formatPlaytime(id); + // Neue Placeholders + String xCoord = String.valueOf(playerX.getOrDefault(id, 0)); + String yCoord = String.valueOf(playerY.getOrDefault(id, 0)); + String zCoord = String.valueOf(playerZ.getOrDefault(id, 0)); + String world = playerWorld.getOrDefault(id, srv); + String gamemode = playerGamemode.getOrDefault(id, "?"); + String exp = String.valueOf(playerExp.getOrDefault(id, 0)); + String food = String.valueOf(playerFood.getOrDefault(id, 20)); + String foodSym = formatFood(playerFood.getOrDefault(id, 20)); + String speed = String.format("%.1f", playerSpeed.getOrDefault(id, 0.2) * 10); + String uptime = getUptime(); + // News: aktuellen Ticker-Stand lesen (wird von tickNews aktualisiert) + String news = buildNewsTicker(newsPos.getOrDefault(id, 0)); + String servers = String.valueOf(ProxyServer.getInstance().getServers().size()); + String proxymem = getRam(); + + // Per-Zeile Rotation: Zeilen mit mehreren Inhalten wechseln automatisch + Map> lineMap = (isAdmin && !adminLineMap.isEmpty()) ? adminLineMap : playerLineMap; + // Aktueller Page-Index basierend auf Zeit + int pageIdx = (rotationInterval > 0) + ? (int)((System.currentTimeMillis() / 1000) / rotationInterval) + : 0; + // Zeilen aufbauen: fuer jede Zeilennummer den aktuellen Inhalt waehlen + int lineCount = lineMap.isEmpty() ? 0 + : lineMap.keySet().stream().mapToInt(Integer::intValue).max().orElse(0); + List srcLines = new ArrayList<>(); + for (int ln = 1; ln <= lineCount; ln++) { + List variants = lineMap.get(ln); + if (variants == null || variants.isEmpty()) { + srcLines.add(""); + } else { + // Zeile mit 1 Variante = immer gleich; mit 2+ = rotieren + int vi = pageIdx % variants.size(); + srcLines.add(variants.get(vi)); + } + } + List lines = new ArrayList<>(); + boolean hasTicker = !tickerText.isEmpty() && !isAdmin; + if (hasTicker) lines.add(ticker(rawTicker, tOff, rIdx)); + // Maximale Inhaltszeilen: MAX_LINES insgesamt (Ticker zählt als eine) + for (String tpl : srcLines) { + if (lines.size() >= MAX_LINES) break; + lines.add(c(ph(tpl, pn, rank, money, srv, comp, hp, hpNum, ping, online, maxpl, tps, ram, time, playtime, + xCoord, yCoord, zCoord, world, gamemode, exp, food, foodSym, speed, uptime, servers, proxymem, date, news))); + } + // Immer genau MAX_LINES Zeilen (Rest mit Leerzeilen auffüllen) + if (lines.size() > MAX_LINES) lines = new ArrayList<>(lines.subList(0, MAX_LINES)); + while (lines.size() < MAX_LINES) lines.add(" "); + + // Admin-Scoreboard hat eigenen Objective-Namen und Titel + String activeObjName = isAdmin ? OBJ_NAME_ADMIN : OBJ_NAME; + String titleStr = isAdmin + ? rainbow(c(adminTitle), rIdx) + : rainbow(c(title), rIdx); + + // Wenn Admin-Status wechselt: altes Objective entfernen + // Wechsel zwischen Player- und Admin-Board: + // Altes Objective + alle zugehörigen Teams sauber entfernen + if (isAdmin && created.contains(id)) { + removeObjectiveAndTeams(p, OBJ_NAME, "vt"); + created.remove(id); + } else if (!isAdmin && createdAdmin.contains(id)) { + removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); + createdAdmin.remove(id); + } + + Set activeCreated = isAdmin ? createdAdmin : created; + + // Objective CREATE (einmalig) oder UPDATE_TITLE + boolean justCreated = !activeCreated.contains(id); + ScoreboardObjective obj = new ScoreboardObjective(); + obj.setName(activeObjName); + if (justCreated) { + obj.setAction((byte) 0); // CREATE + net.md_5.bungee.api.chat.TextComponent titleComp = + new net.md_5.bungee.api.chat.TextComponent(""); + for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(titleStr)) + titleComp.addExtra(bc); + obj.setValue(Either.right(titleComp)); + obj.setType(HealthDisplay.INTEGER); + sendPkt.invoke(p, obj); + + ScoreboardDisplay disp = new ScoreboardDisplay(); + disp.setPosition(1); // Sidebar + disp.setName(activeObjName); + sendPkt.invoke(p, disp); + + activeCreated.add(id); + } else { + obj.setAction((byte) 2); // UPDATE_TITLE + net.md_5.bungee.api.chat.TextComponent titleComp2 = + new net.md_5.bungee.api.chat.TextComponent(""); + for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(titleStr)) + titleComp2.addExtra(bc); + obj.setValue(Either.right(titleComp2)); + obj.setType(HealthDisplay.INTEGER); + sendPkt.invoke(p, obj); + } + + // Scores + Teams + for (int i = 0; i < MAX_LINES; i++) { + if (i < lines.size()) { + ScoreboardScore score = new ScoreboardScore(); + score.setItemName(ENTRIES[i]); + score.setScoreName(activeObjName); + score.setAction((byte) 0); + score.setValue(lines.size() - i); + // NumberFormat.BLANK versteckt die Zahlen rechts + try { + Class ntCls = Class.forName("net.md_5.bungee.protocol.data.NumberFormat$Type"); + Object blank = ntCls.getMethod("valueOf", String.class).invoke(null, "BLANK"); + Class nfCls = Class.forName("net.md_5.bungee.protocol.data.NumberFormat"); + Object nf = nfCls.getDeclaredConstructor(ntCls, Object.class).newInstance(blank, null); + score.getClass().getMethod("setNumberFormat", nfCls).invoke(score, nf); + } catch (Exception ignored) {} + sendPkt.invoke(p, score); + + Team team = new Team(); + team.setName((isAdmin ? "vta" : "vt") + i); + // CREATE wenn das Objective gerade frisch angelegt wurde, sonst UPDATE + team.setMode(justCreated ? (byte) 0 : (byte) 2); + // Hex-Farben: BaseComponent[] als Container-TextComponent verpacken + net.md_5.bungee.api.chat.BaseComponent[] prefixComp = + buildComponents(lines.get(i)); + net.md_5.bungee.api.chat.TextComponent prefixContainer = + new net.md_5.bungee.api.chat.TextComponent(""); + for (net.md_5.bungee.api.chat.BaseComponent bc : prefixComp) + prefixContainer.addExtra(bc); + team.setPrefix(Either.right(prefixContainer)); + team.setSuffix(Either.right(new net.md_5.bungee.api.chat.TextComponent(""))); + team.setDisplayName(Either.right(new net.md_5.bungee.api.chat.TextComponent(""))); + team.setNameTagVisibility(Either.right(NameTagVisibility.ALWAYS)); + team.setCollisionRule(Either.right(CollisionRule.ALWAYS)); + team.setColor(21); // RESET + team.setFriendlyFire((byte) 3); + team.setPlayers(new String[]{ ENTRIES[i] }); + sendPkt.invoke(p, team); + } else { + // Leeren Score entfernen + try { + Class cls = Class.forName("net.md_5.bungee.protocol.packet.ScoreboardScoreReset"); + Object pkt = cls.getDeclaredConstructor().newInstance(); + cls.getMethod("setEntity", String.class).invoke(pkt, ENTRIES[i]); + cls.getMethod("setObjective", String.class).invoke(pkt, activeObjName); + sendPkt.invoke(p, pkt); + } catch (Exception ignored) {} + } + } + } + + // ── Ticker & Rainbow ───────────────────────────────────────────────────── + + private String ticker(String raw, int offset, int rIdx) { + if (raw.isEmpty()) return " "; + String doubled = raw + raw; + String visible = doubled.substring(offset, offset + Math.min(tickerWidth, raw.length())); + return rainbow(visible, rIdx); + } + + /** + * Wave-Effekt: jeder Buchstabe bekommt eine interpolierte Farbe. + * + * mode="wave" → Farbwelle mit konfigurierbaren Farben (smooth interpoliert) + * mode="chars" → klassisch mit RAINBOW-Array + * mode="line" → gesamter Text in einer Farbe + * + * idx steigt pro Tick → Welle wandert von links nach rechts. + */ + private String rainbow(String text, int idx) { + if (!rainbowEnabled || text == null || text.isEmpty()) return text == null ? " " : text; + String plain = ChatColor.stripColor(text); + if (plain.isEmpty()) return text; + + if ("wave".equals(rainbowMode)) { + StringBuilder sb = new StringBuilder(); + boolean bold = text.contains("§l") || text.contains("&l"); + boolean italic = text.contains("§o") || text.contains("&o"); + boolean underline = text.contains("§n") || text.contains("&n"); + boolean strike = text.contains("§m") || text.contains("&m"); + String fmt = (bold ? "§l" : "") + (italic ? "§o" : "") + + (underline ? "§n" : "") + (strike ? "§m" : ""); + // Sichtbare Zeichen zählen + int visLen = 0; + for (char c : plain.toCharArray()) if (c != ' ') visLen++; + int charIdx = 0; + for (int i = 0; i < plain.length(); i++) { + char ch = plain.charAt(i); + if (ch == ' ') { sb.append(' '); continue; } + // JAWa-Style: Hue gleichmäßig über alle Buchstaben verteilt, wandert pro Tick + float hue = ((float) charIdx / Math.max(visLen, 1) + idx * this.waveSpeed) % 1.0f; + if (hue < 0) hue += 1.0f; + int[] rgb = waveColors != null + ? interpolateWaveColor(hue) // konfigurierte Farben + : hsbWave(hue); // Sinus-Regenbogen (Default) + sb.append('§').append('x'); + sb.append('§').append(String.format("%02X", rgb[0]).charAt(0)); + sb.append('§').append(String.format("%02X", rgb[0]).charAt(1)); + sb.append('§').append(String.format("%02X", rgb[1]).charAt(0)); + sb.append('§').append(String.format("%02X", rgb[1]).charAt(1)); + sb.append('§').append(String.format("%02X", rgb[2]).charAt(0)); + sb.append('§').append(String.format("%02X", rgb[2]).charAt(1)); + sb.append(fmt); + sb.append(ch); + charIdx++; + } + return sb.toString(); + } + if ("chars".equals(rainbowMode)) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < plain.length(); i++) { + sb.append(RAINBOW[(idx + i) % RAINBOW.length]); + sb.append(plain.charAt(i)); + } + return sb.toString(); + } + // mode="line": ganzer Text in einer Farbe + return RAINBOW[idx % RAINBOW.length] + plain; + } + + /** + * Interpoliert zwischen den konfigurierten Wellen-Farben. + * pos = 0.0 .. 1.0 (Position in der Welle) + * Wenn keine Farben konfiguriert: HSB-Fallback (voller Regenbogen) + */ + /** + * Verarbeitet %gradient:FARBE1:FARBE2:TEXT% Placeholder. + * + * Beispiele: + * %gradient:#FF0000:#0000FF:Hallo Welt% + * %gradient:&c:&9:> Player Info:% + * %gradient:#FF0000:#FFFF00:#0000FF:Drei Farben% (beliebig viele Stopps) + * + * Auch mit Formatierung: + * %gradient:#00FFFF:#0000FF:&l> Player Info:% + * + * Der Text darf Farb-/Formatcodes enthalten – sie werden nach jedem + * Farbcode wiederhergestellt. + */ + 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; } + // Parse: %gradient:C1:C2:...:TEXT% + String inner = input.substring(start + 10, end); + // Letzter Teil ist der Text, vorherige Teile sind Farben + // Text beginnt nach dem letzten ':' der eine Farbe abschließt + // Strategie: Teile von links lesen solange sie Farben sind + java.util.List stops = new java.util.ArrayList<>(); + int colonIdx = 0; + while (colonIdx < inner.length()) { + // Nächsten ':' suchen + 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) { + // Nicht genug Farben – unveraendert lassen + result.append(input, start, end + 1); + i = end + 1; + continue; + } + String text = inner.substring(colonIdx); + // Formatcodes aus Text extrahieren + String plain = ChatColor.stripColor(c(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" : ""); + // Gradient auf sichtbare Zeichen anwenden + int visLen = 0; + for (char ch : plain.toCharArray()) if (ch != ' ') visLen++; + int charIdx = 0; + for (char ch : plain.toCharArray()) { + if (ch == ' ') { result.append(' '); continue; } + float pos = visLen <= 1 ? 0f : (float) charIdx / (visLen - 1); + int[] rgb = interpolateGradient(stops, pos); + result.append('\u00A7').append('x'); + result.append('\u00A7').append(String.format("%02X", rgb[0]).charAt(0)); + result.append('\u00A7').append(String.format("%02X", rgb[0]).charAt(1)); + result.append('\u00A7').append(String.format("%02X", rgb[1]).charAt(0)); + result.append('\u00A7').append(String.format("%02X", rgb[1]).charAt(1)); + result.append('\u00A7').append(String.format("%02X", rgb[2]).charAt(0)); + result.append('\u00A7').append(String.format("%02X", rgb[2]).charAt(1)); + result.append(fmt); + result.append(ch); + charIdx++; + } + i = end + 1; + } + return result.toString(); + } + + private int[] parseGradientColor(String s) { + s = s.trim(); + // Hex: #RRGGBB oder &#RRGGBB + 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) {} + } + // MC &-Code: &0-&f + if (s.startsWith("&") && s.length() == 2) { + return mcColorToRgb(s.charAt(1)); + } + return null; + } + + private int[] interpolateGradient(java.util.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); + int i1 = i0 + 1; + float t = scaled - i0; + return new int[]{ + (int)(stops.get(i0)[0] * (1-t) + stops.get(i1)[0] * t), + (int)(stops.get(i0)[1] * (1-t) + stops.get(i1)[1] * t), + (int)(stops.get(i0)[2] * (1-t) + stops.get(i1)[2] * t) + }; + } + + /** Sinus-basierter Regenbogen (JAWa-Style) – smooth, volle Farbpalette */ + private static int[] hsbWave(float hue) { + double h = hue * 2 * Math.PI; + int r = (int)(Math.sin(h) * 127.5 + 127.5); + int g = (int)(Math.sin(h + 2*Math.PI/3) * 127.5 + 127.5); + int b = (int)(Math.sin(h + 4*Math.PI/3) * 127.5 + 127.5); + return new int[]{ + Math.max(0, Math.min(255, r)), + Math.max(0, Math.min(255, g)), + Math.max(0, Math.min(255, b)) + }; + } + + /** Wandelt einen Minecraft &-Farbcode (0-9, a-f) in RGB um */ + private static int[] mcColorToRgb(char code) { + switch (Character.toLowerCase(code)) { + case '0': return new int[]{ 0, 0, 0}; // §0 Schwarz + case '1': return new int[]{ 0, 0, 170}; // §1 Dunkelblau + case '2': return new int[]{ 0, 170, 0}; // §2 Dunkelgrün + case '3': return new int[]{ 0, 170, 170}; // §3 Dunkeltürkis + case '4': return new int[]{170, 0, 0}; // §4 Dunkelrot + case '5': return new int[]{170, 0, 170}; // §5 Lila + case '6': return new int[]{255, 170, 0}; // §6 Gold + case '7': return new int[]{170, 170, 170}; // §7 Grau + case '8': return new int[]{ 85, 85, 85}; // §8 Dunkelgrau + case '9': return new int[]{ 85, 85, 255}; // §9 Blau + case 'a': return new int[]{ 85, 255, 85}; // §a Hellgrün + case 'b': return new int[]{ 85, 255, 255}; // §b Türkis + case 'c': return new int[]{255, 85, 85}; // §c Hellrot + case 'd': return new int[]{255, 85, 255}; // §d Hellviolett + case 'e': return new int[]{255, 255, 85}; // §e Gelb + case 'f': return new int[]{255, 255, 255}; // §f Weiß + default: return null; + } + } + + private int[] interpolateWaveColor(float pos) { + if (waveColors == null || waveColors.length == 0) { + // HSB-Fallback: voller Regenbogen + java.awt.Color c = java.awt.Color.getHSBColor(pos, 1.0f, 1.0f); + return new int[]{c.getRed(), c.getGreen(), c.getBlue()}; + } + int n = waveColors.length; + float scaled = pos * n; + int i0 = (int) scaled % n; + int i1 = (i0 + 1) % n; + float t = scaled - (int) scaled; // 0.0 .. 1.0 zwischen i0 und i1 + int r = (int)(waveColors[i0][0] * (1-t) + waveColors[i1][0] * t); + int g = (int)(waveColors[i0][1] * (1-t) + waveColors[i1][1] * t); + int b = (int)(waveColors[i0][2] * (1-t) + waveColors[i1][2] * t); + return new int[]{r, g, b}; + } + + // ── Placeholder ─────────────────────────────────────────────────────────── + + private String ph(String tpl, String player, String rank, String money, String server, + String compass, String health, String healthNum, String ping, String online, String maxplayers, + String tps, String ram, String time, String playtime, + String x, String y, String z, String world, String gamemode, + String exp, String food, String foodSymbol, String speed, + String uptime, String servers, String proxymem, String date, String news) { + if (tpl == null) return " "; + String s = tpl + .replace("%player%", player) .replace("%rank%", rank) + .replace("%money%", money) .replace("%server%", server) + .replace("%compass%", compass) .replace("%health%", health) + .replace("%hearts%", healthNum) .replace("%ping%", ping) + .replace("%online%", online) .replace("%maxplayers%", maxplayers) + .replace("%tps%", tps) .replace("%ram%", ram) + .replace("%time%", time) .replace("%playtime%", playtime) + .replace("%x%", x) .replace("%y%", y) + .replace("%z%", z) .replace("%world%", world) + .replace("%gamemode%", gamemode) .replace("%exp%", exp) + .replace("%food%", food) .replace("%foodsym%", foodSymbol) + .replace("%speed%", speed) + .replace("%uptime%", uptime) .replace("%servers%", servers) + .replace("%proxymem%", proxymem) + .replace("%date%", date) + .replace("%news%", news); + s = applyGradients(s); + s = s.replace("%line%", c(separator)); + return s.isEmpty() ? " " : s; + } + + // ── Daten-Helfer ───────────────────────────────────────────────────────── + + private String getRank(ProxiedPlayer p) { + try { + Class prov = Class.forName("net.luckperms.api.LuckPermsProvider"); + Object api = prov.getMethod("get").invoke(null); + Object um = api.getClass().getMethod("getUserManager").invoke(api); + Object usr = um.getClass().getMethod("getUser", UUID.class).invoke(um, p.getUniqueId()); + if (usr != null) { + Class qo = Class.forName("net.luckperms.api.query.QueryOptions"); + Object opts = qo.getMethod("defaultContextualOptions").invoke(null); + Object cache= usr.getClass().getMethod("getCachedData").invoke(usr); + Object meta = cache.getClass().getMethod("getMetaData", qo).invoke(cache, opts); + Object pfx = meta.getClass().getMethod("getPrefix").invoke(meta); + if (pfx != null && !pfx.toString().isEmpty()) return pfx.toString(); + Object pg = usr.getClass().getMethod("getPrimaryGroup").invoke(usr); + if (pg != null) return pg.toString().toUpperCase(); + } + } catch (Exception ignored) {} + return "Spieler"; + } + + private String getMoney(ProxiedPlayer p) { + try { + Map bal = (Map) StatusAPI.class.getField("playerBalances").get(null); + Object v = bal.get(p.getUniqueId()); + if (v != null) return df.format(((Number) v).doubleValue()); + } catch (Exception ignored) {} + return "0" + decimalSeparator + "00"; + } + + private String formatHealth(double hp) { + int full = (int)(hp / 2.0); + int half = (hp % 2 >= 1) ? 1 : 0; + int empty = 10 - full - half; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < full; i++) sb.append("\u2665"); // ♥ voll + if (half > 0) sb.append("\u2665"); // ♥ halb + for (int i = 0; i < empty; i++) sb.append("\u2661"); // ♡ leer + return sb.length() > 0 ? sb.toString() : "\u2661"; + } + + private String formatFood(int food) { + // Food 0-20, je 2 Punkte = ein Symbol, max 10 Slots + int full = food / 2; + int half = food % 2; + int empty = 10 - full - half; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < full; i++) sb.append("\u25C6"); // ◆ voll + if (half > 0) sb.append("\u25C7"); // ◇ halb + for (int i = 0; i < empty; i++) sb.append("\u25C7"); // ◇ leer + return sb.toString(); + } + + private String formatPlaytime(UUID id) { + Long joinTime = joinTimes.get(id); + if (joinTime == null) return "00 00:00:00"; + long seconds = (System.currentTimeMillis() - joinTime) / 1000; + long days = seconds / 86400; + long hours = (seconds % 86400) / 3600; + long mins = (seconds % 3600) / 60; + long secs = seconds % 60; + return String.format("%02d %02d:%02d:%02d", days, hours, mins, secs); + } + + /** + * Baut den scrollenden Kompass-Balken. + * + * Akzeptiert zwei Formate: + * - Richtungstext: "N", "NE", "E", "SE", "S", "SW", "W", "NW" (Bridge-Altformat) + * - Yaw als Float: "-45.5" (Minecraft-Yaw: 0=S, 90=W, 180=N, -90=E) + * + * Beispiel-Output (Blick nach N): "- - - - &c&lN&r&7 - - - -" + */ + /** + * Baut den Kompass-Balken mit Sub-Grad-Auflösung. + * + * Das Fenster hat COMPASS_WIN Slots (z.B. 9). Jeder Slot entspricht genau + * 1 Grad auf dem Kreis (COMPASS_SLOTS = 360). Dadurch verschiebt sich der + * Balken bei jeder 1°-Änderung um genau eine Position – kein Springen. + * + * Jeder Slot zeigt: + * - 'N' / 'E' / 'S' / 'W' wenn sein Grad-Slot mit einem Himmelsrichtungs- + * Label übereinstimmt (±0°, kein Runden) + * - '|' für den Mittelpunkt (aktuelle Blickrichtung), + * falls kein Label genau trifft + * - '·' für alle anderen Slots + * + * Akzeptierte raw-Formate: + * Float-String "normYaw" (0..360): Bridge sendet normYaw = ((yaw%360)+360)%360 + * Richtungstext "N"/"NE"/"E"/…: Legacy-Fallback + */ + /** + * Kompass-Balken: WIN=15 Slots, 1°/Slot, Himmelsrichtungen farbig. + * + * Zeichen: + * '─' normaler Slot (grau, &8) + * N/E/S/W außerhalb Mitte: gelb &e + * Mitte mit Himmelsrichtung: rot+fett &c&l + * Mitte ohne Himmelsrichtung: rot+fett &c&l '|' + * + * Bridge sendet normYaw 0..360 (0 = Süden/MC-Konvention). + * Umrechnung: facingDeg = (normYaw + 180) % 360 → 0=N, 90=E, 180=S, 270=W + */ + private static final int SCOREBOARD_WIDTH = 26; // sichtbare Breite des Scoreboards + + private String buildCompass(String raw) { + if (raw == null || raw.isEmpty()) raw = "180"; + + float facingDeg; + try { + float normYaw = Float.parseFloat(raw); + facingDeg = (normYaw + 180.0f) % 360.0f; + } catch (NumberFormatException e) { + switch (raw.trim().toUpperCase()) { + case "N": facingDeg = 0; break; + case "NE": facingDeg = 45; break; + case "E": facingDeg = 90; break; + case "SE": facingDeg = 135; break; + case "S": facingDeg = 180; break; + case "SW": facingDeg = 225; break; + case "W": facingDeg = 270; break; + case "NW": facingDeg = 315; break; + default: return raw; + } + } + + int half = COMPASS_WIN / 2; + int totalSlots = COMPASS_SLOTS / COMPASS_DEG_PER_SLOT; + int centerSlot = (Math.round(facingDeg) / COMPASS_DEG_PER_SLOT) % totalSlots; + + StringBuilder sb = new StringBuilder(); + for (int i = -half; i <= half; i++) { + int slot = ((centerSlot + i) % totalSlots + totalSlots) % totalSlots; + int slotDeg = (slot * COMPASS_DEG_PER_SLOT) % COMPASS_SLOTS; + + // Himmelsrichtungs-Label? + char label = 0; + for (int k = 0; k < COMPASS_LABEL_DEG.length; k++) { + if (slotDeg == COMPASS_LABEL_DEG[k]) { label = COMPASS_LABEL_CH[k]; break; } + } + + if (i == 0) { + // Mitte: rot + fett; Himmelsrichtung oder '|' als Cursor + char marker = (label != 0) ? label : '|'; + sb.append("&c&l").append(marker).append("&r&8"); + } else if (label != 0) { + // Himmelsrichtung außerhalb Mitte: gelb, gut sichtbar + sb.append("&e").append(label).append("&8"); + } else { + sb.append('-'); // ASCII-Strich, sicher für alle MC-Versionen + } + } + // Kompass zentrieren: Leerzeichen links = (Scoreboard-Breite - Kompass-Breite) / 2 + int padding = Math.max(0, (26 - COMPASS_WIN) / 2); + StringBuilder padded = new StringBuilder(); + for (int p = 0; p < padding; p++) padded.append(' '); + padded.append(sb); + return padded.toString(); + } + + /** + * Baut den News-Ticker: Text gleitet von rechts nach links durch ein fixes Fenster. + * + * Das Fenster ist IMMER exakt newsWidth Zeichen breit – Scoreboard-Breite konstant. + * Text erscheint von rechts, läuft durch, verschwindet links. + * Dann Pause (Leerzeichen) bevor der Text wieder von rechts einläuft. + * + * newsPrefix ist optional – leer lassen in Config zum Deaktivieren. + */ + private String buildNewsTicker(int offset) { + if (newsText == null || newsText.isEmpty()) return ""; + String plain = ChatColor.stripColor(c(newsText)).trim(); + if (plain.isEmpty()) return ""; + String prefix = (newsPrefix != null && !newsPrefix.isEmpty()) ? c(newsPrefix) : ""; + int pfxLen = ChatColor.stripColor(prefix).length(); + int winWidth = Math.max(4, newsWidth - pfxLen); + + // Text + 4 Leerzeichen Pause → dann sofort wieder von rechts + // Zyklus = plain.length() + 4 + int gap = 4; + int cycleLen = plain.length() + gap; + int pos = offset % cycleLen; + + // Virtuelles Band: plain + 4 Leerzeichen, läuft zyklisch + // Fenster zeigt winWidth Zeichen aus dem Band + // Band-Position des ersten Fensterzeichens: pos - winWidth + 1 + StringBuilder window = new StringBuilder(); + for (int i = 0; i < winWidth; i++) { + int bandIdx = pos - winWidth + 1 + i; + if (bandIdx < 0 || bandIdx >= plain.length()) { + window.append(' '); + } else { + window.append(plain.charAt(bandIdx)); + } + } + return prefix + c("&f") + window.toString(); + } + + private static final long START_TIME = System.currentTimeMillis(); + + private String getUptime() { + long s = (System.currentTimeMillis() - START_TIME) / 1000; + return String.format("%02d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60); + } + + private String getTps(UUID id) { + // Primär: TPS vom Backend-Server (per POST /scoreboard/tps gesendet) + Double t = playerTps.get(id); + if (t != null) { + return new DecimalFormat("0.0").format(Math.min(20.0, t)); + } + // FIX: Fallback auf Proxy-eigene TPS aus NetworkInfoModule (immer verfügbar) + if (networkInfoModule != null && networkInfoModule.isEnabled()) { + double proxyTps = networkInfoModule.getProxyTps(); + return new DecimalFormat("0.0").format(Math.min(20.0, proxyTps)) + " (P)"; + } + return "N/A"; + } + + private String getRam() { + MemoryMXBean m = ManagementFactory.getMemoryMXBean(); + return (m.getHeapMemoryUsage().getUsed() / 1048576) + + "MB/" + (m.getHeapMemoryUsage().getMax() / 1048576) + "MB"; + } + + // ── Component Builder (Hex-Farb-Support für Scoreboard) ───────────────────── + + /** + * Wandelt einen bereits mit c() prozessierten String (§-Codes + §x§R§R§G§G§B§B) + * in BaseComponent[] um, die BungeeCord korrekt ins Scoreboard-Packet schreibt. + * + * Hex-Farben werden als echte net.md_5.bungee.api.ChatColor RGB-Instanzen gesetzt, + * nicht als Legacy-String – das ist der einzige Weg der in Sidebar-Packets funktioniert. + */ + private static net.md_5.bungee.api.chat.BaseComponent[] buildComponents(String text) { + if (text == null || text.isEmpty()) + return new net.md_5.bungee.api.chat.BaseComponent[]{ + new net.md_5.bungee.api.chat.TextComponent("")}; + + java.util.List parts = new java.util.ArrayList<>(); + net.md_5.bungee.api.ChatColor currentColor = net.md_5.bungee.api.ChatColor.WHITE; + boolean bold = false, italic = false, underline = false, strike = false, magic = false; + + int i = 0; + StringBuilder buf = new StringBuilder(); + + while (i < text.length()) { + char c = text.charAt(i); + + // §x§R§R§G§G§B§B – RGB Hex (14 Zeichen) + if (c == '§' && i + 13 <= text.length() - 1 && text.charAt(i+1) == 'x') { + // Flush buffer + if (buf.length() > 0) { + parts.add(makeComp(buf.toString(), currentColor, bold, italic, underline, strike, magic)); + buf.setLength(0); + } + try { + // §x§R§R§G§G§B§B: chars at i+3,i+5,i+7,i+9,i+11,i+13 are the hex digits + String hex = "" + text.charAt(i+3) + text.charAt(i+5) + + text.charAt(i+7) + text.charAt(i+9) + + text.charAt(i+11) + text.charAt(i+13); + currentColor = net.md_5.bungee.api.ChatColor.of("#" + hex); + // Formatierungen NICHT zurücksetzen – Bold/Italic bleiben erhalten + } catch (Exception ignored) {} + i += 14; + continue; + } + + // §X – normale Farb/Format-Codes + if (c == '§' && i + 1 < text.length()) { + char code = Character.toLowerCase(text.charAt(i + 1)); + if (buf.length() > 0) { + parts.add(makeComp(buf.toString(), currentColor, bold, italic, underline, strike, magic)); + buf.setLength(0); + } + switch (code) { + case 'r': currentColor = net.md_5.bungee.api.ChatColor.WHITE; + bold=false; italic=false; underline=false; strike=false; magic=false; break; + case 'l': bold = true; break; + case 'o': italic = true; break; + case 'n': underline = true; break; + case 'm': strike = true; break; + case 'k': magic = true; break; + default: + net.md_5.bungee.api.ChatColor col = + net.md_5.bungee.api.ChatColor.getByChar(code); + if (col != null) { + currentColor = col; + bold=false; italic=false; underline=false; strike=false; magic=false; + } + } + i += 2; + continue; + } + + buf.append(c); + i++; + } + if (buf.length() > 0) + parts.add(makeComp(buf.toString(), currentColor, bold, italic, underline, strike, magic)); + + if (parts.isEmpty()) + return new net.md_5.bungee.api.chat.BaseComponent[]{ + new net.md_5.bungee.api.chat.TextComponent("")}; + return parts.toArray(new net.md_5.bungee.api.chat.BaseComponent[0]); + } + + private static net.md_5.bungee.api.chat.TextComponent makeComp( + String text, net.md_5.bungee.api.ChatColor color, + boolean bold, boolean italic, boolean underline, boolean strike, boolean magic) { + net.md_5.bungee.api.chat.TextComponent tc = new net.md_5.bungee.api.chat.TextComponent(text); + tc.setColor(color); + if (bold) tc.setBold(true); + if (italic) tc.setItalic(true); + if (underline) tc.setUnderlined(true); + if (strike) tc.setStrikethrough(true); + if (magic) tc.setObfuscated(true); + return tc; + } + + // ── Farb-Hilfsmethoden ──────────────────────────────────────────────────── + + private static String c(String s) { + if (s == null) return " "; + return ChatColor.translateAlternateColorCodes('&', hexToSection(s)); + } + + private static String hexToSection(String text) { + if (text == null || !text.contains("&#")) return text == null ? "" : text; + StringBuilder sb = new StringBuilder(); + int i = 0; + while (i < text.length()) { + if (i + 7 <= text.length() && text.charAt(i) == '&' && text.charAt(i+1) == '#') { + String hex = text.substring(i+2, i+8); + if (hex.matches("[0-9a-fA-F]{6}")) { + sb.append('\u00A7').append('x'); + for (char ch : hex.toCharArray()) sb.append('\u00A7').append(ch); + i += 8; continue; + } + } + sb.append(text.charAt(i++)); + } + return sb.toString(); + } + + private static String stripColors(String s) { + return s == null ? "" : ChatColor.stripColor(c(s)); + } + + // ── Config ─────────────────────────────────────────────────────────────── + + private void ensureConfigExists() { + File f = new File(plugin.getDataFolder(), CONFIG_FILE); + if (f.exists()) return; + if (!plugin.getDataFolder().exists()) plugin.getDataFolder().mkdirs(); + String content = + "# ScoreboardModule Konfiguration\n" + + "# Platzhalter Spieler: %player% %rank% %money% %server% %compass% %health% %hearts% %date%\n" + + "# %ping% %online% %maxplayers% %time% %playtime% %news%\n" + + "# %x% %y% %z% %world% %gamemode% %exp% %food% %foodsym% %speed%\n" + + "# Platzhalter Admin: %tps% %ram% %proxymem% %uptime% %servers%\n" + + "# Gradient: %gradient:FARBE1:FARBE2:TEXT%\n" + + "# Sonstiges: %line%\n" + + "# Farben: &-Codes und Hex &#FF6600\n\n" + + "scoreboard.enabled=true\n" + + "scoreboard.update_interval=500\n" + + "scoreboard.title=&lViper Network\n" + + "scoreboard.admin_title=&l[Admin] Panel\n\n" + + "scoreboard.ticker.text=\n" + + "scoreboard.ticker.width=26\n" + + "scoreboard.ticker.speed=1\n\n" + + "scoreboard.rainbow.enabled=true\n" + + "# wave=fließende Welle, chars=Regenbogen pro Buchstabe, line=eine Farbe\n" + + "scoreboard.rainbow.mode=wave\n" + + "# Wellengeschwindigkeit: 1=sehr langsam, 10=normal, 50=schnell\n" + + "scoreboard.rainbow.speed=10\n" + + "# Leer = voller HSB-Regenbogen\n" + + "scoreboard.rainbow.colors=#FF0000,#FF6600,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF\n\n" + + "scoreboard.admin_permission=statusapi.scoreboard.admin\n\n" + + "scoreboard.time_format=HH:mm\n" + + "scoreboard.date_format=dd.MM.yyyy\n" + + "scoreboard.timezone=Europe/Berlin\n" + + "scoreboard.money_format=#,##0.00\n" + + "scoreboard.money_decimal_separator=,\n\n" + + "# SEPARATOR – wird als %line% Placeholder genutzt\n" + + "# scoreboard.separator=&8&m-------------------- (Standard)\n" + + "# scoreboard.separator=&8&m==================== (Doppelt)\n" + + "# scoreboard.separator=&8&m~~~~~~~~~~~~~~~~~~~~ (Wellig)\n" + + "# scoreboard.separator=&8&m──────────────────── (Duenn)\n" + + "# scoreboard.separator=&8&m════════════════════ (Dick)\n" + + "# scoreboard.separator=%gradient:&8:&7:────────────────────% (Gradient)\n" + + "# scoreboard.separator= (Leer)\n" + + "scoreboard.separator=&8&m--------------------\n" + + "# News-Ticker (erscheint als %news% Placeholder)\n" + + "scoreboard.news.text=&eWillkommen auf Viper Network!\n" + + "scoreboard.news.prefix=&8[&6News&8] &r\n" + + "scoreboard.news.width=20\n" + + "scoreboard.news.speed=1\n\n" + + "scoreboard.rotation_interval=4\n" + + "# ===================================================\n" + + "# ZEILEN - max 15 sichtbar\n" + + "# ===================================================\n" + + "scoreboard.lines.1=%line%\n" + + "scoreboard.lines.2=%gradient:&b:&f:&b:&l> Player Info:%\n" + + "scoreboard.lines.3=&7%rank% &f%player%\n" + + "scoreboard.lines.4=\n" + + "scoreboard.lines.5=&7Spielzeit: &f%playtime%\n" + + "scoreboard.lines.5.2=&7Leben: &c%health%\n" + + "scoreboard.lines.5.3=&7Hunger: B4513%foodsym%\n" + + "scoreboard.lines.6=\n" + + "scoreboard.lines.7=%gradient:&b:&f:&b:&l> Money:%\n" + + "scoreboard.lines.8=&a$%money%\n" + + "scoreboard.lines.9=\n" + + "scoreboard.lines.10=%gradient:&b:&f:&b:&l> Server Info:%\n" + + "scoreboard.lines.11=&f%server%\n" + + "scoreboard.lines.11.2=&7Ping: &f%ping%ms &8| &7Online: &f%online%\n" + + "scoreboard.lines.12=\n" + + "scoreboard.lines.13=%news%\n" + + "scoreboard.lines.14=%line%\n" + + "scoreboard.lines.15=&7%compass%\n" + + "# ===================================================\n" + + "# ADMIN-ZEILEN\n" + + "# ===================================================\n" + + "scoreboard.admin_lines.1=%line%\n" + + "scoreboard.admin_lines.2=%gradient:&b:&f:&b:&l> Player Info:%\n" + + "scoreboard.admin_lines.3=&7%rank% &f%player%\n" + + "scoreboard.admin_lines.4=&7Gamemode: &f%gamemode%\n" + + "scoreboard.admin_lines.5=&7Leben: &c%health%\n" + + "scoreboard.admin_lines.5.2=&7Hunger: B4513%foodsym%\n" + + "scoreboard.admin_lines.6=\n" + + "scoreboard.admin_lines.7=%gradient:&b:&f:&b:&l> Server Info:%\n" + + "scoreboard.admin_lines.8=&f%server% &8| &7RAM: &e%ram%\n" + + "scoreboard.admin_lines.8.2=&7Proxy: &f%uptime%\n" + + "scoreboard.admin_lines.9=\n" + + "scoreboard.admin_lines.10=&7TPS: &a%tps%\n" + + "scoreboard.admin_lines.11=\n" + + "scoreboard.admin_lines.12=&7Spieler: &f%online% &8| &7%maxplayers%\n" + + "scoreboard.admin_lines.13=%news%\n" + + "scoreboard.admin_lines.14=%line%\n" + + "scoreboard.admin_lines.15=&7%compass%\n" + + "scoreboard.admin_lines.15.2=&7Pos: X:&f%x% &7Y:&f%y% &7Z:&f%z%\n"; + try (OutputStream out = new FileOutputStream(f)) { + out.write(content.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + plugin.getLogger().warning("[ScoreboardModule] Config: " + e.getMessage()); + } + } + + private void loadConfig() { + File file = new File(plugin.getDataFolder(), CONFIG_FILE); + Map map = new LinkedHashMap<>(); + if (file.exists()) { + try (BufferedReader br = new BufferedReader( + new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) { + String line; + while ((line = br.readLine()) != null) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) continue; + int eq = line.indexOf('='); + if (eq < 1) continue; + map.put(line.substring(0, eq).trim(), line.substring(eq + 1)); + } + } catch (Exception e) { + plugin.getLogger().warning("[ScoreboardModule] Ladefehler: " + e.getMessage()); + } + } + java.util.function.BiFunction g = (k,d) -> map.getOrDefault(k, d); + enabled = Boolean.parseBoolean(g.apply("scoreboard.enabled", "true")); + updateInterval = Math.max(250, pi(g.apply("scoreboard.update_interval", "500"), 500)); + title = g.apply("scoreboard.title", "&6&lViper Network"); + adminTitle = g.apply("scoreboard.admin_title", "&c&l[Admin] &4&lPanel"); + tickerText = g.apply("scoreboard.ticker.text", " Viper Network "); + tickerWidth = pi(g.apply("scoreboard.ticker.width", "26"), 26); + tickerSpeed = pi(g.apply("scoreboard.ticker.speed", "1"), 1); + rainbowEnabled = Boolean.parseBoolean(g.apply("scoreboard.rainbow.enabled", "true")); + rainbowMode = g.apply("scoreboard.rainbow.mode", "wave").trim().toLowerCase(); + // Wellen-Farben laden + // waveSpeed: Hue-Verschiebung pro Tick + // speed=1 → 0.005 pro Tick → bei 500ms-Interval ~100s pro Zyklus + // speed=10 → 0.05 pro Tick → ~10s pro Zyklus (empfohlen) + // speed=50 → 0.25 pro Tick → ~2s pro Zyklus + waveSpeed = Float.parseFloat(g.apply("scoreboard.rainbow.speed", "10")) * 0.005f; + String waveColorsStr = g.apply("scoreboard.rainbow.colors", ""); + if (!waveColorsStr.isEmpty()) { + String[] parts = waveColorsStr.split(","); + java.util.List cols = new java.util.ArrayList<>(); + for (String part : parts) { + String p = part.trim(); + // Hex-Farbe: #RRGGBB oder &#RRGGBB + if (p.startsWith("&#")) p = p.substring(1); + if (p.startsWith("#") && p.length() == 7) { + try { + int r = Integer.parseInt(p.substring(1,3),16); + int g2= Integer.parseInt(p.substring(3,5),16); + int b = Integer.parseInt(p.substring(5,7),16); + cols.add(new int[]{r,g2,b}); + } catch (Exception ignored) {} + // Minecraft &-Farbe: &0-&9, &a-&f + } else if (p.startsWith("&") && p.length() == 2) { + int[] rgb = mcColorToRgb(p.charAt(1)); + if (rgb != null) cols.add(rgb); + } + } + waveColors = cols.isEmpty() ? null : cols.toArray(new int[0][]); + } else { + waveColors = null; // HSB-Fallback + } + adminPermission = g.apply("scoreboard.admin_permission", "statusapi.scoreboard.admin"); + timeFormat = g.apply("scoreboard.time_format", "HH:mm"); + dateFormat = g.apply("scoreboard.date_format", "dd.MM.yyyy"); + timeZone = g.apply("scoreboard.timezone", "Europe/Berlin"); + moneyFormat = g.apply("scoreboard.money_format", "#,##0.00"); + 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)); + } catch (Exception e) { + sdf = new java.text.SimpleDateFormat("HH:mm"); + sdfDate = new java.text.SimpleDateFormat("dd.MM.yyyy"); + } + try { + df = new DecimalFormat(moneyFormat); + java.text.DecimalFormatSymbols sym = df.getDecimalFormatSymbols(); + if (!decimalSeparator.isEmpty()) sym.setDecimalSeparator(decimalSeparator.charAt(0)); + df.setDecimalFormatSymbols(sym); + } catch (Exception e) { df = new DecimalFormat("#,##0.00"); } + rotationInterval = pi(g.apply("scoreboard.rotation_interval", "4"), 4); + newsText = g.apply("scoreboard.news.text", ""); + newsPrefix = g.apply("scoreboard.news.prefix", "&8[&6News&8] &r"); + newsWidth = pi(g.apply("scoreboard.news.width", "20"), 20); + newsSpeed = pi(g.apply("scoreboard.news.speed", "1"), 1); + + // Pro Zeile alle Varianten laden: + // scoreboard.lines.N = Zeile N, Variante 1 (immer sichtbar) + // scoreboard.lines.N.2 = Zeile N, Variante 2 (rotiert nach interval) + // scoreboard.lines.N.3 = Zeile N, Variante 3 usw. + playerLineMap.clear(); + loadLineMap(map, "scoreboard.lines.", playerLineMap); + adminLineMap.clear(); + loadLineMap(map, "scoreboard.admin_lines.", adminLineMap); + + plugin.getLogger().info("[ScoreboardModule] " + + playerLineMap.size() + " Player-Zeilen, " + + adminLineMap.size() + " Admin-Zeilen. RotInterval=" + rotationInterval + "s"); + } + + /** + * Liest pro Zeilennummer alle Varianten aus der Config. + * + * Format: + * prefix + N = Variante 1 fuer Zeile N (immer sichtbar wenn nur 1 Variante) + * prefix + N + ".2" = Variante 2 fuer Zeile N (rotiert mit Variante 1) + * prefix + N + ".3" = Variante 3 fuer Zeile N usw. + * + * Zeilen ohne .2/.3 bleiben immer gleich. + * Zeilen MIT .2/.3 wechseln automatisch alle rotationInterval Sekunden. + */ + private void loadLineMap(Map map, String prefix, + Map> result) { + // Hoechste Zeilennummer finden + int maxLine = 0; + for (String key : map.keySet()) { + if (!key.startsWith(prefix)) continue; + String rest = key.substring(prefix.length()); + int dot = rest.indexOf('.'); + String numStr = (dot >= 0) ? rest.substring(0, dot) : rest; + try { + int ln = Integer.parseInt(numStr); + if (ln > maxLine) maxLine = ln; + } catch (NumberFormatException ignored) {} + } + + for (int ln = 1; ln <= maxLine; ln++) { + List variants = new ArrayList<>(); + // Variante 1 (kein Suffix) + String v1 = map.get(prefix + ln); + if (v1 != null) variants.add(v1); + // Variante 2, 3, ... (Suffix .2, .3, ...) + for (int p = 2; p <= 20; p++) { + String vp = map.get(prefix + ln + "." + p); + if (vp == null) break; + variants.add(vp); + } + if (!variants.isEmpty()) result.put(ln, variants); + else result.put(ln, java.util.Collections.singletonList("")); + } + } + + private int pi(String s, int fb) { + try { return Integer.parseInt(s == null ? "" : s.trim()); } + catch (Exception e) { return fb; } + } + + /** + * Entfernt ein Scoreboard-Objective und alle zugehörigen Teams sauber vom Client. + * Muss aufgerufen werden bevor ein anderes Objective aktiviert wird, + * sonst crasht der Client beim erneuten Team-CREATE. + * + * @param p Spieler + * @param objName Objective-Name (z.B. "vpsb" oder "vpsbadmin") + * @param teamPrefix Team-Prefix (z.B. "vt" oder "vta") + */ + private void removeObjectiveAndTeams(ProxiedPlayer p, String objName, String teamPrefix) { + // 1. Alle Teams löschen (Mode 1 = REMOVE) + for (int i = 0; i < 15; i++) { + try { + Team team = new Team(); + team.setName(teamPrefix + i); + team.setMode((byte) 1); // REMOVE + sendPkt.invoke(p, team); + } catch (Exception ignored) {} + } + // 2. Objective entfernen (Action 1 = REMOVE) + try { + ScoreboardObjective rem = new ScoreboardObjective(); + rem.setName(objName); + rem.setAction((byte) 1); + sendPkt.invoke(p, rem); + } catch (Exception ignored) {} + } + + // ── /scoreboard Toggle-Command ──────────────────────────────────────────── + /** + * /scoreboard → Scoreboard ein-/ausblenden + * /scoreboard hide → Scoreboard ausblenden + * /scoreboard show → Scoreboard einblenden + * /scoreboard player → Player-Scoreboard anzeigen + * /scoreboard admin → Admin-Scoreboard anzeigen (Perm: statusapi.scoreboard.admin) + * + * Aliase: /sb, /togglesb + */ + private class ScoreboardToggleCommand extends Command { + + ScoreboardToggleCommand() { + super("scoreboard", null, "sb", "togglesb"); + } + + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { + sender.sendMessage(new net.md_5.bungee.api.chat.TextComponent( + ChatColor.RED + "Nur für Spieler.")); + return; + } + ProxiedPlayer p = (ProxiedPlayer) sender; + UUID id = p.getUniqueId(); + + String sub = (args.length > 0) ? args[0].toLowerCase() : "toggle"; + + switch (sub) { + case "hide": + // Erst sauber entfernen, dann verstecken + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + hiddenPlayers.add(id); + p.sendMessage(msg("&7Scoreboard &causgeblendet&7. (/sb zum Einblenden)")); + break; + + case "show": + // Sauber entfernen → nächster tickAll baut neu auf + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + hiddenPlayers.remove(id); + p.sendMessage(msg("&7Scoreboard &aeingeblendet&7.")); + break; + + case "player": + // Aktuell angezeigtes Board sauber entfernen, dann auf Player umschalten + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + forcePlayerView.add(id); // überschreibt Admin-Perm + forceAdminView.remove(id); + hiddenPlayers.remove(id); + p.sendMessage(msg("&7Zeige &eSpieler&7-Scoreboard.")); + break; + + case "admin": + if (!p.hasPermission(adminPermission)) { + p.sendMessage(msg("&cKeine Berechtigung.")); + return; + } + // Aktuell angezeigtes Board sauber entfernen, dann auf Admin umschalten + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + forceAdminView.add(id); + forcePlayerView.remove(id); // Admin-Perm wieder aktiv + hiddenPlayers.remove(id); + p.sendMessage(msg("&7Zeige &cAdmin&7-Scoreboard.")); + break; + + default: // "toggle" + if (hiddenPlayers.contains(id)) { + // Einblenden: sauber entfernen → neu aufbauen + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + hiddenPlayers.remove(id); + p.sendMessage(msg("&7Scoreboard &aeingeblendet&7.")); + } else { + // Ausblenden + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + hiddenPlayers.add(id); + p.sendMessage(msg("&7Scoreboard &causgeblendet&7. (/sb zum Einblenden)")); + } + break; + } + } + + private net.md_5.bungee.api.chat.TextComponent msg(String text) { + return new net.md_5.bungee.api.chat.TextComponent(c(text)); + } + } + +} \ No newline at end of file diff --git a/StatusAPI/src/main/java/net/viper/status/modules/serverswitcher/ServerSwitcherModule.java b/StatusAPI/src/main/java/net/viper/status/modules/serverswitcher/ServerSwitcherModule.java index 68085ce..a0c649e 100644 --- a/StatusAPI/src/main/java/net/viper/status/modules/serverswitcher/ServerSwitcherModule.java +++ b/StatusAPI/src/main/java/net/viper/status/modules/serverswitcher/ServerSwitcherModule.java @@ -53,7 +53,7 @@ public class ServerSwitcherModule implements Module { private List aliases = new ArrayList<>(Arrays.asList("wechsel", "switch")); private List serverWhitelist = new ArrayList<>(); - private String colorHeader = "&8&m---&r &6&lServer-Menue &8&m---"; + private String colorHeader = "&8&m---&r &6&lServer-Menü &8&m---"; private String colorEntry = "&7>> &e"; private String colorOnline = "&a"; private String colorOffline = "&c"; diff --git a/StatusAPI/src/main/java/net/viper/status/stats/PlayerStats.java b/StatusAPI/src/main/java/net/viper/status/stats/PlayerStats.java index f07b922..1c9c04f 100644 --- a/StatusAPI/src/main/java/net/viper/status/stats/PlayerStats.java +++ b/StatusAPI/src/main/java/net/viper/status/stats/PlayerStats.java @@ -11,6 +11,10 @@ public class PlayerStats { public long currentSessionStart; public int joins; + // Combat + public int kills; + public int deaths; + // Economy public double balance; public double totalEarned; @@ -34,6 +38,8 @@ public class PlayerStats { this.totalPlaytime = 0; this.currentSessionStart = 0; this.joins = 0; + this.kills = 0; + this.deaths = 0; this.balance = 0.0; this.totalEarned = 0.0; this.totalSpent = 0.0; @@ -70,11 +76,35 @@ public class PlayerStats { return totalPlaytime; } + /** + * Format: uuid|name|firstSeen|lastSeen|totalPlaytime|currentSessionStart|joins| + * kills|deaths| + * balance|totalEarned|totalSpent|transactionsCount| + * bansCount|mutesCount|warnsCount|lastPunishmentAt|lastPunishmentType|punishmentScore + * + * HINWEIS: kills/deaths wurden in Version 1.17.1 als Felder 7 und 8 eingefügt. + * fromLine() ist rückwärtskompatibel (alte Dateien ohne kills/deaths werden 0 gesetzt). + */ public synchronized String toLine() { String safeType = (lastPunishmentType == null ? "" : lastPunishmentType).replace("|", "_"); - return uuid + "|" + name.replace("|", "_") + "|" + firstSeen + "|" + lastSeen + "|" + totalPlaytime + "|" + currentSessionStart + "|" + joins - + "|" + balance + "|" + totalEarned + "|" + totalSpent + "|" + transactionsCount - + "|" + bansCount + "|" + mutesCount + "|" + warnsCount + "|" + lastPunishmentAt + "|" + safeType + "|" + punishmentScore; + return uuid + "|" + name.replace("|", "_") + + "|" + firstSeen + + "|" + lastSeen + + "|" + totalPlaytime + + "|" + currentSessionStart + + "|" + joins + + "|" + kills + + "|" + deaths + + "|" + balance + + "|" + totalEarned + + "|" + totalSpent + + "|" + transactionsCount + + "|" + bansCount + + "|" + mutesCount + + "|" + warnsCount + + "|" + lastPunishmentAt + + "|" + safeType + + "|" + punishmentScore; } public static PlayerStats fromLine(String line) { @@ -84,26 +114,63 @@ public class PlayerStats { UUID uuid = UUID.fromString(parts[0]); String name = parts[1]; PlayerStats ps = new PlayerStats(uuid, name); - ps.firstSeen = Long.parseLong(parts[2]); - ps.lastSeen = Long.parseLong(parts[3]); - ps.totalPlaytime = Long.parseLong(parts[4]); + ps.firstSeen = Long.parseLong(parts[2]); + ps.lastSeen = Long.parseLong(parts[3]); + ps.totalPlaytime = Long.parseLong(parts[4]); ps.currentSessionStart = Long.parseLong(parts[5]); - ps.joins = Integer.parseInt(parts[6]); - // Economy (felder 7-10) - if (parts.length >= 11) { - try { ps.balance = Double.parseDouble(parts[7]); } catch (Exception ignored) {} - try { ps.totalEarned = Double.parseDouble(parts[8]); } catch (Exception ignored) {} - try { ps.totalSpent = Double.parseDouble(parts[9]); } catch (Exception ignored) {} - try { ps.transactionsCount = Integer.parseInt(parts[10]); } catch (Exception ignored) {} + ps.joins = Integer.parseInt(parts[6]); + + // Erkennung ob altes Format (ohne kills/deaths, Economy ab Index 7) + // oder neues Format (kills/deaths ab Index 7, Economy ab Index 9). + // Altes Format hat 17 Felder (Index 0-16), neues hat 19 (Index 0-18). + // Heuristik: Wenn parts[7] ein gültiger Integer ist UND parts[9] wie eine + // Gleitkommazahl aussieht → neues Format. Sonst altes Format. + boolean newFormat = false; + if (parts.length >= 19) { + try { + Integer.parseInt(parts[7]); // kills + Integer.parseInt(parts[8]); // deaths + Double.parseDouble(parts[9]); // balance (Gleitkomma) + newFormat = true; + } catch (Exception ignored) {} } - // Punishments (felder 11-16) - if (parts.length >= 17) { - try { ps.bansCount = Integer.parseInt(parts[11]); } catch (Exception ignored) {} - try { ps.mutesCount = Integer.parseInt(parts[12]); } catch (Exception ignored) {} - try { ps.warnsCount = Integer.parseInt(parts[13]); } catch (Exception ignored) {} - try { ps.lastPunishmentAt = Long.parseLong(parts[14]); } catch (Exception ignored) {} - ps.lastPunishmentType = parts[15]; - try { ps.punishmentScore = Integer.parseInt(parts[16]); } catch (Exception ignored) {} + + if (newFormat) { + // Neues Format: kills ab [7], deaths ab [8], economy ab [9] + try { ps.kills = Integer.parseInt(parts[7]); } catch (Exception ignored) {} + try { ps.deaths = Integer.parseInt(parts[8]); } catch (Exception ignored) {} + if (parts.length >= 13) { + try { ps.balance = Double.parseDouble(parts[9]); } catch (Exception ignored) {} + try { ps.totalEarned = Double.parseDouble(parts[10]); } catch (Exception ignored) {} + try { ps.totalSpent = Double.parseDouble(parts[11]); } catch (Exception ignored) {} + try { ps.transactionsCount = Integer.parseInt(parts[12]); } catch (Exception ignored) {} + } + if (parts.length >= 19) { + try { ps.bansCount = Integer.parseInt(parts[13]); } catch (Exception ignored) {} + try { ps.mutesCount = Integer.parseInt(parts[14]); } catch (Exception ignored) {} + try { ps.warnsCount = Integer.parseInt(parts[15]); } catch (Exception ignored) {} + try { ps.lastPunishmentAt = Long.parseLong(parts[16]); } catch (Exception ignored) {} + ps.lastPunishmentType = parts[17]; + try { ps.punishmentScore = Integer.parseInt(parts[18]); } catch (Exception ignored) {} + } + } else { + // Altes Format (kompatibel): kills/deaths = 0, Economy ab [7] + ps.kills = 0; + ps.deaths = 0; + if (parts.length >= 11) { + try { ps.balance = Double.parseDouble(parts[7]); } catch (Exception ignored) {} + try { ps.totalEarned = Double.parseDouble(parts[8]); } catch (Exception ignored) {} + try { ps.totalSpent = Double.parseDouble(parts[9]); } catch (Exception ignored) {} + try { ps.transactionsCount = Integer.parseInt(parts[10]); } catch (Exception ignored) {} + } + if (parts.length >= 17) { + try { ps.bansCount = Integer.parseInt(parts[11]); } catch (Exception ignored) {} + try { ps.mutesCount = Integer.parseInt(parts[12]); } catch (Exception ignored) {} + try { ps.warnsCount = Integer.parseInt(parts[13]); } catch (Exception ignored) {} + try { ps.lastPunishmentAt = Long.parseLong(parts[14]); } catch (Exception ignored) {} + ps.lastPunishmentType = parts[15]; + try { ps.punishmentScore = Integer.parseInt(parts[16]); } catch (Exception ignored) {} + } } return ps; } catch (Exception e) { diff --git a/StatusAPI/src/main/java/net/viper/status/stats/StatsModule.java b/StatusAPI/src/main/java/net/viper/status/stats/StatsModule.java index c4a2123..46baa0a 100644 --- a/StatusAPI/src/main/java/net/viper/status/stats/StatsModule.java +++ b/StatusAPI/src/main/java/net/viper/status/stats/StatsModule.java @@ -10,11 +10,20 @@ import net.viper.status.module.Module; import java.util.concurrent.TimeUnit; /** - * StatsModule: Kümmert sich eigenständig um das Tracking der Spielerdaten. - * Implementiert Module (für das Lifecycle) und Listener (für die Events). + * StatsModule: Tracking von Spielerdaten (Playtime, Joins, Kills, Deaths). + * + * Fixes: + * - BUG-1: Crash-Recovery für currentSessionStart (verhindert falsche Spielzeit nach Absturz) + * - BUG-2: kills / deaths werden jetzt getrackt und per POST /stats/update aktualisiert */ public class StatsModule implements Module, Listener { + /** + * Maximale Sessionlänge nach einem Crash noch gutschreiben (24 Stunden). + * Längere Differenzen sind unrealistisch → werden ignoriert, currentSessionStart = 0 gesetzt. + */ + private static final long MAX_SESSION_SECONDS = 86_400L; + private StatsManager manager; private StatsStorage storage; @@ -25,21 +34,64 @@ public class StatsModule implements Module, Listener { @Override public void onEnable(Plugin plugin) { - // Initialisierung manager = new StatsManager(); storage = new StatsStorage(plugin.getDataFolder()); - // Laden try { storage.load(manager); } catch (Exception e) { plugin.getLogger().warning("Fehler beim Laden der Stats: " + e.getMessage()); } - // Event Listener registrieren + // ----------------------------------------------------------------------- + // FIX BUG-1: Crash-Recovery – offene Sessions bereinigen. + // + // Bei normalem Shutdown setzt onDisable() currentSessionStart = 0 und speichert. + // Bei einem Crash (kill -9, OOM, etc.) passiert das nicht. Beim nächsten Start + // sind alle Spieler offline, aber currentSessionStart enthält noch den alten + // Timestamp. getPlaytimeWithCurrentSession() würde dann fälschlicherweise + // (now - alter_crash_timestamp) zur Spielzeit addieren → massiv falscher Wert. + // + // Fix: Nach dem Laden jeden Eintrag prüfen. Falls currentSessionStart > 0: + // - Plausible Differenz (≤ MAX_SESSION_SECONDS) → als echte Zeit gutschreiben + // - Unplausibel (> MAX_SESSION_SECONDS) → verwerfen, nur zurücksetzen + // - In beiden Fällen: currentSessionStart = 0 setzen + // ----------------------------------------------------------------------- + long now = System.currentTimeMillis() / 1000L; + int recovered = 0; + for (PlayerStats ps : manager.all()) { + synchronized (ps) { + if (ps.currentSessionStart > 0) { + long delta = now - ps.currentSessionStart; + if (delta > 0 && delta <= MAX_SESSION_SECONDS) { + ps.totalPlaytime += delta; + recovered++; + } else if (delta > MAX_SESSION_SECONDS) { + plugin.getLogger().warning( + "[StatsModule] Unplausibler currentSessionStart für " + ps.name + + " (delta=" + delta + "s > " + MAX_SESSION_SECONDS + "s). " + + "Session wird ohne Gutschrift zurückgesetzt." + ); + } + ps.currentSessionStart = 0; + } + } + } + if (recovered > 0) { + plugin.getLogger().info( + "[StatsModule] Crash-Recovery: " + recovered + " offene Session(en) bereinigt und gespeichert." + ); + try { + storage.save(manager); + } catch (Exception e) { + plugin.getLogger().warning("Fehler beim Speichern nach Crash-Recovery: " + e.getMessage()); + } + } + // ----------------------------------------------------------------------- + plugin.getProxy().getPluginManager().registerListener(plugin, this); - // Auto-Save Task (alle 5 Minuten) + // Auto-Save alle 5 Minuten plugin.getProxy().getScheduler().schedule(plugin, () -> { try { storage.save(manager); @@ -52,16 +104,13 @@ public class StatsModule implements Module, Listener { @Override public void onDisable(Plugin plugin) { if (manager != null && storage != null) { - // Laufende Sessions beenden vor dem Speichern long now = System.currentTimeMillis() / 1000L; for (PlayerStats ps : manager.all()) { synchronized (ps) { if (ps.currentSessionStart > 0) { long delta = now - ps.currentSessionStart; - if (delta > 0) { - ps.totalPlaytime += delta; - } - ps.currentSessionStart = 0; // Session beenden + if (delta > 0) ps.totalPlaytime += delta; + ps.currentSessionStart = 0; } } } @@ -73,7 +122,6 @@ public class StatsModule implements Module, Listener { } } - // Öffentlicher Zugriff für den WebServer public StatsManager getManager() { return manager; } diff --git a/StatusAPI/src/main/resources/plugin.yml b/StatusAPI/src/main/resources/plugin.yml index 667b0fa..5b85f87 100644 --- a/StatusAPI/src/main/resources/plugin.yml +++ b/StatusAPI/src/main/resources/plugin.yml @@ -10,6 +10,12 @@ softdepend: - Geyser-BungeeCord commands: + # ── ScoreboardModule ────────────────────────────────────── + scoreboard: + description: Scoreboard ein-/ausblenden oder zwischen Player/Admin wechseln + usage: /scoreboard [hide|show|player|admin] + aliases: [sb, togglesb] + # ── Economy ─────────────────────────────────────────────── pay: description: Überweise Geld an einen Spieler (auch offline) diff --git a/StatusAPI/src/main/resources/scoreboard.properties b/StatusAPI/src/main/resources/scoreboard.properties new file mode 100644 index 0000000..31027cb --- /dev/null +++ b/StatusAPI/src/main/resources/scoreboard.properties @@ -0,0 +1,115 @@ +# ScoreboardModule Konfiguration +# Platzhalter Spieler: %player% %rank% %money% %server% %compass% %health% %hearts% %date% +# %ping% %online% %maxplayers% %time% %playtime% %news% +# %x% %y% %z% %world% %gamemode% %exp% %food% %foodsym% %speed% +# Platzhalter Admin: %tps% %ram% %proxymem% %uptime% %servers% +# Gradient: %gradient:FARBE1:FARBE2:TEXT% (beliebig viele Farb-Stopps) +# Sonstiges: %line% +# Farben: &-Codes und Hex &#FF6600 + +scoreboard.enabled=true +# Update-Intervall in Millisekunden - MINIMUM 250! (500 = 0.5s empfohlen) +scoreboard.update_interval=500 +scoreboard.title=&lViper Network +scoreboard.admin_title=&l[Admin] Panel + +# Laufschrift – leer lassen zum Deaktivieren +scoreboard.ticker.text= +scoreboard.ticker.width=26 +scoreboard.ticker.speed=1 + +scoreboard.rainbow.enabled=true +# wave = fließende Farbwelle | chars = Regenbogen pro Buchstabe | line = eine Farbe +scoreboard.rainbow.mode=wave +# Wellengeschwindigkeit: 1=sehr langsam, 10=normal, 50=schnell, 100=sehr schnell +scoreboard.rainbow.speed=10 +# Farben: Hex (#RRGGBB oder &#RRGGBB) oder Minecraft-Codes (&0-&f) – kommagetrennt +# Leer lassen = voller HSB-Regenbogen +scoreboard.rainbow.colors=#FF0000,#FF6600,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF + +scoreboard.admin_permission=statusapi.scoreboard.admin + +scoreboard.time_format=HH:mm +scoreboard.date_format=dd.MM.yyyy +scoreboard.timezone=Europe/Berlin +scoreboard.money_format=#,##0.00 +scoreboard.money_decimal_separator=, + +# =================================================== +# SEPARATOR – wird als %line% Placeholder genutzt +# Wähle einen Stil oder erstelle deinen eigenen: +# +# scoreboard.separator=&8&m-------------------- (Standard) +# scoreboard.separator=&8&m==================== (Doppelt) +# scoreboard.separator=&8&m~~~~~~~~~~~~~~~~~~~~ (Wellig) +# scoreboard.separator=&8&m.................... (Punkte) +# scoreboard.separator=&8&m──────────────────── (Dünn) +# scoreboard.separator=&8&m════════════════════ (Dick) +# scoreboard.separator=&8◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇ (Diamanten) +# scoreboard.separator=%gradient:&8:&7:────────────────────% (Gradient) +# scoreboard.separator=%gradient:#FF0000:#0000FF:────────────────────% (Farbig) +# scoreboard.separator= (Leer/unsichtbar) +# =================================================== +scoreboard.separator=&8&m-------------------- + +# =================================================== +# NEWS-TICKER – erscheint als %news% Placeholder +# Leer lassen zum Deaktivieren +# =================================================== +scoreboard.news.text=&eWillkommen auf Viper Network! +scoreboard.news.prefix=&8[&6News&8] &r +scoreboard.news.width=26 +# Geschwindigkeit: 1=langsam, 2=normal, 3=schnell +scoreboard.news.speed=1 + +# Sekunden pro Rotation (0 = kein Wechsel) +scoreboard.rotation_interval=4 + +# =================================================== +# ZEILEN – max 15 sichtbar +# Rotation pro Zeile: +# scoreboard.lines.N = Variante 1 (immer sichtbar / nur Variante) +# scoreboard.lines.N.2 = Variante 2 (wechselt alle rotation_interval Sekunden) +# scoreboard.lines.N.3 = Variante 3 usw. +# Gradient: %gradient:FARBE1:FARBE2:TEXT% +# =================================================== +scoreboard.lines.1=%line% +scoreboard.lines.2=%gradient:&b:&f:&b:&l> Player Info:% +scoreboard.lines.3=&7%rank% &f%player% +scoreboard.lines.4= +scoreboard.lines.5=&7Spielzeit: &f%playtime% +scoreboard.lines.5.2=&7Leben: &c%health% +scoreboard.lines.5.3=&7Hunger: B4513%foodsym% +scoreboard.lines.6= +scoreboard.lines.7=%gradient:&b:&f:&b:&l> Money:% +scoreboard.lines.8=&a$%money% +scoreboard.lines.9= +scoreboard.lines.10=%gradient:&b:&f:&b:&l> Server Info:% +scoreboard.lines.11=&f%server% +scoreboard.lines.11.2=&7Ping: &f%ping%ms &8| &7Online: &f%online% +scoreboard.lines.12= +scoreboard.lines.13=%news% +scoreboard.lines.14=%line% +scoreboard.lines.15=&7%compass% + +# =================================================== +# ADMIN-ZEILEN +# =================================================== +scoreboard.admin_lines.1=%line% +scoreboard.admin_lines.2=%gradient:&b:&f:&b:&l> Player Info:% +scoreboard.admin_lines.3=&7%rank% &f%player% +scoreboard.admin_lines.4=&7Gamemode: &f%gamemode% +scoreboard.admin_lines.5=&7Leben: &c%health% +scoreboard.admin_lines.5.2=&7Hunger: B4513%foodsym% +scoreboard.admin_lines.6= +scoreboard.admin_lines.7=%gradient:&b:&f:&b:&l> Server Info:% +scoreboard.admin_lines.8=&f%server% &8| &7RAM: &e%ram% +scoreboard.admin_lines.8.2=&7Proxy: &f%uptime% +scoreboard.admin_lines.9= +scoreboard.admin_lines.10=&7TPS: &a%tps% +scoreboard.admin_lines.11= +scoreboard.admin_lines.12=&7Spieler: &f%online% &8| &7%maxplayers% +scoreboard.admin_lines.13=%news% +scoreboard.admin_lines.14=%line% +scoreboard.admin_lines.15=&7%compass% +scoreboard.admin_lines.15.2=&7Pos: X:&f%x% &7Y:&f%y% &7Z:&f%z%