diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt index f7fc4c5..f2ab83c 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt @@ -18,6 +18,7 @@ import club.mcscrims.speedhg.listener.ConnectListener import club.mcscrims.speedhg.listener.GameStateListener import club.mcscrims.speedhg.listener.SoupListener import club.mcscrims.speedhg.listener.StatsListener +import club.mcscrims.speedhg.ranking.RankingManager import club.mcscrims.speedhg.scoreboard.ScoreboardManager import club.mcscrims.speedhg.webhook.DiscordWebhookManager import club.mcscrims.speedhg.world.WorldManager @@ -64,6 +65,9 @@ class SpeedHG : JavaPlugin() { lateinit var customGameManager: CustomGameManager private set + lateinit var rankingManager: RankingManager + private set + override fun onLoad() { instance = this @@ -95,6 +99,7 @@ class SpeedHG : JavaPlugin() { languageManager = LanguageManager( this ) gameManager = GameManager( this ) + rankingManager = RankingManager( this ) antiRunningManager = AntiRunningManager( this ) scoreboardManager = ScoreboardManager( this ) kitManager = KitManager( this ) diff --git a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt index 95c2b2c..5eac34a 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt @@ -205,6 +205,8 @@ class GameManager( player.sendMsg( "game.started" ) } + plugin.rankingManager.startRound( Bukkit.getOnlinePlayers() ) + Bukkit.getOnlinePlayers().forEach { player -> player.sendMsg( "game.invincibility-start", "time" to invincibilityTime.toString() ) } @@ -247,13 +249,14 @@ class GameManager( player.gameMode = GameMode.SPECTATOR plugin.statsManager.addDeath( player.uniqueId ) - plugin.statsManager.adjustScrimScore( player.uniqueId, -25 ) // Elo-Verlust + plugin.statsManager.addLoss( player.uniqueId ) + plugin.rankingManager.onPlayerResult( player, isWinner = false ) if ( killer != null ) { killer.exp += 0.5f plugin.statsManager.addKill( killer.uniqueId ) - plugin.statsManager.adjustScrimScore( killer.uniqueId, +15 ) // Elo-Gewinn + plugin.rankingManager.registerRoundKill( killer.uniqueId ) } player.inventory.contents.filterNotNull().forEach { @@ -296,7 +299,7 @@ class GameManager( if ( p.uniqueId == winnerUUID ) { plugin.statsManager.addWin( p.uniqueId ) - plugin.statsManager.adjustScrimScore( p.uniqueId, +50 ) // Elo-Bonus für Win + plugin.rankingManager.onPlayerResult( p, isWinner = true ) } } diff --git a/src/main/kotlin/club/mcscrims/speedhg/ranking/RankingManager.kt b/src/main/kotlin/club/mcscrims/speedhg/ranking/RankingManager.kt new file mode 100644 index 0000000..615c6f9 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/ranking/RankingManager.kt @@ -0,0 +1,306 @@ +package club.mcscrims.speedhg.ranking + +import club.mcscrims.speedhg.SpeedHG +import club.mcscrims.speedhg.util.sendMsg +import net.kyori.adventure.text.minimessage.MiniMessage +import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder +import org.bukkit.entity.Player +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.roundToInt + +/** + * Manages the RR (Rank Rating) system for SpeedHG. + * + * ## Architecture + * + * RankingManager ist zustandsbehaftet pro Runde. Es tracked: + * - Round-spezifische Kills (getrennt von Career-Stats) + * - Elimination-Reihenfolge für das Placement-Berechnung + * - Placement-Phase (erste [PLACEMENT_GAMES] Spiele) + * + * ## Scoring-Modell + * + * Der kombinierte Score (0.0-1.0) gewichtet: + * - Placement (70%) - wer zuerst stirbt, bestraft stärker + * - Kills (30%) - mildert Verluste bei aggressivem Spiel + * + * Mapping: + * - compositeScore ≥ 0.5 -> Gewinn: [MIN_RR_GAIN]..[MAX_RR_GAIN] + * - compositeScore < 0.5 -> Verlust: -[MAX_RR_LOSS]..-[MIN_RR_LOSS] + * + * Beispiele (10 Spieler): + * | Placement | Kills | Score | RR | + * |-----------|-------|-------|--------| + * | 1 (Win) | 5 | 0.88 | +24 RR | + * | 1 (Win) | 0 | 0.70 | +18 RR | + * | 5 (Mid) | 4 | 0.65 | +16 RR | + * | 7 (Bad) | 3 | 0.40 | -8 RR | + * | 10 (Last) | 5 | 0.19 | -10 RR | + * | 10 (Last) | 0 | 0.00 | -15 RR | + * + */ +class RankingManager( + private val plugin: SpeedHG +) { + + companion object { + /** Anzahl der Placement-Spiele, bevor Ranked-Modus beginnt. */ + const val PLACEMENT_GAMES = 5 + + /** Maximaler RR-Gewinn (Top-Performance: Win mit vielen Kills). */ + const val MAX_RR_GAIN = 30 + + /** Minimaler RR-Gewinn (Gute, aber nicht perfekte Performance). */ + const val MIN_RR_GAIN = 10 + + /** Maximaler RR-Verlust (Schlechteste Performance: erster Tod, 0 Kills). */ + const val MAX_RR_LOSS = 15 + + /** Minimaler RR-Verlust (Schlechtes Placement, aber viele Kills). */ + const val MIN_RR_LOSS = 5 + + /** + * Ab dieser Kill-Anzahl hat der Kill-Bonus seinen Maximaleffekt. + * Mehr Kills als dieser Wert bringen keinen weiteren Bonus. + */ + const val KILL_CAP = 8 + } + + private val mm = MiniMessage.miniMessage() + + // ── Round State (wird in startRound() zurückgesetzt) ───────────────────── + + /** Kills in der aktuellen Runde pro Spieler (getrennt von Career-Kills). */ + private val roundKills = ConcurrentHashMap() + + /** + * Monoton wachsender Index, der bei jedem Tod inkrementiert wird. + * Erster Tod → 0, zweiter → 1, usw. + * Wird benutzt, um das Placement zu berechnen: + * `placement = totalPlayersThisRound - eliminationIndex` + */ + private val eliminationIndex = AtomicInteger(0) + + /** Spieleranzahl zu Rundenstart. */ + @Volatile + private var totalPlayersThisRound = 0 + + // ========================================================================= + // Round Lifecycle + // ========================================================================= + + /** + * Initialisiert den Round-State. Muss in `GameManager.startGame()` aufgerufen werden, + * **nachdem** `alivePlayers` befüllt wurde und die Spieler teleportiert sind. + */ + fun startRound( + players: Collection + ) { + roundKills.clear() + eliminationIndex.set( 0 ) + totalPlayersThisRound = players.size + players.forEach { roundKills[ it.uniqueId ] = 0 } + plugin.logger.info("[RankingManager] Runde gestartet mit ${players.size} Spielern.") + } + + /** + * Registriert einen Kill in der aktuellen Runde. + * + * Aufruf: In `GameManager.onPlayerEliminated()`, wenn ein [killer] vorhanden ist - + * **vor** dem Aufruf von [onPlayerResult]. + */ + fun registerRoundKill( + killerUUID: UUID + ) { + roundKills.compute( killerUUID ) { _, current -> ( current ?: 0 ) + 1 } + } + + /** + * Verarbeitet das Runden-Ergebnis eines Spielers und passt den scrimScore an. + * + * - Für eliminierte Spieler: In `GameManager.onPlayerEliminated()` aufrufen. + * - Für den Gewinner: In `GameManager.endGame()` mit `isWinner = true` aufrufen. + * + * Diese Methode: + * 1. Berechnet das Placement. + * 2. Erkennt Placement-Phase vs. Ranked. + * 3. Passt `scrimScore` im Cache an und triggert einen asynchronen DB-Write. + * 4. Sendet eine MiniMessage-Nachricht an den Spieler. + * + * @param player Der betroffene Spieler. + * @param isWinner `true` nur für den letzten überlebenden Spieler. + */ + fun onPlayerResult( + player: Player, + isWinner: Boolean + ) { + val stats = plugin.statsManager.getCachedStats( player.uniqueId ) ?: run { + plugin.logger.warning("[RankingManager] Keine Stats für ${player.name} im Cache!") + return + } + + // Placement berechnen: 1 = Winner, totalPlayers = Erster Tod (schlecht) + val placement = if ( isWinner ) 1 + else ( totalPlayersThisRound - eliminationIndex.getAndIncrement() ).coerceAtLeast( 1 ) + + val kills = roundKills[ player.uniqueId ] ?: 0 + // wins + losses = abgeschlossene Spiele + val gamesPlayed = stats.wins + stats.losses + + if ( gamesPlayed < PLACEMENT_GAMES ) { + handlePlacementMatch( player, gamesPlayed, kills, placement ) + } else { + handleRankedMatch( player, kills, placement ) + } + } + + /** + * Setzt den Round-State zurück. + * Wird automatisch von [startRound] aufgerufen — kann optional auch in + * `GameManager.endGame()` am Ende manuell aufgerufen werden. + */ + fun resetRound() + { + roundKills.clear() + eliminationIndex.set( 0 ) + totalPlayersThisRound = 0 + } + + // ========================================================================= + // Core RR Algorithm + // ========================================================================= + + /** + * Berechnet die RR-Änderung für einen Spieler. + * + * Der Algorithmus arbeitet mit einem normierten Composite-Score [0.0, 1.0]: + * - Placement-Score (70%): normiert über alle Spieler dieser Runde. + * - Kill-Score (30%): begrenzt auf [KILL_CAP] Kills für maximalen Effekt. + * + * Der finale RR-Wert wird linear interpoliert: + * - compositeScore ∈ [0.5, 1.0] → interpoliert in [+MIN_RR_GAIN, +MAX_RR_GAIN] + * - compositeScore ∈ [0.0, 0.5) → interpoliert in [-MAX_RR_LOSS, -MIN_RR_LOSS] + * + * @param placement Rang in dieser Runde (1 = Winner, [totalPlayers] = Erster Tod). + * @param totalPlayers Anzahl Spieler zu Rundenstart. + * @param kills Kills in dieser Runde. + * @return RR-Delta (positiv = Gewinn, negativ = Verlust). + */ + fun calculateRRChange( + placement: Int, + totalPlayers: Int, + kills: Int + ): Int + { + // Edge-case: nur 1 Spieler -> immer Minimalgewinn + if ( totalPlayers <= 1 ) return MIN_RR_GAIN + + // ── 1. Placement-Score: 1.0 = Winner, 0.0 = Erster Tod ───────────── + val placementScore = ( totalPlayers - placement ).toDouble() + .div( totalPlayers - 1 ) + .coerceIn( 0.0, 1.0 ) + + // ── 2. Kill-Score: 0.0 = 0 Kills, 1.0 = KILL_CAP+ Kills ───────────── + val killScore = ( kills.toDouble() / KILL_CAP ).coerceIn( 0.0, 1.0 ) + + // ── 3. Gewichteter Composite (Placement dominiert) ──────────────────── + val compositeScore = placementScore * 0.7 + killScore * 0.3 + + // ── 4. Mapping auf RR-Spannen via linearer Interpolation ───────────── + return if ( compositeScore >= 0.5 ) + { + // Gute Performance -> +MIN_RR_GAIN bis +MAX_RR_GAIN + lerp( + from = MIN_RR_GAIN.toDouble(), + to = MAX_RR_GAIN.toDouble(), + t = ( compositeScore - 0.5 ) * 2.0 + ).roundToInt() + } + else + { + // Schlechte Performance -> -MAX_RR_LOSS bis -MIN_RR_LOSS + lerp( + from = -MAX_RR_LOSS.toDouble(), + to = -MIN_RR_LOSS.toDouble(), + t = compositeScore * 2.0 + ).roundToInt() + } + } + + // ========================================================================= + // Private: Match Handlers + // ========================================================================= + + private fun handlePlacementMatch( + player: Player, + completedGames: Int, + 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 ) + + // Spielnummer im Display: min mit PLACEMENT_GAMES, damit kein "6/5" erscheint + val currentGameNumber = ( completedGames + 1 ).coerceAtMost( PLACEMENT_GAMES ) + + player.sendMsg( + "ranking.placement_progress", + "current" to currentGameNumber.toString(), + "total" to PLACEMENT_GAMES.toString(), + "kills" to kills.toString(), + "placement" to placement.toString() + ) + } + + private fun handleRankedMatch( + player: Player, + kills: Int, + placement: Int + ) { + val rrChange = calculateRRChange( placement, totalPlayersThisRound, kills ) + + // scrimScore clamp auf >= 0 ist bereits in adjustScrimScore() via coerceAtLest(0) gesichert + plugin.statsManager.adjustScrimScore( player.uniqueId, rrChange ) + + // RR-Tag vorformatieren + val rrTag = if ( rrChange >= 0 ) "+${rrChange} RR" + else "${rrChange} RR" + + val msgKey = if ( placement == 1 ) "ranking.result_win" + else "ranking.result_loss" + + player.sendMsg( + msgKey, + "placement" to ordinal( placement ), + "kills" to kills.toString(), + "rr" to rrTag + ) + } + + // ========================================================================= + // Private: Helpers + // ========================================================================= + + /** Lineare Interpolation von [from] nach [to] mit Faktor [t] ∈ [0, 1]. */ + private fun lerp( from: Double, to: Double, t: Double ): Double = + from + ( to - from ) * t.coerceIn( 0.0, 1.0 ) + + /** Gibt die englische Ordinalzahl zurück, z.B. 1 -> "1st", 4 -> "4th" */ + private fun ordinal( + n: Int + ): String + { + val suffix = when { + n % 100 in 11..13 -> "th" + n % 10 == 1 -> "st" + n % 10 == 2 -> "nd" + n % 10 == 3 -> "rd" + else -> "th" + } + return "$n$suffix" + } + +} \ No newline at end of file diff --git a/src/main/resources/languages/en_US.yml b/src/main/resources/languages/en_US.yml index 06a21a0..1ca1d97 100644 --- a/src/main/resources/languages/en_US.yml +++ b/src/main/resources/languages/en_US.yml @@ -19,6 +19,11 @@ game: death-pve: ' has died! There are players left.' win-chat: ' has won the game! Thanks for playing!' +ranking: + placement_progress: 'Placement / — Placed # · Kill(s)' + result_win: '🏆 Victory! Kills: · ' + result_loss: 'You placed · Kills: · ' + title: fight-main: 'The battle has begun!' fight-sub: 'Try not to die!'