Add RankingManager and integrate RR system

Introduce a new RankingManager that implements a per-round Rank Rating (RR) system (placement + kill-based scoring, placement games, kill caps and RR interpolation). Wire the manager into the plugin (SpeedHG): add field + initialization. Integrate into GameManager: call startRound at game start, registerRoundKill on kills, and onPlayerResult for eliminated players and the winner; adjust some stats calls (addLoss/addWin kept alongside ranking updates). Add corresponding English language messages for placement progress and result notifications.
This commit is contained in:
TDSTOS
2026-04-03 19:27:04 +02:00
parent 184443b7c6
commit ab976cc2a4
4 changed files with 322 additions and 3 deletions

View File

@@ -18,6 +18,7 @@ import club.mcscrims.speedhg.listener.ConnectListener
import club.mcscrims.speedhg.listener.GameStateListener import club.mcscrims.speedhg.listener.GameStateListener
import club.mcscrims.speedhg.listener.SoupListener import club.mcscrims.speedhg.listener.SoupListener
import club.mcscrims.speedhg.listener.StatsListener import club.mcscrims.speedhg.listener.StatsListener
import club.mcscrims.speedhg.ranking.RankingManager
import club.mcscrims.speedhg.scoreboard.ScoreboardManager import club.mcscrims.speedhg.scoreboard.ScoreboardManager
import club.mcscrims.speedhg.webhook.DiscordWebhookManager import club.mcscrims.speedhg.webhook.DiscordWebhookManager
import club.mcscrims.speedhg.world.WorldManager import club.mcscrims.speedhg.world.WorldManager
@@ -64,6 +65,9 @@ class SpeedHG : JavaPlugin() {
lateinit var customGameManager: CustomGameManager lateinit var customGameManager: CustomGameManager
private set private set
lateinit var rankingManager: RankingManager
private set
override fun onLoad() override fun onLoad()
{ {
instance = this instance = this
@@ -95,6 +99,7 @@ class SpeedHG : JavaPlugin() {
languageManager = LanguageManager( this ) languageManager = LanguageManager( this )
gameManager = GameManager( this ) gameManager = GameManager( this )
rankingManager = RankingManager( this )
antiRunningManager = AntiRunningManager( this ) antiRunningManager = AntiRunningManager( this )
scoreboardManager = ScoreboardManager( this ) scoreboardManager = ScoreboardManager( this )
kitManager = KitManager( this ) kitManager = KitManager( this )

View File

@@ -205,6 +205,8 @@ class GameManager(
player.sendMsg( "game.started" ) player.sendMsg( "game.started" )
} }
plugin.rankingManager.startRound( Bukkit.getOnlinePlayers() )
Bukkit.getOnlinePlayers().forEach { player -> Bukkit.getOnlinePlayers().forEach { player ->
player.sendMsg( "game.invincibility-start", "time" to invincibilityTime.toString() ) player.sendMsg( "game.invincibility-start", "time" to invincibilityTime.toString() )
} }
@@ -247,13 +249,14 @@ class GameManager(
player.gameMode = GameMode.SPECTATOR player.gameMode = GameMode.SPECTATOR
plugin.statsManager.addDeath( player.uniqueId ) 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 ) if ( killer != null )
{ {
killer.exp += 0.5f killer.exp += 0.5f
plugin.statsManager.addKill( killer.uniqueId ) plugin.statsManager.addKill( killer.uniqueId )
plugin.statsManager.adjustScrimScore( killer.uniqueId, +15 ) // Elo-Gewinn plugin.rankingManager.registerRoundKill( killer.uniqueId )
} }
player.inventory.contents.filterNotNull().forEach { player.inventory.contents.filterNotNull().forEach {
@@ -296,7 +299,7 @@ class GameManager(
if ( p.uniqueId == winnerUUID ) if ( p.uniqueId == winnerUUID )
{ {
plugin.statsManager.addWin( p.uniqueId ) plugin.statsManager.addWin( p.uniqueId )
plugin.statsManager.adjustScrimScore( p.uniqueId, +50 ) // Elo-Bonus für Win plugin.rankingManager.onPlayerResult( p, isWinner = true )
} }
} }

View File

@@ -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<UUID, Int>()
/**
* 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<Player>
) {
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 ) "<green>+${rrChange} RR</green>"
else "<red>${rrChange} RR</red>"
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"
}
}

View File

@@ -19,6 +19,11 @@ game:
death-pve: '<prefix><yellow><player> has died! There are <left> players left.</yellow>' death-pve: '<prefix><yellow><player> has died! There are <left> players left.</yellow>'
win-chat: '<prefix><green><winner> has won the game! Thanks for playing!</green>' win-chat: '<prefix><green><winner> has won the game! Thanks for playing!</green>'
ranking:
placement_progress: '<prefix><gray>Placement <aqua><current>/<total></aqua> — Placed <aqua>#<placement></aqua> · <aqua><kills></aqua> Kill(s)</gray>'
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>'
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>'