From 13f6ce56382432fc8c9fef9884a26a9b0ba6c31d Mon Sep 17 00:00:00 2001 From: TDSTOS Date: Sat, 11 Apr 2026 02:34:42 +0200 Subject: [PATCH] Add tablist manager and Volcano rank provider Introduce a TablistManager to manage per-player scoreboard teams, tab header/footer and periodic updates; uses a ServerRankProvider abstraction. Add DefaultServerRankProvider and VolcanoServerRankProvider (uses CoreAPI) in ServerRankProvider.kt. Wire TablistManager into SpeedHG: initialize in onLoad, update on join, and shutdown on disable. Add local VolcanoAPI.jar as compileOnly dependency and declare Volcano as a plugin dependency in plugin.yml. --- build.gradle.kts | 2 + .../kotlin/club/mcscrims/speedhg/SpeedHG.kt | 7 + .../speedhg/scoreboard/ServerRankProvider.kt | 112 ++++++ .../speedhg/scoreboard/TablistManager.kt | 352 ++++++++++++++++++ src/main/resources/plugin.yml | 1 + 5 files changed, 474 insertions(+) create mode 100644 src/main/kotlin/club/mcscrims/speedhg/scoreboard/ServerRankProvider.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/scoreboard/TablistManager.kt diff --git a/build.gradle.kts b/build.gradle.kts index 3a93179..27c2e6f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -33,6 +33,8 @@ dependencies { compileOnly("com.lunarclient:apollo-api:1.2.4") compileOnly("com.lunarclient:apollo-extra-adventure4:1.2.4") + compileOnly(files( "${rootProject.projectDir}/libs/VolcanoAPI.jar" )) + compileOnly("io.papermc.paper:paper-api:1.21.1-R0.1-SNAPSHOT") compileOnly("com.sk89q.worldedit:worldedit-core:7.2.17-SNAPSHOT") compileOnly("com.sk89q.worldedit:worldedit-bukkit:7.2.17-SNAPSHOT") diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt index 97a1aab..fc1cfaa 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt @@ -38,6 +38,8 @@ import club.mcscrims.speedhg.perk.impl.VampirePerk import club.mcscrims.speedhg.perk.listener.PerkEventDispatcher import club.mcscrims.speedhg.ranking.RankingManager import club.mcscrims.speedhg.scoreboard.ScoreboardManager +import club.mcscrims.speedhg.scoreboard.TablistManager +import club.mcscrims.speedhg.scoreboard.VolcanoServerRankProvider import club.mcscrims.speedhg.team.TeamListener import club.mcscrims.speedhg.team.TeamManager import club.mcscrims.speedhg.webhook.DiscordWebhookManager @@ -114,6 +116,9 @@ class SpeedHG : JavaPlugin() { lateinit var lobbyItemManager: LobbyItemManager private set + lateinit var tablistManager: TablistManager + private set + override fun onLoad() { instance = this @@ -169,6 +174,7 @@ class SpeedHG : JavaPlugin() { discordWebhookManager = DiscordWebhookManager( this ) lunarClientManager = LunarClientManager( this ) lobbyItemManager = LobbyItemManager( this ) + tablistManager = TablistManager( this, VolcanoServerRankProvider() ) perkManager = PerkManager( this ) perkManager.initialize() @@ -197,6 +203,7 @@ class SpeedHG : JavaPlugin() { podiumManager.cleanup() if ( ::perkManager.isInitialized ) perkManager.shutdown() if ( ::teamManager.isInitialized ) teamManager.reset() + if ( ::tablistManager.isInitialized ) tablistManager.shutdown() if ( ::statsManager.isInitialized ) statsManager.shutdown() if ( ::databaseManager.isInitialized ) databaseManager.disconnect() if ( ::dataPackManager.isInitialized ) dataPackManager.uninstall() diff --git a/src/main/kotlin/club/mcscrims/speedhg/scoreboard/ServerRankProvider.kt b/src/main/kotlin/club/mcscrims/speedhg/scoreboard/ServerRankProvider.kt new file mode 100644 index 0000000..225ad55 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/scoreboard/ServerRankProvider.kt @@ -0,0 +1,112 @@ +package club.mcscrims.speedhg.scoreboard + +import me.zowpy.core.api.CoreAPI +import me.zowpy.core.api.rank.Rank +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.minimessage.MiniMessage +import org.bukkit.entity.Player + +/** + * Liefert Server-Rang-Informationen für den [TablistManager] + */ +interface ServerRankProvider { + + /** + * Gibt den logischen Rang-Schlüssel zurück, der auf ein Sort-Gewicht + * in [TablistManager.WEIGHT_MAP] zeigt. + * Muss einer der Keys sein: `"admin"`, `"mod"`, `"helper"`, `"player"`. + */ + fun getRankWeight( player: Player ): String + + /** + * Gibt die formatierte Prefix-Komponente zurück, die VOR dem + * Spielernamen in der Tabliste erscheint. + * + * Beispiel: `[Admin] ` (mit Leerzeichen am Ende). + * Gibt [Component.empty] zurück wenn kein Prefix gewünscht. + */ + fun getRankPrefix( player: Player ): Component + + /** + * Gibt einen MiniMessage-Farb-Tag zurück, der auf den Spielernamen + * in der Namens-Spalte angewendet wird. + * Beispiel: `""` für Admins, `""` für normale Spieler. + */ + fun getRankColor( player: Player ): String + +} + +/** + * Einfache Implementierung auf Basis von Bukkit-Permissions. + */ +class DefaultServerRankProvider : ServerRankProvider { + + private val mm = MiniMessage.miniMessage() + + override fun getRankWeight( + player: Player + ): String = when { + player.hasPermission( "group.admin" ) -> "admin" + player.hasPermission( "group.mod" ) -> "mod" + player.hasPermission( "group.helper" ) -> "helper" + else -> "player" + } + + override fun getRankPrefix( + player: Player + ): Component = when { + player.hasPermission( "group.admin" ) -> mm.deserialize( "[Admin]" ) + player.hasPermission( "group.mod" ) -> mm.deserialize( "[Mod]" ) + player.hasPermission( "group.helper" ) -> mm.deserialize( "[Helper]" ) + else -> Component.empty() + } + + override fun getRankColor( + player: Player + ): String = when { + player.hasPermission( "group.admin" ) -> "" + player.hasPermission( "group.mod" ) -> "" + player.hasPermission( "group.helper" ) -> "" + else -> "" + } + +} + +/** + * Volcano Implementierung + */ +class VolcanoServerRankProvider : ServerRankProvider { + + private val coreAPI get() = CoreAPI.getInstance() + private val mm = MiniMessage.miniMessage() + + override fun getRankWeight( + player: Player + ): String + { + return getRank( player ).weight.toString() + } + + override fun getRankPrefix( + player: Player + ): Component + { + return mm.deserialize(getRank( player ).prefix) + } + + override fun getRankColor( + player: Player + ): String + { + return getRank( player ).displayColor + } + + private fun getRank( + player: Player + ): Rank + { + val rank = coreAPI.profileManager.getByUUID( player.uniqueId ).realRank + return coreAPI.rankManager.getByUUID( rank.uuid ) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/scoreboard/TablistManager.kt b/src/main/kotlin/club/mcscrims/speedhg/scoreboard/TablistManager.kt new file mode 100644 index 0000000..c185286 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/scoreboard/TablistManager.kt @@ -0,0 +1,352 @@ +package club.mcscrims.speedhg.scoreboard + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.ranking.Rank +import net.kyori.adventure.text.minimessage.MiniMessage +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerJoinEvent +import org.bukkit.event.player.PlayerQuitEvent +import org.bukkit.scheduler.BukkitTask +import org.bukkit.scoreboard.Scoreboard +import org.bukkit.scoreboard.Team +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * Verwaltet Sortierung, Prefix/Suffix und Header/Footer der Spieler-Tabliste. + * + * ## Sortier-Mechanismus (Scoreboard-Teams) + * + * Minecraft sortiert Tab-Einträge **alphabetisch nach Team-Namen**, dann nach + * Spielername innerhalb des Teams. Wir nutzen das aus: + * + * | Rang | Team-Name | Sortierung | + * |---------|------------------------|-----------| + * | Admin | `sHG_00_AdminBob` | ganz oben | + * | Mod | `sHG_01_CoolMod` | ↓ | + * | Helper | `sHG_02_HelperAnna` | ↓ | + * | Spieler | `sHG_99_Notch` | unten | + * + * Da `"0" < "9"` alphabetisch gilt, landen Admins immer vor Spielern. + * + * ## Warum ein Team pro Spieler? + * Scoreboard-Teams besitzen **eine** Prefix/Suffix-Einstellung für alle + * Mitglieder. Da jeder Spieler einen individuellen SpeedHG-Rang als Suffix + * benötigt (z. B. `[Gold II]` vs. `[Silver III]`), erhält jeder Spieler + * sein eigenes Team. Das finale Tab-Format sieht so aus: + * + * ``` + * [team.prefix()] [playerListName] [team.suffix()] + * [Admin] Notch [Gold II] + * ``` + * + * ## Scoreboard-Isolation + * Es wird ein dediziertes [Scoreboard] verwendet, das nie mit FastBoard + * geteilt wird. FastBoard 2.x sendet Sidebar-Pakete direkt — es liest + * `player.scoreboard` nicht aus, daher gibt es keinen Konflikt. + * + * ## Rang-Anbindung + * Implementiere [ServerRankProvider] für LuckPerms oder einen anderen + * Permission-Manager und übergib ihn im Konstruktor. + */ +class TablistManager( + private val plugin: SpeedHG, + val rankProvider: ServerRankProvider = DefaultServerRankProvider() +) : Listener { + + // ========================================================================= + // Konstanten + // ========================================================================= + + companion object { + /** + * Logischer Rang-Key -> numerisches Sort-Gewicht + * Das Gewicht wird Teil des Team-Namens: `sHG_{gewicht}_{spielerName}` + * + * Neue Ränge einfach hier eintragen, z.B. `"vip"` to "05" + */ + val WEIGHT_MAP: Map = linkedMapOf( + "admin" to "00", + "mod" to "01", + "helper" to "02", + "vip" to "10", + "player" to "99" + ) + + /** Namespace-Präfix für alle Tab-Teams — verhindert Namenskollisionen. */ + private const val TEAM_NAMESPACE = "sHG_" + + /** Ticks zwischen periodischen Aktualisierungen (60 = 3 Sekunden). */ + private const val UPDATE_INTERVAL_TICKS = 60L + } + + // ========================================================================= + // Zustand + // ========================================================================= + + private val mm = MiniMessage.miniMessage() + + /** + * Dediziertes Scoreboard ausschließlich für das Tab-Management. + * Niemals mit anderen Systemen teilen. + */ + private val scoreboard: Scoreboard = + requireNotNull( Bukkit.getScoreboardManager() ).newScoreboard + + /** + * UUID → aktueller Team-Name des Spielers. + * Notwendig für sauberes Aufräumen bei Rang-Wechsel oder Disconnect. + */ + private val playerTeams = ConcurrentHashMap() + + private var updateTask: BukkitTask? = null + + // ========================================================================= + // Lifecycle + // ========================================================================= + + init { + plugin.server.pluginManager.registerEvents( this, plugin ) + startUpdateTask() + plugin.logger.info("[TablistManager] Initialisiert (Intervall: ${UPDATE_INTERVAL_TICKS * 50}ms).") + } + + /** + * Bricht den Update-Task ab, gibt alle Teams frei und setzt die Spieler + * auf das Haupt-Scoreboard zurück. + * + * In [SpeedHG.onDisable] aufrufen: + * ```kotlin + * if (::tablistManager.isInitialized) tablistManager.shutdown() + * ``` + */ + fun shutdown() + { + updateTask?.cancel() + + // Teams aus dem Scoreboard entfernen + playerTeams.values.toSet().forEach { teamName -> + scoreboard.getTeam( teamName )?.unregister() + } + playerTeams.clear() + + // Spieler zurück auf das Haupt-Scoreboard (kein hängender Zustand) + val main = Bukkit.getScoreboardManager().mainScoreboard + Bukkit.getOnlinePlayers().forEach { it.scoreboard = main } + + plugin.logger.info( "[TablistManager] Heruntergefahren und Teams bereinigt." ) + } + + // ========================================================================= + // Öffentliche API + // ========================================================================= + + /** + * Aktualisiert den gesamten Tab-Eintrag für [player]: + * Team-Zuweisung, Prefix, Spieler-Listen-Name, Suffix und Header/Footer. + * + * Aufruf bei: Spieler-Join, Rang-Änderung, SpeedHG-Runden-Ende. + */ + fun updateTab( + player: Player + ) { + assignToTeam( player ) + updateHeaderFooter( player ) + } + + // ========================================================================= + // Bukkit-Events + // ========================================================================= + + /** + * Ein Tick verzögert, damit Permissions (LuckPerms) und der Stats-Cache + * ([StatsManager]) sicher befüllt sind. + */ + @EventHandler( + priority = EventPriority.MONITOR + ) + fun onJoin( + event: PlayerJoinEvent + ) { + plugin.server.scheduler.runTask( plugin ) { -> + if ( event.player.isOnline ) updateTab( event.player ) + } + } + + /** Räumt das per-Spieler-Team beim Disconnect auf. */ + @EventHandler( + priority = EventPriority.MONITOR + ) + fun onQuit( + event: PlayerQuitEvent + ) { + removePlayerTeam( event.player.uniqueId ) + } + + // ========================================================================= + // Privat: Team-Verwaltung + // ========================================================================= + + /** + * Weist [player] einem eigenen Scoreboard-Team zu und setzt + * Prefix, Spieler-Listen-Name und Suffix. + * + * ### Team-Name Format + * `sHG_{gewicht}_{spielerName}`, z. B. `sHG_00_AdminBob` oder `sHG_99_Notch`. + * + * Durch den numerischen Gewicht-Präfix sortiert Minecraft Admins + * automatisch vor normalen Spielern, ohne dass wir Pakete manuell manipulieren. + * + * ### Rang-Wechsel-Erkennung + * Wenn ein Spieler die Gruppe wechselt (z. B. Beförderung zum Mod), + * weist der neue Team-Name eine andere Gewichtung auf. Das alte Team + * wird automatisch deregistriert und ein neues angelegt. + */ + private fun assignToTeam( + player: Player + ) { + val weight = WEIGHT_MAP[rankProvider.getRankWeight( player )] ?: "99" + val newTeamName = "${TEAM_NAMESPACE}${weight}_${player.name}" + val oldTeamName = playerTeams[ player.uniqueId ] + + // Altes Team bei Rang-Wechsel entfernen + if ( oldTeamName != null && oldTeamName != newTeamName ) + { + scoreboard.getTeam( oldTeamName )?.unregister() + playerTeams.remove( player.uniqueId ) + } + + val team = scoreboard.getTeam( newTeamName ) + ?: scoreboard.registerNewTeam( newTeamName ).also { newTeam -> + // Team-Optionen einmalig beim Erstellen setzen + newTeam.setOption( + Team.Option.COLLISION_RULE, + Team.OptionStatus.NEVER + ) + } + + // ── Prefix: Server-Rang (z. B. "[Admin]") ───────────────────────── + team.prefix(rankProvider.getRankPrefix( player )) + + // ── playerListName: farbiger Spielername ─────────────────────────── + // Ersetzt den Standard-Anzeigenamen in der Namens-Spalte. + // Endergebnis: [PREFIX] [NAME] [SUFFIX] + val nameColor = rankProvider.getRankColor( player ) + player.playerListName(mm.deserialize( "${nameColor}${player.name}" )) + + // ── Suffix: SpeedHG-Rang (z. B. "[Gold II]") ────────────────────── + team.suffix(buildSpeedHGRankSuffix( player )) + + // Spieler dem Team zuweisen + if (!team.hasEntry( player.name )) team.addEntry( player.name ) + playerTeams[ player.uniqueId ] = newTeamName + + // Scoreboard dem Spieler zuweisen (notwendig damit Teams sichtbar sind) + player.scoreboard = scoreboard + } + + /** Erstellt die Suffix-Komponente mit dem aktuellen SpeedHG-Rang. */ + private fun buildSpeedHGRankSuffix( + player: Player + ) = run { + val stats = plugin.statsManager.getCachedStats( player.uniqueId ) + val score = stats?.scrimScore ?: 0 + val games = ( stats?.wins ?: 0 ) + ( stats?.losses ?: 0 ) + val rankTag = Rank.getFormattedRankTag( score, games ) + + mm.deserialize( " [${rankTag}]" ) + } + + /** Entfernt das Scoreboard-Team des Spielers vollständig. */ + private fun removePlayerTeam( + uuid: UUID + ) { + val teamName = playerTeams.remove( uuid ) ?: return + scoreboard.getTeam( teamName )?.unregister() + } + + // ========================================================================= + // Privat: Header & Footer + // ========================================================================= + + /** + * Setzt den Header und Footer der Tabliste für [player]. + * + * Der Footer zeigt dynamische Werte (Spieleranzahl, Ping), daher wird + * diese Methode regelmäßig vom Update-Task aufgerufen. + * + * Passe die MiniMessage-Strings nach Wunsch an oder lies sie aus + * der [LanguageManager]-Konfiguration. + */ + private fun updateHeaderFooter( + player: Player + ) { + val online = Bukkit.getOnlinePlayers().size + val ping = player.ping + + val header = mm.deserialize( + "\n⚔ SpeedHG ⚔\n" + + "play.mcscrims.club\n" + ) + + val footer = mm.deserialize( + "\nOnline: " + + " " + + "Ping: ms\n", + Placeholder.unparsed( "online", online.toString() ), + Placeholder.unparsed( "ping", ping.toString() ), + // Ping-Farbe: grün < 80ms, gelb < 150ms, rot sonst + Placeholder.parsed( "ping_color", when { + ping < 80 -> "" + ping < 150 -> "" + else -> "" + }) + ) + + player.sendPlayerListHeaderAndFooter( header, footer ) + } + + // ========================================================================= + // Privat: Periodischer Update-Task + // ========================================================================= + + /** + * Synchroner Task (Main-Thread!), der alle [UPDATE_INTERVAL_TICKS] Ticks + * für alle Online-Spieler Header/Footer und SpeedHG-Suffix aktualisiert. + * + * **Sync ist hier Pflicht**: Scoreboard-Operationen und + * `sendPlayerListHeaderAndFooter` müssen auf dem Main-Thread laufen. + * + * Nur leichtgewichtige Operationen hier — kein vollständiges + * [assignToTeam] (das wird nur bei Join/Rang-Wechsel benötigt). + */ + private fun startUpdateTask() + { + updateTask = plugin.server.scheduler.runTaskTimer( plugin, { -> + Bukkit.getOnlinePlayers().forEach { player -> + // Footer mit aktuellen Ping-Werten neu senden + updateHeaderFooter( player ) + // SpeedHG-Suffix synchronisieren (falls Rang sich geändert hat) + refreshRankSuffix( player ) + } + }, UPDATE_INTERVAL_TICKS, UPDATE_INTERVAL_TICKS ) + } + + /** + * Aktualisiert nur den Suffix des bestehenden Teams — deutlich günstiger + * als ein vollständiges [assignToTeam] bei jedem Tick. + */ + private fun refreshRankSuffix( + player: Player + ) { + val teamName = playerTeams[ player.uniqueId ] ?: return + val team = scoreboard.getTeam( teamName ) ?: return + team.suffix(buildSpeedHGRankSuffix( player )) + } + +} \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 6566710..b59e3c1 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -6,6 +6,7 @@ api-version: '1.21' depend: - "WorldEdit" - "Apollo-Bukkit" + - "Volcano" permissions: speedhg.bypass: