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.
This commit is contained in:
TDSTOS
2026-03-26 00:10:26 +01:00
parent ee79dd4bf4
commit 2d720d962c
13 changed files with 818 additions and 11 deletions

View File

@@ -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 {

View File

@@ -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 )
}
}

View File

@@ -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,

View File

@@ -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<out String>
): 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
}
}

View File

@@ -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 (~50200ms), 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
}

View File

@@ -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)
}

View File

@@ -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<PlayerStats> {
val results = mutableListOf<PlayerStats>()
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<PlayerStats>) {
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)
}
}

View File

@@ -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<UUID, PlayerStats>()
/**
* Dirty-Flag: true = Stats wurden verändert und müssen noch gespeichert werden.
* Wird nach erfolgreichem DB-Write entfernt.
*/
private val dirtyFlags = ConcurrentHashMap<UUID, Boolean>()
/**
* 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<PlayerStats>) -> 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()
}
}
}
}

View File

@@ -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 ->

View File

@@ -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 (~2050ms), 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)
}
}

View File

@@ -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
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

View File

@@ -53,6 +53,11 @@ commands:
gameHasStarted: '<red>The game has already started. You cannot select a kit right now!</red>'
cannotPickSameKit: '<red>You cannot pick the same kit!</red>'
selected: '<green>You have selected <kit> as your Kit with playstyle <playstyle>!</green>'
leaderboard:
header: '<gray>====== <gold>Leaderboard</gold> ======</gray>'
empty: '<red>There are currently no stats</red>'
line: '#<rank> - <green><name></green> - <aqua><score></aqua>'
footer: '<gray>====== <gold>Leaderboard</gold> ======</gray>'
scoreboard:
title: '<gradient:red:gold><bold>SpeedHG</bold></gradient>'

View File

@@ -9,4 +9,7 @@ depend:
commands:
kit:
description: 'Select kits via command'
usage: '/kit <kitName> <playstyle>'
usage: '/kit <kitName> <playstyle>'
leaderboard:
description: 'View the top 10 players'
usage: '/leaderboard'