Add ranking system and /ranking command

Introduce a ranking system: add Rank enum and RankingManager enhancements (isRankingEnabled toggle, getRank(), RR change handling, rank-up/down detection and player notifications with titles/sounds). Add a new /ranking admin command (toggle, status, rank) with tab completion and register it in plugin startup. Surface player rank on the scoreboard (ScoreboardManager) and add related language entries and plugin.yml permission/command metadata. Logging updated to include ranked state.
This commit is contained in:
TDSTOS
2026-04-03 22:28:56 +02:00
parent ab976cc2a4
commit 7b6557122b
7 changed files with 339 additions and 18 deletions

View File

@@ -2,6 +2,7 @@ package club.mcscrims.speedhg
import club.mcscrims.speedhg.command.KitCommand import club.mcscrims.speedhg.command.KitCommand
import club.mcscrims.speedhg.command.LeaderboardCommand import club.mcscrims.speedhg.command.LeaderboardCommand
import club.mcscrims.speedhg.command.RankingCommand
import club.mcscrims.speedhg.command.TimerCommand import club.mcscrims.speedhg.command.TimerCommand
import club.mcscrims.speedhg.config.CustomGameManager import club.mcscrims.speedhg.config.CustomGameManager
import club.mcscrims.speedhg.config.CustomGameSettings import club.mcscrims.speedhg.config.CustomGameSettings
@@ -149,6 +150,12 @@ class SpeedHG : JavaPlugin() {
tabCompleter = timerCommand tabCompleter = timerCommand
} }
val rankingCommand = RankingCommand( this )
getCommand( "ranking" )?.apply {
setExecutor( rankingCommand )
tabCompleter = rankingCommand
}
getCommand( "leaderboard" )?.setExecutor( LeaderboardCommand() ) getCommand( "leaderboard" )?.setExecutor( LeaderboardCommand() )
} }

View File

@@ -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<out String>
): 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<out String>
) {
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<out String>
): List<String?>
{
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()
}
}
}

View File

