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:
@@ -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 )
|
||||
|
||||
@@ -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 )
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
306
src/main/kotlin/club/mcscrims/speedhg/ranking/RankingManager.kt
Normal file
306
src/main/kotlin/club/mcscrims/speedhg/ranking/RankingManager.kt
Normal 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"
|
||||
}
|
||||
|
||||
}
|
||||
@@ -19,6 +19,11 @@ game:
|
||||
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>'
|
||||
|
||||
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:
|
||||
fight-main: '<red>The battle has begun!</red>'
|
||||
fight-sub: '<red>Try not to die!</red>'
|
||||
|
||||
Reference in New Issue
Block a user