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: