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.
This commit is contained in:
TDSTOS
2026-04-11 02:34:42 +02:00
parent da7e495374
commit 13f6ce5638
5 changed files with 474 additions and 0 deletions

View File

@@ -33,6 +33,8 @@ dependencies {
compileOnly("com.lunarclient:apollo-api:1.2.4") compileOnly("com.lunarclient:apollo-api:1.2.4")
compileOnly("com.lunarclient:apollo-extra-adventure4: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("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-core:7.2.17-SNAPSHOT")
compileOnly("com.sk89q.worldedit:worldedit-bukkit:7.2.17-SNAPSHOT") compileOnly("com.sk89q.worldedit:worldedit-bukkit:7.2.17-SNAPSHOT")

View File

@@ -38,6 +38,8 @@ import club.mcscrims.speedhg.perk.impl.VampirePerk
import club.mcscrims.speedhg.perk.listener.PerkEventDispatcher import club.mcscrims.speedhg.perk.listener.PerkEventDispatcher
import club.mcscrims.speedhg.ranking.RankingManager import club.mcscrims.speedhg.ranking.RankingManager
import club.mcscrims.speedhg.scoreboard.ScoreboardManager 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.TeamListener
import club.mcscrims.speedhg.team.TeamManager import club.mcscrims.speedhg.team.TeamManager
import club.mcscrims.speedhg.webhook.DiscordWebhookManager import club.mcscrims.speedhg.webhook.DiscordWebhookManager
@@ -114,6 +116,9 @@ class SpeedHG : JavaPlugin() {
lateinit var lobbyItemManager: LobbyItemManager lateinit var lobbyItemManager: LobbyItemManager
private set private set
lateinit var tablistManager: TablistManager
private set
override fun onLoad() override fun onLoad()
{ {
instance = this instance = this
@@ -169,6 +174,7 @@ class SpeedHG : JavaPlugin() {
discordWebhookManager = DiscordWebhookManager( this ) discordWebhookManager = DiscordWebhookManager( this )
lunarClientManager = LunarClientManager( this ) lunarClientManager = LunarClientManager( this )
lobbyItemManager = LobbyItemManager( this ) lobbyItemManager = LobbyItemManager( this )
tablistManager = TablistManager( this, VolcanoServerRankProvider() )
perkManager = PerkManager( this ) perkManager = PerkManager( this )
perkManager.initialize() perkManager.initialize()
@@ -197,6 +203,7 @@ class SpeedHG : JavaPlugin() {
podiumManager.cleanup() podiumManager.cleanup()
if ( ::perkManager.isInitialized ) perkManager.shutdown() if ( ::perkManager.isInitialized ) perkManager.shutdown()
if ( ::teamManager.isInitialized ) teamManager.reset() if ( ::teamManager.isInitialized ) teamManager.reset()
if ( ::tablistManager.isInitialized ) tablistManager.shutdown()
if ( ::statsManager.isInitialized ) statsManager.shutdown() if ( ::statsManager.isInitialized ) statsManager.shutdown()
if ( ::databaseManager.isInitialized ) databaseManager.disconnect() if ( ::databaseManager.isInitialized ) databaseManager.disconnect()
if ( ::dataPackManager.isInitialized ) dataPackManager.uninstall() if ( ::dataPackManager.isInitialized ) dataPackManager.uninstall()

View File

@@ -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: `<red><bold>[Admin]</bold></red> ` (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: `"<red>"` für Admins, `"<gray>"` 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( "<red><bold>[Admin]</bold></red>" )
player.hasPermission( "group.mod" ) -> mm.deserialize( "<green><bold>[Mod]</bold></green>" )
player.hasPermission( "group.helper" ) -> mm.deserialize( "<aqua><bold>[Helper]</bold></aqua>" )
else -> Component.empty()
}
override fun getRankColor(
player: Player
): String = when {
player.hasPermission( "group.admin" ) -> "<red>"
player.hasPermission( "group.mod" ) -> "<green>"
player.hasPermission( "group.helper" ) -> "<aqua>"
else -> "<gray>"
}
}
/**
* 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 )
}
}

View File

@@ -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<String, String> = 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<UUID, String>()
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}<reset>" ))
// ── 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( " <dark_gray>[<reset>${rankTag}<dark_gray>]</dark_gray>" )
}
/** 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<gradient:red:gold><bold>⚔ SpeedHG ⚔</bold></gradient>\n" +
"<dark_gray>play.mcscrims.club</dark_gray>\n"
)
val footer = mm.deserialize(
"\n<gray>Online: <white><online></white> " +
"<dark_gray>⬥</dark_gray> " +
"Ping: <ping_color><ping>ms</ping_color></gray>\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 -> "<green>"
ping < 150 -> "<yellow>"
else -> "<red>"
})
)
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 ))
}
}

View File

@@ -6,6 +6,7 @@ api-version: '1.21'
depend: depend:
- "WorldEdit" - "WorldEdit"
- "Apollo-Bukkit" - "Apollo-Bukkit"
- "Volcano"
permissions: permissions:
speedhg.bypass: speedhg.bypass: