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:
@@ -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 {
|
||||
|
||||
@@ -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 )
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
262
src/main/kotlin/club/mcscrims/speedhg/database/StatsManager.kt
Normal file
262
src/main/kotlin/club/mcscrims/speedhg/database/StatsManager.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>'
|
||||
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user