@@ -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. "<gold>").
* @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", "<dark_gray>", Int.MIN_VALUE),
BRONZE ("Bronze", "<#CD7F32>", 0 ),
SILVER ("Silver", "<silver>", 500 ),
GOLD ("Gold", "<gold>", 1000 ),
PLATINUM ("Platinum", "<aqua>", 1500 ),
DIAMOND ("Diamond", "<#B9F2FF>", 2000 ),
SCRIM_MASTER("Scrim-Master", "<gradient:red:gold>", 2500 );
// ── Vorgefertigte Strings (String-Konkatenation einmalig, nicht pro Frame) ──
/** MiniMessage-String: "<gold>Gold<reset>". Ideal für Platzhalter in Nachrichten. */
val tag: String = "$colorTag$displayName<reset>"
/** Klammer-Prefix für Chat/Scoreboard: "<gold>[Gold]<reset>". */
val prefix: String = "$colorTag[$displayName]<reset>"
/**
* 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
}
}

View File

@@ -1,11 +1,19 @@
package club.mcscrims.speedhg.ranking package club.mcscrims.speedhg.ranking
import club.mcscrims.speedhg.SpeedHG 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 club.mcscrims.speedhg.util.sendMsg
import net.kyori.adventure.text.minimessage.MiniMessage import club.mcscrims.speedhg.util.trans
import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder import net.kyori.adventure.title.Title
import org.bukkit.Sound
import org.bukkit.entity.Player 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.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.roundToInt import kotlin.math.roundToInt
@@ -68,7 +76,15 @@ class RankingManager(
const val KILL_CAP = 8 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) ───────────────────── // ── Round State (wird in startRound() zurückgesetzt) ─────────────────────
@@ -102,7 +118,7 @@ class RankingManager(
eliminationIndex.set( 0 ) eliminationIndex.set( 0 )
totalPlayersThisRound = players.size totalPlayersThisRound = players.size
players.forEach { roundKills[ it.uniqueId ] = 0 } 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 ) { if ( gamesPlayed < PLACEMENT_GAMES ) {
handlePlacementMatch( player, gamesPlayed, kills, placement ) handlePlacementMatch( player, gamesPlayed, kills, placement )
} else { } else {
handleRankedMatch( player, kills, placement ) handleRankedMatch( player, kills, placement, gamesPlayed )
} }
} }
@@ -168,6 +184,26 @@ class RankingManager(
totalPlayersThisRound = 0 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 // Core RR Algorithm
// ========================================================================= // =========================================================================
@@ -239,9 +275,12 @@ class RankingManager(
kills: Int, kills: Int,
placement: Int placement: Int
) { ) {
// Internen Score still anpassen (für die spätere Einrankung relevant) if ( isRankingEnabled )
val internalChange = calculateRRChange( placement, totalPlayersThisRound, kills ) {
plugin.statsManager.adjustScrimScore( player.uniqueId, internalChange ) // 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 // Spielnummer im Display: min mit PLACEMENT_GAMES, damit kein "6/5" erscheint
val currentGameNumber = ( completedGames + 1 ).coerceAtMost( PLACEMENT_GAMES ) val currentGameNumber = ( completedGames + 1 ).coerceAtMost( PLACEMENT_GAMES )
@@ -258,16 +297,29 @@ class RankingManager(
private fun handleRankedMatch( private fun handleRankedMatch(
player: Player, player: Player,
kills: Int, kills: Int,
placement: Int placement: Int,
gamesPlayed: Int
) { ) {
val rrChange = calculateRRChange( placement, totalPlayersThisRound, kills ) val rrChange = calculateRRChange( placement, totalPlayersThisRound, kills )
val rrTag: String
// scrimScore clamp auf >= 0 ist bereits in adjustScrimScore() via coerceAtLest(0) gesichert if ( isRankingEnabled )
plugin.statsManager.adjustScrimScore( player.uniqueId, rrChange ) {
// ── 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 plugin.statsManager.adjustScrimScore( player.uniqueId, rrChange )
val rrTag = if ( rrChange >= 0 ) "<green>+${rrChange} RR</green>" val rankAfter = Rank.fromScore( stats?.scrimScore ?: 0 )
else "<red>${rrChange} RR</red>"
if ( rankBefore != rankAfter && gamesPlayed >= PLACEMENT_GAMES )
notifyRankChange( player, rankBefore, rankAfter )
rrTag = if ( rrChange >= 0 ) "<green>+${rrChange} RR</green>"
else "<red>${rrChange} RR</red>"
}
else rrTag = "<gray><italic>(Unranked - no RR)</italic></gray>"
val msgKey = if ( placement == 1 ) "ranking.result_win" val msgKey = if ( placement == 1 ) "ranking.result_win"
else "ranking.result_loss" 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 // Private: Helpers
// ========================================================================= // =========================================================================

View File

@@ -66,6 +66,9 @@ class ScoreboardManager(
val max = Bukkit.getMaxPlayers().toString() val max = Bukkit.getMaxPlayers().toString()
val kitName = plugin.kitManager.getSelectedKit( player )?.displayName ?: Component.text( "None" ) val kitName = plugin.kitManager.getSelectedKit( player )?.displayName ?: Component.text( "None" )
val rank = plugin.rankingManager.getRank( player )
val rankComponent = rank.component
val lines: List<Component> val lines: List<Component>
if ( state == GameState.LOBBY || state == GameState.STARTING ) if ( state == GameState.LOBBY || state == GameState.STARTING )
@@ -75,7 +78,7 @@ class ScoreboardManager(
lines = plugin.languageManager.getMessageList( lines = plugin.languageManager.getMessageList(
player, "scoreboard.lobby", player, "scoreboard.lobby",
mapOf( "online" to online, "max" to max, "time" to timeString ), mapOf( "online" to online, "max" to max, "time" to timeString ),
mapOf( "kit" to kitName ) mapOf( "kit" to kitName, "rank" to rankComponent )
) )
} }
else else
@@ -88,7 +91,7 @@ class ScoreboardManager(
lines = plugin.languageManager.getMessageList( lines = plugin.languageManager.getMessageList(
player, "scoreboard.ingame", player, "scoreboard.ingame",
mapOf( "timer" to timeString, "alive" to alive, "kills" to kills, "border" to border ), mapOf( "timer" to timeString, "alive" to alive, "kills" to kills, "border" to border ),
mapOf( "kit" to kitName ) mapOf( "kit" to kitName, "rank" to rankComponent )
) )
} }

View File

@@ -24,6 +24,12 @@ ranking:
result_win: '<prefix><gold>🏆 Victory!</gold> <gray>Kills: <aqua><kills></aqua></gray> · <rr>' result_win: '<prefix><gold>🏆 Victory!</gold> <gray>Kills: <aqua><kills></aqua></gray> · <rr>'
result_loss: '<prefix><gray>You placed <aqua><placement></aqua> · Kills: <aqua><kills></aqua></gray> · <rr>' result_loss: '<prefix><gray>You placed <aqua><placement></aqua> · Kills: <aqua><kills></aqua></gray> · <rr>'
promote-main: '<gradient:gold:yellow><bold>RANK UP!</bold></gradient>'
promote-sub: '<color><name><reset>'
rank_up: '<prefix><green>⬆ Rank Up!</green> <gray><from> → <to>'
rank_down: '<prefix><red>⬇ Rank Down.</red> <gray><from> → <to>'
title: title:
fight-main: '<red>The battle has begun!</red>' fight-main: '<red>The battle has begun!</red>'
fight-sub: '<red>Try not to die!</red>' fight-sub: '<red>Try not to die!</red>'
@@ -79,6 +85,15 @@ commands:
positiveNumber: '<red>Invalid time format! Use, for example, 10m, 30s, or 600.</red>' positiveNumber: '<red>Invalid time format! Use, for example, 10m, 30s, or 600.</red>'
onlyIngame: '<red>Timer can only be set in game.</red>' onlyIngame: '<red>Timer can only be set in game.</red>'
set: '<green>The game timer has been set to <time>!</green>' set: '<green>The game timer has been set to <time>!</green>'
ranking:
usage: '<red>Usage: /ranking <toggle|status|rank [player]></red>'
enabled: '<prefix><green>✔ Ranked mode is now <bold>ACTIVE</bold>. RR will be awarded next round.</green>'
disabled: '<prefix><yellow>⚠ Unranked mode is now <bold>ACTIVE</bold>. No RR changes next round.</yellow>'
status_enabled: '<prefix><gray>Ranking: <green><bold>ACTIVE</bold></green></gray>'
status_disabled: '<prefix><gray>Ranking: <yellow><bold>DISABLED (Unranked)</bold></yellow></gray>'
rank_usage: '<red>Usage: /ranking rank <player></red>'
player_not_found: '<red>Player <name> is not online.</red>'
rank_info: '<prefix><gray>Player <white><name></white> — <rank> <gray>(<score> RR · <games> games)</gray>'
scoreboard: scoreboard:
title: '<gradient:red:gold><bold>SpeedHG</bold></gradient>' title: '<gradient:red:gold><bold>SpeedHG</bold></gradient>'
@@ -86,6 +101,7 @@ scoreboard:
- "<gray><st> " - "<gray><st> "
- "Players: <green><online>/<max>" - "Players: <green><online>/<max>"
- "Kit: <yellow><kit>" - "Kit: <yellow><kit>"
- "Rank: <rank>"
- "" - ""
- "<gray>Waiting for start..." - "<gray>Waiting for start..."
- "" - ""
@@ -95,6 +111,7 @@ scoreboard:
- "Time: <green><timer>" - "Time: <green><timer>"
- "Players: <red><alive>" - "Players: <red><alive>"
- "Kills: <green><kills>" - "Kills: <green><kills>"
- "Rank: <rank>"
- "" - ""
- "Border: <red><border>" - "Border: <red><border>"
- "" - ""

View File

@@ -13,6 +13,9 @@ permissions:
speedhg.admin.timer: speedhg.admin.timer:
description: 'Change the current game time' description: 'Change the current game time'
default: false default: false
speedhg.admin.ranking:
description: 'Manage the ranking system (toggle unranked mode, inspect ranks)'
default: false
commands: commands:
kit: kit:
@@ -24,4 +27,8 @@ commands:
timer: timer:
description: 'Change the current game time (Admin Command)' description: 'Change the current game time (Admin Command)'
usage: '/timer <seconds>' usage: '/timer <seconds>'
permission: speedhg.admin.timer permission: speedhg.admin.timer
ranking:
description: 'Manage the SpeedHG ranking system'
usage: '/ranking <toggle|status|rank [player]>'
permission: speedhg.admin.ranking