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 {
|
dependencies {
|
||||||
implementation("fr.mrmicky:fastboard:2.1.3")
|
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-core:7.2.17-SNAPSHOT")
|
||||||
compileOnly("com.sk89q.worldedit:worldedit-bukkit:7.2.17-SNAPSHOT")
|
compileOnly("com.sk89q.worldedit:worldedit-bukkit:7.2.17-SNAPSHOT")
|
||||||
}
|
}
|
||||||
@@ -42,6 +45,8 @@ tasks {
|
|||||||
archiveBaseName.set("GameModes-SpeedHG")
|
archiveBaseName.set("GameModes-SpeedHG")
|
||||||
archiveClassifier.set("")
|
archiveClassifier.set("")
|
||||||
archiveVersion.set(project.version.toString())
|
archiveVersion.set(project.version.toString())
|
||||||
|
relocate("com.zaxxer.hikari", "club.mcscrims.speedhg.libs.hikari")
|
||||||
|
relocate("com.mysql", "club.mcscrims.speedhg.libs.mysql")
|
||||||
}
|
}
|
||||||
|
|
||||||
build {
|
build {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package club.mcscrims.speedhg
|
package club.mcscrims.speedhg
|
||||||
|
|
||||||
import club.mcscrims.speedhg.command.KitCommand
|
import club.mcscrims.speedhg.command.KitCommand
|
||||||
|
import club.mcscrims.speedhg.command.LeaderboardCommand
|
||||||
import club.mcscrims.speedhg.config.LanguageManager
|
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.GameManager
|
||||||
import club.mcscrims.speedhg.game.modules.AntiRunningManager
|
import club.mcscrims.speedhg.game.modules.AntiRunningManager
|
||||||
import club.mcscrims.speedhg.kit.KitManager
|
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.ConnectListener
|
||||||
import club.mcscrims.speedhg.listener.GameStateListener
|
import club.mcscrims.speedhg.listener.GameStateListener
|
||||||
import club.mcscrims.speedhg.listener.SoupListener
|
import club.mcscrims.speedhg.listener.SoupListener
|
||||||
|
import club.mcscrims.speedhg.listener.StatsListener
|
||||||
import club.mcscrims.speedhg.scoreboard.ScoreboardManager
|
import club.mcscrims.speedhg.scoreboard.ScoreboardManager
|
||||||
import org.bukkit.Bukkit
|
import org.bukkit.Bukkit
|
||||||
import org.bukkit.plugin.java.JavaPlugin
|
import org.bukkit.plugin.java.JavaPlugin
|
||||||
@@ -41,21 +45,37 @@ class SpeedHG : JavaPlugin() {
|
|||||||
lateinit var kitManager: KitManager
|
lateinit var kitManager: KitManager
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
lateinit var databaseManager: DatabaseManager
|
||||||
|
private set
|
||||||
|
|
||||||
|
lateinit var statsManager: StatsManager
|
||||||
|
private set
|
||||||
|
|
||||||
override fun onEnable()
|
override fun onEnable()
|
||||||
{
|
{
|
||||||
instance = this
|
instance = this
|
||||||
|
|
||||||
saveDefaultConfig()
|
saveDefaultConfig()
|
||||||
languageManager = LanguageManager( this )
|
|
||||||
|
|
||||||
gameManager = GameManager( this )
|
databaseManager = DatabaseManager( this )
|
||||||
antiRunningManager = AntiRunningManager( 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()
|
registerKits()
|
||||||
|
|
||||||
registerCommands()
|
registerCommands()
|
||||||
registerListener()
|
registerListener()
|
||||||
|
|
||||||
@@ -64,6 +84,8 @@ class SpeedHG : JavaPlugin() {
|
|||||||
|
|
||||||
override fun onDisable()
|
override fun onDisable()
|
||||||
{
|
{
|
||||||
|
if ( ::statsManager.isInitialized ) statsManager.shutdown()
|
||||||
|
if ( ::databaseManager.isInitialized ) databaseManager.disconnect()
|
||||||
kitManager.clearAll()
|
kitManager.clearAll()
|
||||||
super.onDisable()
|
super.onDisable()
|
||||||
}
|
}
|
||||||
@@ -81,6 +103,8 @@ class SpeedHG : JavaPlugin() {
|
|||||||
val kitCommand = KitCommand()
|
val kitCommand = KitCommand()
|
||||||
getCommand( "kit" )?.setExecutor( kitCommand )
|
getCommand( "kit" )?.setExecutor( kitCommand )
|
||||||
getCommand( "kit" )?.tabCompleter = kitCommand
|
getCommand( "kit" )?.tabCompleter = kitCommand
|
||||||
|
|
||||||
|
getCommand( "leaderboard" )?.setExecutor( LeaderboardCommand() )
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun registerListener()
|
private fun registerListener()
|
||||||
@@ -91,6 +115,7 @@ class SpeedHG : JavaPlugin() {
|
|||||||
pm.registerEvents( GameStateListener(), this )
|
pm.registerEvents( GameStateListener(), this )
|
||||||
pm.registerEvents( SoupListener(), this )
|
pm.registerEvents( SoupListener(), this )
|
||||||
pm.registerEvents(KitEventDispatcher( this, kitManager ), this )
|
pm.registerEvents(KitEventDispatcher( this, kitManager ), this )
|
||||||
|
pm.registerEvents( StatsListener(), this )
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -13,7 +13,7 @@ import org.bukkit.entity.Player
|
|||||||
|
|
||||||
class KitCommand : CommandExecutor, TabCompleter {
|
class KitCommand : CommandExecutor, TabCompleter {
|
||||||
|
|
||||||
private val plugin = SpeedHG.instance
|
private val plugin get() = SpeedHG.instance
|
||||||
|
|
||||||
override fun onCommand(
|
override fun onCommand(
|
||||||
sender: CommandSender,
|
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 )
|
alivePlayers.remove( player.uniqueId )
|
||||||
player.gameMode = GameMode.SPECTATOR
|
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.inventory.contents.filterNotNull().forEach {
|
||||||
player.world.dropItemNaturally( player.location, it )
|
player.world.dropItemNaturally( player.location, it )
|
||||||
}
|
}
|
||||||
@@ -257,6 +266,17 @@ class GameManager(
|
|||||||
) {
|
) {
|
||||||
setGameState( GameState.ENDING )
|
setGameState( GameState.ENDING )
|
||||||
timer = 15
|
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()
|
plugin.kitManager.clearAll()
|
||||||
|
|
||||||
Bukkit.getOnlinePlayers().forEach { p ->
|
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
|
punish-time: 25
|
||||||
# Einstellungen für die Cave-Erkennung
|
# Einstellungen für die Cave-Erkennung
|
||||||
ignore-vertical-distance: 15.0 # Wenn Höhenunterschied > 15, Timer ignorieren
|
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>'
|
gameHasStarted: '<red>The game has already started. You cannot select a kit right now!</red>'
|
||||||
cannotPickSameKit: '<red>You cannot pick the same kit!</red>'
|
cannotPickSameKit: '<red>You cannot pick the same kit!</red>'
|
||||||
selected: '<green>You have selected <kit> as your Kit with playstyle <playstyle>!</green>'
|
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:
|
scoreboard:
|
||||||
title: '<gradient:red:gold><bold>SpeedHG</bold></gradient>'
|
title: '<gradient:red:gold><bold>SpeedHG</bold></gradient>'
|
||||||
|
|||||||
@@ -9,4 +9,7 @@ depend:
|
|||||||
commands:
|
commands:
|
||||||
kit:
|
kit:
|
||||||
description: 'Select kits via command'
|
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