diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt index f2ab83c..a9a6f6a 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt @@ -2,6 +2,7 @@ package club.mcscrims.speedhg import club.mcscrims.speedhg.command.KitCommand import club.mcscrims.speedhg.command.LeaderboardCommand +import club.mcscrims.speedhg.command.RankingCommand import club.mcscrims.speedhg.command.TimerCommand import club.mcscrims.speedhg.config.CustomGameManager import club.mcscrims.speedhg.config.CustomGameSettings @@ -149,6 +150,12 @@ class SpeedHG : JavaPlugin() { tabCompleter = timerCommand } + val rankingCommand = RankingCommand( this ) + getCommand( "ranking" )?.apply { + setExecutor( rankingCommand ) + tabCompleter = rankingCommand + } + getCommand( "leaderboard" )?.setExecutor( LeaderboardCommand() ) } diff --git a/src/main/kotlin/club/mcscrims/speedhg/command/RankingCommand.kt b/src/main/kotlin/club/mcscrims/speedhg/command/RankingCommand.kt new file mode 100644 index 0000000..3a1dfde --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/command/RankingCommand.kt @@ -0,0 +1,109 @@ +package club.mcscrims.speedhg.command + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.util.sendMsg +import org.bukkit.command.Command +import org.bukkit.command.CommandExecutor +import org.bukkit.command.CommandSender +import org.bukkit.command.TabCompleter + +class RankingCommand( + private val plugin: SpeedHG +) : CommandExecutor, TabCompleter { + + override fun onCommand( + sender: CommandSender, + command: Command, + label: String, + args: Array + ): Boolean + { + if (!sender.hasPermission( "speedhg.admin.ranking" )) + { + sender.sendMsg( "default.no_permission" ) + return true + } + + when( args.firstOrNull()?.lowercase() ) + { + "toggle" -> handleToggle( sender ) + "status" -> handleStatus( sender ) + "rank" -> handleRank( sender, args ) + else -> sender.sendMsg( "commands.ranking.usage" ) + } + return true + } + + // ── Sub-Commands ────────────────────────────────────────────────────────── + + private fun handleToggle( + sender: CommandSender + ) { + val rm = plugin.rankingManager + rm.isRankingEnabled = !rm.isRankingEnabled + + val key = if ( rm.isRankingEnabled ) "commands.ranking.enabled" + else "commands.ranking.disabled" + sender.sendMsg( key ) + } + + private fun handleStatus( + sender: CommandSender + ) { + val enabled = plugin.rankingManager.isRankingEnabled + val key = if ( enabled ) "commands.ranking.status_enabled" + else "commands.ranking.status_disabled" + sender.sendMsg( key ) + } + + private fun handleRank( + sender: CommandSender, + args: Array + ) { + val targetName = args.getOrNull( 1 ) ?: run { + sender.sendMsg( "commands.ranking.usage" ) + return + } + + val target = plugin.server.getPlayer( targetName ) ?: run { + sender.sendMsg( "commands.ranking.player_not_found", "name" to targetName ) + return + } + + val stats = plugin.statsManager.getCachedStats( target.uniqueId ) + val rank = plugin.rankingManager.getRank( target ) + val score = stats?.scrimScore ?: 0 + val games = ( stats?.wins ?: 0 ) + ( stats?.losses ?: 0 ) + + sender.sendMsg( + "commands.ranking.rank_info", + "name" to target.name, + "rank" to rank.tag, + "score" to score.toString(), + "games" to games.toString() + ) + } + + override fun onTabComplete( + sender: CommandSender, + command: Command, + label: String, + args: Array + ): List + { + if (!sender.hasPermission( "speedhg.admin.ranking" )) + return emptyList() + + return when( args.size ) + { + 1 -> listOf( "toggle", "status", "rank" ) + .filter { it.startsWith( args[0], true ) } + 2 -> if (args[ 0 ].equals( "rank", true )) + plugin.server.onlinePlayers.map { it.name } + .filter { it.startsWith( args[1], true ) } + else emptyList() + else -> emptyList() + } + } + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/ranking/Rank.kt b/src/main/kotlin/club/mcscrims/speedhg/ranking/Rank.kt new file mode 100644 index 0000000..78cfd26 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/ranking/Rank.kt @@ -0,0 +1,78 @@ +package club.mcscrims.speedhg.ranking + +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.minimessage.MiniMessage + +/** + * Tier-System für SpeedHG. + * + * Reihenfolge der Enum-Einträge ist bewusst aufsteigend (niedrig → hoch), + * da [ordinal] für den Rank-Up/Down-Vergleich in [RankingManager] genutzt wird. + * + * @param displayName Angezeigter Name (z. B. "Gold"). + * @param colorTag MiniMessage-Farbtag ohne Inhalt (z. B. ""). + * @param minScore Mindest-scrimScore für diesen Rang (inklusive). UNRANKED + * bekommt [Int.MIN_VALUE] — er wird nie per Score gewählt, + * sondern nur wenn der Spieler in der Placement-Phase ist. + */ +enum class Rank( + val displayName: String, + val colorTag: String, + val minScore: Int +) { + UNRANKED ("Unranked", "", Int.MIN_VALUE), + BRONZE ("Bronze", "<#CD7F32>", 0 ), + SILVER ("Silver", "", 500 ), + GOLD ("Gold", "", 1000 ), + PLATINUM ("Platinum", "", 1500 ), + DIAMOND ("Diamond", "<#B9F2FF>", 2000 ), + SCRIM_MASTER("Scrim-Master", "", 2500 ); + + // ── Vorgefertigte Strings (String-Konkatenation einmalig, nicht pro Frame) ── + + /** MiniMessage-String: "Gold". Ideal für Platzhalter in Nachrichten. */ + val tag: String = "$colorTag$displayName" + + /** Klammer-Prefix für Chat/Scoreboard: "[Gold]". */ + val prefix: String = "$colorTag[$displayName]" + + /** + * Deserialisiertes Adventure-[Component] des [tag]. + * [lazy] → wird nur beim ersten Zugriff gebaut und dann gecacht. + * Da Enum-Instanzen Singletons sind, passiert das genau einmal pro Rang. + */ + val component: Component by lazy { MiniMessage.miniMessage().deserialize(tag) } + + /** Deserialisiertes Adventure-[Component] des [prefix]. */ + val prefixComponent: Component by lazy { MiniMessage.miniMessage().deserialize(prefix) } + + companion object { + + /** + * Gibt den **sichtbaren** Rang zurück. + * + * Spieler mit < [RankingManager.PLACEMENT_GAMES] abgeschlossenen Spielen + * bekommen immer [UNRANKED], egal wie hoch ihr interner Score ist. + * + * @param scrimScore Aktueller Scrim-Score. + * @param gamesPlayed Abgeschlossene Spiele (wins + losses zum Zeitpunkt der Abfrage). + */ + fun fromPlayer(scrimScore: Int, gamesPlayed: Int): Rank = + if (gamesPlayed < RankingManager.PLACEMENT_GAMES) UNRANKED + else fromScore(scrimScore) + + /** + * Reines Score → Tier Mapping, ignoriert die Placement-Phase. + * Wird intern für Rank-Change-Erkennung (Pre/Post Adjustment) genutzt. + * + * Iteriert 6 Einträge in absteigender Score-Reihenfolge → O(1) für die Praxis. + */ + fun fromScore(scrimScore: Int): Rank = + entries + .asSequence() + .filter { it != UNRANKED } + .sortedByDescending { it.minScore } + .firstOrNull { scrimScore >= it.minScore } + ?: BRONZE + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/ranking/RankingManager.kt b/src/main/kotlin/club/mcscrims/speedhg/ranking/RankingManager.kt index 615c6f9..ed502ac 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/ranking/RankingManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/ranking/RankingManager.kt @@ -1,11 +1,19 @@ package club.mcscrims.speedhg.ranking import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.ranking.RankingManager.Companion.KILL_CAP +import club.mcscrims.speedhg.ranking.RankingManager.Companion.MAX_RR_GAIN +import club.mcscrims.speedhg.ranking.RankingManager.Companion.MAX_RR_LOSS +import club.mcscrims.speedhg.ranking.RankingManager.Companion.MIN_RR_GAIN +import club.mcscrims.speedhg.ranking.RankingManager.Companion.MIN_RR_LOSS +import club.mcscrims.speedhg.ranking.RankingManager.Companion.PLACEMENT_GAMES import club.mcscrims.speedhg.util.sendMsg -import net.kyori.adventure.text.minimessage.MiniMessage -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder +import club.mcscrims.speedhg.util.trans +import net.kyori.adventure.title.Title +import org.bukkit.Sound import org.bukkit.entity.Player -import java.util.UUID +import java.time.Duration +import java.util.* import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger import kotlin.math.roundToInt @@ -68,7 +76,15 @@ class RankingManager( const val KILL_CAP = 8 } - private val mm = MiniMessage.miniMessage() + // ── Ranking-Modus ───────────────────────────────────────────────────────── + + /** + * Wenn `false`: Keine RR-Änderungen am Ende der Runde. + * Kills/Deaths werden weiterhin geloggt. + * Per `/ranking toggle` umschaltbar. + */ + @Volatile + var isRankingEnabled: Boolean = true // ── Round State (wird in startRound() zurückgesetzt) ───────────────────── @@ -102,7 +118,7 @@ class RankingManager( eliminationIndex.set( 0 ) totalPlayersThisRound = players.size players.forEach { roundKills[ it.uniqueId ] = 0 } - plugin.logger.info("[RankingManager] Runde gestartet mit ${players.size} Spielern.") + plugin.logger.info("[RankingManager] Runde gestartet mit ${players.size} Spielern, Ranked: $isRankingEnabled.") } /** @@ -152,7 +168,7 @@ class RankingManager( if ( gamesPlayed < PLACEMENT_GAMES ) { handlePlacementMatch( player, gamesPlayed, kills, placement ) } else { - handleRankedMatch( player, kills, placement ) + handleRankedMatch( player, kills, placement, gamesPlayed ) } } @@ -168,6 +184,26 @@ class RankingManager( totalPlayersThisRound = 0 } + // ========================================================================= + // Rank-Abfrage (für Scoreboard, Chat-Prefix etc.) + // ========================================================================= + + /** + * Gibt den aktuell sichtbaren Rang des Spielers zurück. + * Berücksichtigt die Placement-Phase — Spieler in Placement bekommen [Rank.UNRANKED]. + * + * **Performant**: Greift nur auf den In-Memory-Cache zu, kein DB-Call. + * Sicher für häufige Aufrufe (Scoreboard-Updates alle 10 Ticks). + */ + fun getRank( + player: Player + ): Rank + { + val stats = plugin.statsManager.getCachedStats( player.uniqueId ) + ?: return Rank.UNRANKED + return Rank.fromPlayer( stats.scrimScore, stats.wins + stats.losses ) + } + // ========================================================================= // Core RR Algorithm // ========================================================================= @@ -239,9 +275,12 @@ class RankingManager( kills: Int, placement: Int ) { - // Internen Score still anpassen (für die spätere Einrankung relevant) - val internalChange = calculateRRChange( placement, totalPlayersThisRound, kills ) - plugin.statsManager.adjustScrimScore( player.uniqueId, internalChange ) + if ( isRankingEnabled ) + { + // Internen Score still anpassen (für die spätere Einrankung relevant) + val internalChange = calculateRRChange( placement, totalPlayersThisRound, kills ) + plugin.statsManager.adjustScrimScore( player.uniqueId, internalChange ) + } // Spielnummer im Display: min mit PLACEMENT_GAMES, damit kein "6/5" erscheint val currentGameNumber = ( completedGames + 1 ).coerceAtMost( PLACEMENT_GAMES ) @@ -258,16 +297,29 @@ class RankingManager( private fun handleRankedMatch( player: Player, kills: Int, - placement: Int + placement: Int, + gamesPlayed: Int ) { val rrChange = calculateRRChange( placement, totalPlayersThisRound, kills ) + val rrTag: String - // scrimScore clamp auf >= 0 ist bereits in adjustScrimScore() via coerceAtLest(0) gesichert - plugin.statsManager.adjustScrimScore( player.uniqueId, rrChange ) + if ( isRankingEnabled ) + { + // ── Rank-Change-Erkennung: Score VOR dem Anpassen lesen ─────────── + val stats = plugin.statsManager.getCachedStats( player.uniqueId ) + val scoreBefore = stats?.scrimScore ?: 0 + val rankBefore = Rank.fromScore( scoreBefore ) - // RR-Tag vorformatieren - val rrTag = if ( rrChange >= 0 ) "+${rrChange} RR" - else "${rrChange} RR" + plugin.statsManager.adjustScrimScore( player.uniqueId, rrChange ) + val rankAfter = Rank.fromScore( stats?.scrimScore ?: 0 ) + + if ( rankBefore != rankAfter && gamesPlayed >= PLACEMENT_GAMES ) + notifyRankChange( player, rankBefore, rankAfter ) + + rrTag = if ( rrChange >= 0 ) "+${rrChange} RR" + else "${rrChange} RR" + } + else rrTag = "(Unranked - no RR)" val msgKey = if ( placement == 1 ) "ranking.result_win" else "ranking.result_loss" @@ -280,6 +332,54 @@ class RankingManager( ) } + // ========================================================================= + // Private: Rank-Change Benachrichtigung + // ========================================================================= + + /** + * Benachrichtigt den Spieler über einen Rang-Aufstieg oder -Abstieg. + * Promotion: Title + Fanfare-Sound. + * Demotion: Subtile Nachricht + leiser Sound. + */ + private fun notifyRankChange( + player: Player, + from: Rank, + to: Rank + ) { + val isPromotion = to.ordinal > from.ordinal + + if ( isPromotion ) + { + player.showTitle(Title.title( + player.trans( "ranking.promote-main" ), + player.trans( "ranking.promote-sub", "color" to to.colorTag, "name" to to.displayName ), + Title.Times.times( + Duration.ofMillis( 300 ), + Duration.ofSeconds( 3 ), + Duration.ofMillis( 700 ) + ) + )) + + player.playSound( player.location, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f, 1f ) + player.playSound( player.location, Sound.ENTITY_PLAYER_LEVELUP, 0.6f, 1.5f ) + + player.sendMsg( + "ranking.rank_up", + "from" to from.tag, + "to" to to.tag + ) + } + else + { + player.playSound( player.location, Sound.ENTITY_WITHER_HURT, 0.4f, 1.6f ) + player.sendMsg( + "ranking.rank_down", + "from" to from.tag, + "to" to to.tag + ) + } + } + // ========================================================================= // Private: Helpers // ========================================================================= diff --git a/src/main/kotlin/club/mcscrims/speedhg/scoreboard/ScoreboardManager.kt b/src/main/kotlin/club/mcscrims/speedhg/scoreboard/ScoreboardManager.kt index c346368..26291ca 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/scoreboard/ScoreboardManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/scoreboard/ScoreboardManager.kt @@ -66,6 +66,9 @@ class ScoreboardManager( val max = Bukkit.getMaxPlayers().toString() val kitName = plugin.kitManager.getSelectedKit( player )?.displayName ?: Component.text( "None" ) + val rank = plugin.rankingManager.getRank( player ) + val rankComponent = rank.component + val lines: List if ( state == GameState.LOBBY || state == GameState.STARTING ) @@ -75,7 +78,7 @@ class ScoreboardManager( lines = plugin.languageManager.getMessageList( player, "scoreboard.lobby", mapOf( "online" to online, "max" to max, "time" to timeString ), - mapOf( "kit" to kitName ) + mapOf( "kit" to kitName, "rank" to rankComponent ) ) } else @@ -88,7 +91,7 @@ class ScoreboardManager( lines = plugin.languageManager.getMessageList( player, "scoreboard.ingame", mapOf( "timer" to timeString, "alive" to alive, "kills" to kills, "border" to border ), - mapOf( "kit" to kitName ) + mapOf( "kit" to kitName, "rank" to rankComponent ) ) } diff --git a/src/main/resources/languages/en_US.yml b/src/main/resources/languages/en_US.yml index 1ca1d97..b0b5e02 100644 --- a/src/main/resources/languages/en_US.yml +++ b/src/main/resources/languages/en_US.yml @@ -24,6 +24,12 @@ ranking: result_win: '🏆 Victory! Kills: · ' result_loss: 'You placed · Kills: · ' + promote-main: 'RANK UP!' + promote-sub: '' + + rank_up: '⬆ Rank Up! ' + rank_down: '⬇ Rank Down. ' + title: fight-main: 'The battle has begun!' fight-sub: 'Try not to die!' @@ -79,6 +85,15 @@ commands: positiveNumber: 'Invalid time format! Use, for example, 10m, 30s, or 600.' onlyIngame: 'Timer can only be set in game.' set: 'The game timer has been set to ' + ranking: + usage: 'Usage: /ranking ' + enabled: '✔ Ranked mode is now ACTIVE. RR will be awarded next round.' + disabled: '⚠ Unranked mode is now ACTIVE. No RR changes next round.' + status_enabled: 'Ranking: ACTIVE' + status_disabled: 'Ranking: DISABLED (Unranked)' + rank_usage: 'Usage: /ranking rank ' + player_not_found: 'Player is not online.' + rank_info: 'Player ( RR · games)' scoreboard: title: 'SpeedHG' @@ -86,6 +101,7 @@ scoreboard: - " " - "Players: /" - "Kit: " + - "Rank: " - "" - "Waiting for start..." - "" @@ -95,6 +111,7 @@ scoreboard: - "Time: " - "Players: " - "Kills: " + - "Rank: " - "" - "Border: " - "" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 1bf4f84..2b097cf 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -13,6 +13,9 @@ permissions: speedhg.admin.timer: description: 'Change the current game time' default: false + speedhg.admin.ranking: + description: 'Manage the ranking system (toggle unranked mode, inspect ranks)' + default: false commands: kit: @@ -24,4 +27,8 @@ commands: timer: description: 'Change the current game time (Admin Command)' usage: '/timer ' - permission: speedhg.admin.timer \ No newline at end of file + permission: speedhg.admin.timer + ranking: + description: 'Manage the SpeedHG ranking system' + usage: '/ranking ' + permission: speedhg.admin.ranking \ No newline at end of file