From 2d720d962c7d4437231427040ac366a7b9d1ddc9 Mon Sep 17 00:00:00 2001 From: TDSTOS Date: Thu, 26 Mar 2026 00:10:26 +0100 Subject: [PATCH] Add persistent player stats and DB support Introduce MySQL persistence and an in-memory stats system with async saving. - Add HikariCP & MySQL connector dependencies and relocate libs in build.gradle.kts. - Add DatabaseManager (HikariCP) to manage connection pool and lifecycle. - Add PlayerStats data model and PlayerStatsRepository for table creation, reads, upserts and batch upserts (with prepared statements). - Add StatsManager: coroutine-based cache, dirty-flags, async batch saves, auto-save (every 5 minutes), load/save APIs and shutdown save. - Add StatsListener: load on AsyncPlayerPreLoginEvent and save on PlayerQuitEvent. - Wire DB and stats into main plugin: connect on enable (disable plugin on fail), initialize StatsManager, save/disconnect on disable, register leaderboard command and stats listener. - Update GameManager to record kills/wins/deaths and adjust scrimScore on events. - Add LeaderboardCommand and language entries for leaderboard output; expose command in plugin.yml. - Add database configuration section to config.yml. - Minor refactor: KitCommand plugin accessor changed to a getter. These changes provide a robust, pooled DB connection and efficient stats persistence (batched/upserted) to reduce DB load and ensure data safety during shutdown. --- build.gradle.kts | 7 +- .../kotlin/club/mcscrims/speedhg/SpeedHG.kt | 39 ++- .../mcscrims/speedhg/command/KitCommand.kt | 2 +- .../speedhg/command/LeaderboardCommand.kt | 54 ++++ .../speedhg/database/DatabaseManager.kt | 97 +++++++ .../mcscrims/speedhg/database/PlayerStats.kt | 65 +++++ .../speedhg/database/PlayerStatsRepository.kt | 208 ++++++++++++++ .../mcscrims/speedhg/database/StatsManager.kt | 262 ++++++++++++++++++ .../club/mcscrims/speedhg/game/GameManager.kt | 20 ++ .../speedhg/listener/StatsListener.kt | 51 ++++ src/main/resources/config.yml | 14 +- src/main/resources/languages/en_US.yml | 5 + src/main/resources/plugin.yml | 5 +- 13 files changed, 818 insertions(+), 11 deletions(-) create mode 100644 src/main/kotlin/club/mcscrims/speedhg/command/LeaderboardCommand.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/database/DatabaseManager.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/database/PlayerStats.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/database/PlayerStatsRepository.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/database/StatsManager.kt create mode 100644 src/main/kotlin/club/mcscrims/speedhg/listener/StatsListener.kt diff --git a/build.gradle.kts b/build.gradle.kts index 4f8a550..94c33bb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,8 +23,11 @@ repositories { dependencies { implementation("fr.mrmicky:fastboard:2.1.3") - compileOnly("io.papermc.paper:paper-api:1.21.1-R0.1-SNAPSHOT") + implementation("com.zaxxer:HikariCP:5.1.0") + implementation("com.mysql:mysql-connector-j:8.4.0") + implementation(libs.kotlinxCoroutines) + compileOnly("io.papermc.paper:paper-api:1.21.1-R0.1-SNAPSHOT") compileOnly("com.sk89q.worldedit:worldedit-core:7.2.17-SNAPSHOT") compileOnly("com.sk89q.worldedit:worldedit-bukkit:7.2.17-SNAPSHOT") } @@ -42,6 +45,8 @@ tasks { archiveBaseName.set("GameModes-SpeedHG") archiveClassifier.set("") archiveVersion.set(project.version.toString()) + relocate("com.zaxxer.hikari", "club.mcscrims.speedhg.libs.hikari") + relocate("com.mysql", "club.mcscrims.speedhg.libs.mysql") } build { diff --git a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt index af18a1c..4f7bbb7 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/SpeedHG.kt @@ -1,7 +1,10 @@ package club.mcscrims.speedhg import club.mcscrims.speedhg.command.KitCommand +import club.mcscrims.speedhg.command.LeaderboardCommand import club.mcscrims.speedhg.config.LanguageManager +import club.mcscrims.speedhg.database.DatabaseManager +import club.mcscrims.speedhg.database.StatsManager import club.mcscrims.speedhg.game.GameManager import club.mcscrims.speedhg.game.modules.AntiRunningManager import club.mcscrims.speedhg.kit.KitManager @@ -13,6 +16,7 @@ import club.mcscrims.speedhg.kit.listener.KitEventDispatcher 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.scoreboard.ScoreboardManager import org.bukkit.Bukkit import org.bukkit.plugin.java.JavaPlugin @@ -41,21 +45,37 @@ class SpeedHG : JavaPlugin() { lateinit var kitManager: KitManager private set + lateinit var databaseManager: DatabaseManager + private set + + lateinit var statsManager: StatsManager + private set + override fun onEnable() { instance = this - saveDefaultConfig() - languageManager = LanguageManager( this ) - gameManager = GameManager( this ) - antiRunningManager = AntiRunningManager( this ) + databaseManager = DatabaseManager( this ) + try { + databaseManager.connect() + } catch ( e: Exception ) { + logger.severe( "[Database] Verbindung fehlgeschlagen: ${e.message}" ) + logger.severe( "[Database] Plugin wird deaktiviert." ) + server.pluginManager.disablePlugin( this ) + return + } - scoreboardManager = ScoreboardManager( this ) + statsManager = StatsManager( this ) + statsManager.initialize() + + languageManager = LanguageManager( this ) + gameManager = GameManager( this ) + antiRunningManager = AntiRunningManager( this ) + scoreboardManager = ScoreboardManager( this ) + kitManager = KitManager( this ) - kitManager = KitManager( this ) registerKits() - registerCommands() registerListener() @@ -64,6 +84,8 @@ class SpeedHG : JavaPlugin() { override fun onDisable() { + if ( ::statsManager.isInitialized ) statsManager.shutdown() + if ( ::databaseManager.isInitialized ) databaseManager.disconnect() kitManager.clearAll() super.onDisable() } @@ -81,6 +103,8 @@ class SpeedHG : JavaPlugin() { val kitCommand = KitCommand() getCommand( "kit" )?.setExecutor( kitCommand ) getCommand( "kit" )?.tabCompleter = kitCommand + + getCommand( "leaderboard" )?.setExecutor( LeaderboardCommand() ) } private fun registerListener() @@ -91,6 +115,7 @@ class SpeedHG : JavaPlugin() { pm.registerEvents( GameStateListener(), this ) pm.registerEvents( SoupListener(), this ) pm.registerEvents(KitEventDispatcher( this, kitManager ), this ) + pm.registerEvents( StatsListener(), this ) } } \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/command/KitCommand.kt b/src/main/kotlin/club/mcscrims/speedhg/command/KitCommand.kt index cd1fad5..4d5e275 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/command/KitCommand.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/command/KitCommand.kt @@ -13,7 +13,7 @@ import org.bukkit.entity.Player class KitCommand : CommandExecutor, TabCompleter { - private val plugin = SpeedHG.instance + private val plugin get() = SpeedHG.instance override fun onCommand( sender: CommandSender, diff --git a/src/main/kotlin/club/mcscrims/speedhg/command/LeaderboardCommand.kt b/src/main/kotlin/club/mcscrims/speedhg/command/LeaderboardCommand.kt new file mode 100644 index 0000000..c7aa81d --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/command/LeaderboardCommand.kt @@ -0,0 +1,54 @@ +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.entity.Player + +class LeaderboardCommand : CommandExecutor { + + private val plugin get() = SpeedHG.instance + + override fun onCommand( + sender: CommandSender, + command: Command, + label: String, + args: Array + ): Boolean + { + val player = sender as? Player + + if ( player == null ) + { + sender.sendMessage( "§cOnly players can execute this command." ) + return true + } + + plugin.statsManager.getLeaderboard( limit = 10 ) { topPlayers -> + // 1. Header senden + player.sendMsg( "commands.leaderboard.header" ) + + // 2. Spieler auflisten + if ( topPlayers.isEmpty() ) + { + player.sendMsg( "commands.leaderboard.empty" ) + } + else topPlayers.forEachIndexed { index, stats -> + val rank = ( index + 1 ).toString() + + val playerName = stats.name + val score = stats.scrimScore.toString() + + player.sendMsg( "commands.leaderboard.line", "rank" to rank, "name" to playerName, "score" to score ) + } + + // 3. Footer senden + player.sendMsg( "commands.leaderboard.footer" ) + } + return true + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/database/DatabaseManager.kt b/src/main/kotlin/club/mcscrims/speedhg/database/DatabaseManager.kt new file mode 100644 index 0000000..7039dbf --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/database/DatabaseManager.kt @@ -0,0 +1,97 @@ +package club.mcscrims.speedhg.database + +import club.mcscrims.speedhg.SpeedHG +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import java.sql.Connection + +/** + * Verwaltet den HikariCP Connection Pool zur MySQL-Datenbank. + * + * ## Design-Entscheidungen + * + * **HikariCP statt manueller Connections**: HikariCP hält einen Pool aus + * vorbereiteten Verbindungen. Statt bei jeder Query eine neue TCP-Verbindung + * aufzubauen (~50–200ms), wird eine bereits offene Verbindung aus dem Pool + * gezogen (~µs). Bei einem HG-Server mit vielen gleichzeitigen Queries + * (Kills, Ends, Rejoins) ist das entscheidend. + * + * **`poolName` + `connectionTestQuery`**: Macht Logs lesbar und verhindert + * "stale connection"-Fehler nach einem MySQL-Timeout (`wait_timeout`). + * + * Konfiguration wird aus `config.yml` unter dem Key `database.*` gelesen. + */ +class DatabaseManager(private val plugin: SpeedHG) { + + private lateinit var dataSource: HikariDataSource + + val isConnected: Boolean + get() = ::dataSource.isInitialized && !dataSource.isClosed + + /** + * Baut den Connection Pool auf. Wirft eine [Exception] wenn die + * Verbindung fehlschlägt — das Plugin sollte sich dann selbst deaktivieren. + */ + fun connect() { + val cfg = plugin.config + + val hikariConfig = HikariConfig().apply { + // JDBC-URL mit wichtigen Flags: + // allowPublicKeyRetrieval=true → nötig für MySQL 8+ ohne SSL + // characterEncoding=utf8 → Umlaut-Sicherheit + // autoReconnect=true → erholt sich nach Netzwerkunterbrechungen + jdbcUrl = buildString { + append("jdbc:mysql://") + append(cfg.getString("database.host", "localhost")) + append(":") + append(cfg.getInt("database.port", 3306)) + append("/") + append(cfg.getString("database.name", "speedhg")) + append("?autoReconnect=true") + append("&characterEncoding=utf8") + append("&useSSL=${cfg.getBoolean("database.use-ssl", false)}") + append("&allowPublicKeyRetrieval=true") + append("&serverTimezone=UTC") + } + + username = cfg.getString("database.username", "root")!! + password = cfg.getString("database.password", "")!! + + // Pool-Größe: 10 aktive + 2 idle ist für einen einzelnen + // HG-Server mehr als ausreichend. + maximumPoolSize = cfg.getInt("database.pool.max-size", 10) + minimumIdle = cfg.getInt("database.pool.min-idle", 2) + + // Timeouts (in ms): + // connectionTimeout → wie lange auf eine freie Connection gewartet wird + // idleTimeout → wann idle Connections aus dem Pool entfernt werden + // maxLifetime → maximale Lebensdauer einer Connection (< MySQL wait_timeout!) + connectionTimeout = 30_000L + idleTimeout = 600_000L + maxLifetime = 1_800_000L // 30 min; MySQL default wait_timeout = 8h + + poolName = "SpeedHG-DB-Pool" + connectionTestQuery = "SELECT 1" // schneller Heartbeat statt vollständigem Ping + } + + dataSource = HikariDataSource(hikariConfig) + plugin.logger.info("[Database] HikariCP Pool gestartet (${hikariConfig.maximumPoolSize} connections).") + } + + /** Schließt alle Datenbankverbindungen sauber. Immer in `onDisable()` aufrufen. */ + fun disconnect() { + if (isConnected) { + dataSource.close() + plugin.logger.info("[Database] Connection Pool geschlossen.") + } + } + + /** + * Gibt eine Connection aus dem Pool zurück. + * **Immer** mit `use { }` aufrufen, damit sie automatisch zurückgegeben wird: + * ```kotlin + * db.getConnection().use { conn -> ... } + * ``` + */ + fun getConnection(): Connection = dataSource.connection +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/database/PlayerStats.kt b/src/main/kotlin/club/mcscrims/speedhg/database/PlayerStats.kt new file mode 100644 index 0000000..ba6af38 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/database/PlayerStats.kt @@ -0,0 +1,65 @@ +package club.mcscrims.speedhg.database + +import java.util.UUID + +/** + * Hält die rohen Spieler-Statistiken im Arbeitsspeicher. + * + * ## Design-Entscheidungen + * + * **Mutable `var`-Felder**: Stats werden ausschließlich auf dem Main-Thread + * modifiziert (via Bukkit-Events). Der IO-Thread liest sie nur zum Speichern. + * Da Kotlin-Reads auf dem JVM für primitive Typen atomic sind und wir keine + * transaktionale Konsistenz zwischen Feldern brauchen, ist hier keine weitere + * Synchronisierung nötig. Bei kritischeren Systemen (z.B. Echtzeit-Ranglisten) + * würde man `AtomicInteger` verwenden. + * + * **Keine DB-Spalten für KD/WinRate**: Abgeleitete Werte werden ausschließlich + * als berechnete Properties gehalten, da sie jederzeit aus den Rohdaten + * rekonstruiert werden können. Das spart DB-Speicher und verhindert, dass + * die Daten jemals inkonsistent werden. + * + * @param uuid UUID des Spielers (Primärschlüssel in der DB) + * @param kills Gesamtzahl der Kills + * @param deaths Gesamtzahl der Deaths + * @param wins Gesamtzahl der gewonnenen Runden + * @param losses Gesamtzahl der verlorenen Runden + * @param scrimScore Elo-ähnlicher Ranking-Wert (Standard: 1000) + */ +data class PlayerStats( + val uuid: UUID, + val name: String, + var kills: Int = 0, + var deaths: Int = 0, + var wins: Int = 0, + var losses: Int = 0, + var scrimScore: Int = 1000 +) { + /** + * Kill/Death-Ratio. + * Gibt [kills] zurück (statt Infinity), wenn [deaths] == 0 — verhindert + * eine Division durch Null und zeigt dennoch einen aussagekräftigen Wert. + */ + val kdRatio: Double + get() = if (deaths == 0) kills.toDouble() + else kills.toDouble() / deaths.toDouble() + + /** + * Gewinnrate in Prozent (0.0 – 100.0). + * Gibt 0.0 zurück wenn noch keine Runde gespielt wurde. + */ + val winRate: Double + get() { + val total = wins + losses + return if (total == 0) 0.0 + else wins.toDouble() / total.toDouble() * 100.0 + } + + /** K/D formatiert auf 2 Dezimalstellen, z.B. `"2.35"`. */ + val formattedKD: String + get() = "%.2f".format(kdRatio) + + /** Win-Rate formatiert, z.B. `"67.3%"`. */ + val formattedWinRate: String + get() = "%.1f%%".format(winRate) +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/database/PlayerStatsRepository.kt b/src/main/kotlin/club/mcscrims/speedhg/database/PlayerStatsRepository.kt new file mode 100644 index 0000000..a85e438 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/database/PlayerStatsRepository.kt @@ -0,0 +1,208 @@ +package club.mcscrims.speedhg.database + +import java.util.UUID + +/** + * Kapselt alle SQL-Operationen für die `player_stats`-Tabelle. + * + * ## Design-Entscheidungen + * + * **PreparedStatements**: Schützen vor SQL-Injection und sind nach dem ersten + * Ausführen im Statement-Cache der JDBC-Connection vorgehalten — schneller + * als dynamische Strings. + * + * **`use { }`-Blocks**: Kotlin's `AutoCloseable.use()` schließt Connection, + * Statement und ResultSet garantiert, auch im Fehlerfall. Kein finally-Block nötig. + * + * **UPSERT mit `ON DUPLICATE KEY UPDATE`**: Ein einzelnes Statement erledigt + * sowohl INSERT (neuer Spieler) als auch UPDATE (bestehender Spieler). Keine + * vorherige SELECT-Abfrage zum Prüfen nötig. + * + * **Batch-Insert**: `upsertBatch()` sendet alle Rows in einer einzigen + * Netzwerk-Roundtrip-Sequenz. Bei 20 Spielern sind das 20× weniger + * DB-Roundtrips als einzelne Calls. + * + * Diese Klasse führt **keine** async-Logik durch — das ist Aufgabe des + * [StatsManager]. So bleibt die Klasse testbar und wiederverwendbar. + */ +class PlayerStatsRepository(private val db: DatabaseManager) { + + // ------------------------------------------------------------------------- + // Schema + // ------------------------------------------------------------------------- + + /** + * Erstellt die Tabelle falls sie noch nicht existiert. + * Idempotent — sicher bei jedem Plugin-Start aufrufbar. + */ + fun createTableIfNotExists() { + db.getConnection().use { conn -> + conn.createStatement().use { stmt -> + stmt.execute( + """ + CREATE TABLE IF NOT EXISTS player_stats ( + uuid VARCHAR(36) NOT NULL, + name VARCHAR(16) NOT NULL, + kills INT NOT NULL DEFAULT 0, + deaths INT NOT NULL DEFAULT 0, + wins INT NOT NULL DEFAULT 0, + losses INT NOT NULL DEFAULT 0, + scrim_score INT NOT NULL DEFAULT 1000, + last_updated TIMESTAMP NOT NULL + DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (uuid) + ) ENGINE=InnoDB + DEFAULT CHARSET=utf8mb4 + COLLATE=utf8mb4_unicode_ci; + """.trimIndent() + ) + } + } + } + + // ------------------------------------------------------------------------- + // Lesen + // ------------------------------------------------------------------------- + + /** + * Lädt die Stats für eine UUID aus der DB. + * Gibt `null` zurück wenn der Spieler noch nie in der DB war. + */ + fun findByUUID(uuid: UUID): PlayerStats? { + db.getConnection().use { conn -> + conn.prepareStatement( + "SELECT name, kills, deaths, wins, losses, scrim_score FROM player_stats WHERE uuid = ?" + ).use { stmt -> + stmt.setString(1, uuid.toString()) + + stmt.executeQuery().use { rs -> + if (!rs.next()) return null + + return PlayerStats( + uuid = uuid, + name = rs.getString("name"), + kills = rs.getInt("kills"), + deaths = rs.getInt("deaths"), + wins = rs.getInt("wins"), + losses = rs.getInt("losses"), + scrimScore = rs.getInt("scrim_score") + ) + } + } + } + } + + /** + * Gibt die Top-N Spieler nach [ScrimScore] zurück. + * Nützlich für eine Rangliste (/top, /leaderboard). + */ + fun findTopByScrimScore(limit: Int = 10): List { + val results = mutableListOf() + + db.getConnection().use { conn -> + conn.prepareStatement( + """ + SELECT uuid, name, kills, deaths, wins, losses, scrim_score + FROM player_stats + ORDER BY scrim_score DESC + LIMIT ? + """.trimIndent() + ).use { stmt -> + stmt.setInt(1, limit) + + stmt.executeQuery().use { rs -> + while (rs.next()) { + results += PlayerStats( + uuid = UUID.fromString(rs.getString("uuid")), + name = rs.getString("name"), + kills = rs.getInt("kills"), + deaths = rs.getInt("deaths"), + wins = rs.getInt("wins"), + losses = rs.getInt("losses"), + scrimScore = rs.getInt("scrim_score") + ) + } + } + } + } + + return results + } + + // ------------------------------------------------------------------------- + // Schreiben (Upsert) + // ------------------------------------------------------------------------- + + /** Speichert oder aktualisiert die Stats eines einzelnen Spielers. */ + fun upsert(stats: PlayerStats) { + db.getConnection().use { conn -> + conn.prepareStatement(UPSERT_SQL).use { stmt -> + stmt.applyStats(stats) + stmt.executeUpdate() + } + } + } + + /** + * Speichert mehrere Spieler in einem einzigen Batch-Aufruf. + * Deutlich effizienter als einzelne [upsert]-Calls in einer Schleife. + * + * Wird in [StatsManager.saveAllDirty] und [StatsManager.saveAllBlocking] + * verwendet. + */ + fun upsertBatch(statsList: Collection) { + if (statsList.isEmpty()) return + + db.getConnection().use { conn -> + // Auto-Commit deaktivieren → alle Rows landen in einer einzigen Transaktion. + // Das ist signifikant schneller und atomar. + conn.autoCommit = false + + try { + conn.prepareStatement(UPSERT_SQL).use { stmt -> + statsList.forEach { stats -> + stmt.applyStats(stats) + stmt.addBatch() + } + stmt.executeBatch() + } + conn.commit() + } catch (e: Exception) { + conn.rollback() + throw e // nach oben weitergeben, damit StatsManager loggen kann + } finally { + conn.autoCommit = true + } + } + } + + // ------------------------------------------------------------------------- + // Hilfsmittel + // ------------------------------------------------------------------------- + + private companion object { + const val UPSERT_SQL = """ + INSERT INTO player_stats (uuid, name, kills, deaths, wins, losses, scrim_score) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + kills = VALUES(kills), + deaths = VALUES(deaths), + wins = VALUES(wins), + losses = VALUES(losses), + scrim_score = VALUES(scrim_score) + """ + } + + /** Extension um den Prepared-Statement-Boilerplate zu zentralisieren. */ + private fun java.sql.PreparedStatement.applyStats(s: PlayerStats) { + setString(1, s.uuid.toString()) + setString(2, s.name) + setInt(3, s.kills) + setInt(4, s.deaths) + setInt(5, s.wins) + setInt(6, s.losses) + setInt(7, s.scrimScore) + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/database/StatsManager.kt b/src/main/kotlin/club/mcscrims/speedhg/database/StatsManager.kt new file mode 100644 index 0000000..f729b24 --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/database/StatsManager.kt @@ -0,0 +1,262 @@ +package club.mcscrims.speedhg.database + +import club.mcscrims.speedhg.SpeedHG +import kotlinx.coroutines.* +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.milliseconds + +/** + * Orchestriert den gesamten Stats-Lebenszyklus: Laden, Caching, Mutationen + * und asynchrones Speichern. + * + * ## Design-Entscheidungen + * + * ### Coroutines statt Bukkit-Scheduler + * Bukkit's `runTaskAsynchronously` ist ein einfacher Thread-Pool-Wrapper ohne + * strukturiertes Error-Handling. Kotlin Coroutines bieten: + * - `SupervisorJob()`: Ein Fehler in einer Coroutine killt nicht den ganzen Scope. + * - `Dispatchers.IO`: Optimierter Thread-Pool für blockierende I/O-Operationen. + * - `scope.cancel()` in `shutdown()`: Alle laufenden Coroutines werden sauber beendet. + * + * ### Cache + Dirty-Flag + * Wir speichern **nicht** bei jedem Kill in die DB. Stattdessen: + * 1. Mutationen → nur im RAM-Cache ([cache]) + * 2. UUID wird als "dirty" markiert ([dirtyFlags]) + * 3. Erst beim Quit oder beim Auto-Save wird die DB geschrieben + * + * Das reduziert die DB-Last bei 20 Spielern und 500 Kills/Runde von + * 500 einzelnen Writes auf ~1 Batch-Write alle 5 Minuten + 20 Writes am Ende. + * + * ### Thread-Safety + * Mutationen ([addKill], [addWin] etc.) werden **ausschließlich vom Main-Thread** + * aufgerufen — alle Bukkit-Events laufen dort. Der IO-Thread liest die Werte + * nur (beim Speichern). Da Java-Reads auf `Int`-Feldern atomar sind, ist das + * ohne explizite Synchronisierung sicher. + */ +class StatsManager(private val plugin: SpeedHG) { + + private val repository = PlayerStatsRepository(plugin.databaseManager) + + /** RAM-Cache: UUID → Stats-Objekt (immer aktuell) */ + private val cache = ConcurrentHashMap() + + /** + * Dirty-Flag: true = Stats wurden verändert und müssen noch gespeichert werden. + * Wird nach erfolgreichem DB-Write entfernt. + */ + private val dirtyFlags = ConcurrentHashMap() + + /** + * Coroutine Scope für alle async DB-Operationen. + * SupervisorJob stellt sicher, dass ein Fehler bei einem einzelnen Save + * nicht alle anderen Coroutines abbricht. + */ + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // ------------------------------------------------------------------------- + // Initialisierung + // ------------------------------------------------------------------------- + + /** + * Erstellt die Tabelle und startet den Auto-Save-Task. + * Muss nach [DatabaseManager.connect] aufgerufen werden. + */ + fun initialize() { + repository.createTableIfNotExists() + startAutoSave() + plugin.logger.info("[StatsManager] Initialisiert (Auto-Save alle 5 Minuten).") + } + + // ------------------------------------------------------------------------- + // Laden (async) + // ------------------------------------------------------------------------- + + /** + * Lädt die Stats eines Spielers aus der DB in den Cache. + * + * Diese Funktion ist eine **suspend function** und muss in einer Coroutine + * aufgerufen werden. Sie blockiert niemals den Main-Thread. + * + * Ist die UUID bereits im Cache, wird kein DB-Call gemacht (idempotent). + * + * Primärer Aufrufpunkt: [AsyncPlayerPreLoginEvent] → der Spieler wartet + * kurz beim Login, dafür sind die Daten beim ersten Tick garantiert da. + * + * @return Das [PlayerStats]-Objekt (neu aus DB oder aus Cache). + */ + suspend fun loadStats(uuid: UUID): PlayerStats = withContext(Dispatchers.IO) { + cache.getOrPut(uuid) { + repository.findByUUID(uuid) ?: PlayerStats(uuid) // Neuer Spieler → Defaults + } + } + + /** + * Startet das Laden im Hintergrund ohne zu warten. + * Für Fälle wo kein `runBlocking` erwünscht ist. + */ + fun loadStatsAsync(uuid: UUID) { + scope.launch { + loadStats(uuid) + } + } + + // ------------------------------------------------------------------------- + // Cache-Zugriff (Main-Thread, sync) + // ------------------------------------------------------------------------- + + /** + * Gibt die gecachten Stats zurück **ohne DB-Call**. + * Gibt `null` zurück wenn der Spieler noch nicht geladen wurde — + * das sollte nach korrektem Setup via [AsyncPlayerPreLoginEvent] nicht passieren. + */ + fun getCachedStats(uuid: UUID): PlayerStats? = cache[uuid] + + // ------------------------------------------------------------------------- + // Mutations (Main-Thread only) + // ------------------------------------------------------------------------- + + fun addKill(uuid: UUID) { cache[uuid]?.let { it.kills++ ; markDirty(uuid) } } + fun addDeath(uuid: UUID) { cache[uuid]?.let { it.deaths++ ; markDirty(uuid) } } + fun addWin(uuid: UUID) { cache[uuid]?.let { it.wins++ ; markDirty(uuid) } } + fun addLoss(uuid: UUID) { cache[uuid]?.let { it.losses++ ; markDirty(uuid) } } + + /** + * Erhöht oder verringert den ScrimScore um [delta] (kann negativ sein). + * Clamps auf >= 0, damit kein negativer Score möglich ist. + */ + fun adjustScrimScore(uuid: UUID, delta: Int) { + cache[uuid]?.let { + it.scrimScore = (it.scrimScore + delta).coerceAtLeast(0) + markDirty(uuid) + } + } + + // ------------------------------------------------------------------------- + // Speichern (async & blocking) + // ------------------------------------------------------------------------- + + /** + * Speichert die Stats eines Spielers asynchron und entfernt ihn aus dem Cache. + * + * Aufrufpunkt: `PlayerQuitEvent` — nach diesem Event brauchen wir die + * Daten nicht mehr im RAM. + */ + fun saveAndEvict(uuid: UUID) { + val stats = cache[uuid] ?: return + + scope.launch { + try { + repository.upsert(stats) + } catch (e: Exception) { + plugin.logger.severe("[StatsManager] Fehler beim Speichern für $uuid: ${e.message}") + } finally { + cache.remove(uuid) + dirtyFlags.remove(uuid) + } + } + } + + /** + * Speichert alle dirty Einträge in einem einzigen Batch-Call. + * + * Aufrufpunkt: Auto-Save-Task alle 5 Minuten (für Online-Spieler). + * Schützt vor Datenverlust bei Server-Crash. + */ + private fun saveAllDirty() { + val dirtyUUIDs = dirtyFlags.keys.toList() + val toSave = dirtyUUIDs.mapNotNull { cache[it] } + if (toSave.isEmpty()) return + + try { + repository.upsertBatch(toSave) + dirtyUUIDs.forEach { dirtyFlags.remove(it) } + plugin.logger.info("[StatsManager] Auto-Save: ${toSave.size} Spieler gespeichert.") + } catch (e: Exception) { + plugin.logger.severe("[StatsManager] Batch-Save fehlgeschlagen: ${e.message}") + } + } + + /** + * Speichert **alle** gecachten Spieler synchron (blockierend). + * + * **Nur in `onDisable()` verwenden!** Im laufenden Spiel immer + * [saveAndEvict] oder [saveAllDirty] nutzen. + * + * `onDisable()` läuft auf dem Main-Thread und darf nicht lange blockieren, + * aber da der Server ohnehin heruntergefahren wird, ist ein kurzes + * Blockieren hier akzeptabel und der sicherste Ansatz. + */ + fun saveAllBlocking() { + val toSave = cache.values.toList() + if (toSave.isEmpty()) return + + try { + repository.upsertBatch(toSave) + plugin.logger.info("[StatsManager] Shutdown-Save: ${toSave.size} Spieler gespeichert.") + } catch (e: Exception) { + plugin.logger.severe("[StatsManager] Shutdown-Save fehlgeschlagen: ${e.message}") + } + } + + /** + * Gibt die Top-Spieler nach ScrimScore zurück (async). + * + * Callback wird auf dem **Main-Thread** ausgeführt via Bukkit-Scheduler, + * damit Bukkit-API-Aufrufe im Callback sicher sind. + */ + fun getLeaderboard(limit: Int = 10, callback: (List) -> Unit) { + scope.launch { + val results = try { + repository.findTopByScrimScore(limit) + } catch (e: Exception) { + plugin.logger.severe("[StatsManager] Leaderboard-Abfrage fehlgeschlagen: ${e.message}") + emptyList() + } + // Zurück auf Main-Thread für Bukkit-API-Aufrufe im Callback + plugin.server.scheduler.runTask(plugin) { -> + callback(results) + } + } + } + + // ------------------------------------------------------------------------- + // Lifecycle + // ------------------------------------------------------------------------- + + /** + * Beendet den Coroutine-Scope und speichert alle verbleibenden Daten. + * Aufrufreihenfolge in `onDisable()`: + * 1. [saveAllBlocking] (Daten sichern) + * 2. `scope.cancel()` (Coroutines stoppen) + */ + fun shutdown() { + saveAllBlocking() + scope.cancel() + cache.clear() + dirtyFlags.clear() + plugin.logger.info("[StatsManager] Heruntergefahren.") + } + + // ------------------------------------------------------------------------- + // Hilfsmittel + // ------------------------------------------------------------------------- + + private fun markDirty(uuid: UUID) { + dirtyFlags[uuid] = true + } + + /** + * Coroutine-basierter Auto-Save alle 5 Minuten. + * Läuft in einer Endlosschleife innerhalb des Scopes — wird automatisch + * beendet wenn [scope] via `cancel()` gestoppt wird. + */ + private fun startAutoSave() { + scope.launch { + while (isActive) { + delay((5 * 60 * 1_000L).milliseconds) // 5 Minuten + saveAllDirty() + } + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt index 9fea66d..dd3aed5 100644 --- a/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt +++ b/src/main/kotlin/club/mcscrims/speedhg/game/GameManager.kt @@ -226,6 +226,15 @@ class GameManager( alivePlayers.remove( player.uniqueId ) player.gameMode = GameMode.SPECTATOR + plugin.statsManager.addDeath( player.uniqueId ) + plugin.statsManager.adjustScrimScore( player.uniqueId, -25 ) // Elo-Verlust + + if ( killer != null ) + { + plugin.statsManager.addKill( killer.uniqueId ) + plugin.statsManager.adjustScrimScore( killer.uniqueId, +15 ) // Elo-Gewinn + } + player.inventory.contents.filterNotNull().forEach { player.world.dropItemNaturally( player.location, it ) } @@ -257,6 +266,17 @@ class GameManager( ) { setGameState( GameState.ENDING ) timer = 15 + + val winnerUUID = alivePlayers.firstOrNull() + + Bukkit.getOnlinePlayers().forEach { p -> + if ( p.uniqueId == winnerUUID ) + { + plugin.statsManager.addWin( p.uniqueId ) + plugin.statsManager.adjustScrimScore( p.uniqueId, +50 ) // Elo-Bonus für Win + } + } + plugin.kitManager.clearAll() Bukkit.getOnlinePlayers().forEach { p -> diff --git a/src/main/kotlin/club/mcscrims/speedhg/listener/StatsListener.kt b/src/main/kotlin/club/mcscrims/speedhg/listener/StatsListener.kt new file mode 100644 index 0000000..09affbc --- /dev/null +++ b/src/main/kotlin/club/mcscrims/speedhg/listener/StatsListener.kt @@ -0,0 +1,51 @@ +package club.mcscrims.speedhg.listener + +import club.mcscrims.speedhg.SpeedHG +import kotlinx.coroutines.runBlocking +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.player.AsyncPlayerPreLoginEvent +import org.bukkit.event.player.PlayerQuitEvent + +/** + * Verwaltet den Stats-Lebenszyklus für einzelne Spieler. + * + * ## Design-Entscheidungen: Warum `AsyncPlayerPreLoginEvent`? + * + * Es gibt zwei Möglichkeiten, Stats beim Login zu laden: + * + * | Event | Thread | Vorteil | Nachteil | + * |----------------------------|---------|---------|----------| + * | `PlayerLoginEvent` | Main | Einfach | Blockiert Main-Thread | + * | `AsyncPlayerPreLoginEvent` | Async | Kein Blockieren | Erfordert `runBlocking` | + * + * `AsyncPlayerPreLoginEvent` wird von Paper **immer** auf einem Worker-Thread + * gefeuert. Wir können hier mit `runBlocking` auf die DB warten — der + * Main-Thread läuft währenddessen normal weiter. Der Spieler sieht beim + * Login eine kurze Verzögerung (~20–50ms), dafür sind seine Daten beim + * ersten Tick garantiert im Cache verfügbar. + * + * **Warum `runBlocking` hier in Ordnung ist**: Wir sind bereits auf einem + * Async-Thread. `runBlocking` blockiert nur diesen Worker-Thread, nicht + * den Main-Thread. Das ist genau der Zweck dieses Events. + */ +class StatsListener : Listener { + + private val plugin = SpeedHG.instance + + @EventHandler(priority = EventPriority.NORMAL) + fun onPreLogin(event: AsyncPlayerPreLoginEvent) { + // Wir sind auf einem Async-Thread — runBlocking ist hier korrekt. + runBlocking { + plugin.statsManager.loadStats(event.uniqueId) + } + } + + @EventHandler(priority = EventPriority.MONITOR) + fun onQuit(event: PlayerQuitEvent) { + // MONITOR: läuft nach allen anderen Listenern — sicherstellen, + // dass alle anderen Plugins ihre finalen Änderungen gemacht haben. + plugin.statsManager.saveAndEvict(event.player.uniqueId) + } +} \ No newline at end of file diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 38722db..0f92005 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -18,4 +18,16 @@ anti-runner: punish-time: 25 # Einstellungen für die Cave-Erkennung ignore-vertical-distance: 15.0 # Wenn Höhenunterschied > 15, Timer ignorieren - ignore-cave-surface-mix: true # Ignorieren, wenn einer Sonne hat und der andere nicht \ No newline at end of file + ignore-cave-surface-mix: true # Ignorieren, wenn einer Sonne hat und der andere nicht + +database: + host: "localhost" + port: 3306 + name: "speedhg" + username: "speedhg_user" + password: "dein_sicheres_passwort" + use-ssl: false # auf true setzen wenn der DB-Server ein SSL-Zertifikat hat + + pool: + max-size: 10 # maximale gleichzeitige Connections (für 1 Server: 5-10 reichen) + min-idle: 2 # Mindestanzahl idle Connections im Pool \ 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 61f7cb3..a929184 100644 --- a/src/main/resources/languages/en_US.yml +++ b/src/main/resources/languages/en_US.yml @@ -53,6 +53,11 @@ commands: gameHasStarted: 'The game has already started. You cannot select a kit right now!' cannotPickSameKit: 'You cannot pick the same kit!' selected: 'You have selected as your Kit with playstyle !' + leaderboard: + header: '====== Leaderboard ======' + empty: 'There are currently no stats' + line: '# - - ' + footer: '====== Leaderboard ======' scoreboard: title: 'SpeedHG' diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 2a39f79..b10baaf 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -9,4 +9,7 @@ depend: commands: kit: description: 'Select kits via command' - usage: '/kit ' \ No newline at end of file + usage: '/kit ' + leaderboard: + description: 'View the top 10 players' + usage: '/leaderboard' \ No newline at end of file