A Paper plugin that reads Minecraft's per-player statistics JSON files and persists them to a MySQL/MariaDB database. Statistics are captured on player logout, periodic world saves, and server shutdown.
| Module | Description |
|---|---|
surf-stats-api |
Public API interfaces and data models |
surf-stats-core |
Core implementation (file parsing, database persistence) |
surf-stats-paper |
Paper plugin (event listeners, lifecycle management) |
- Paper 1.21.1+
- Java 21+
- MySQL or MariaDB
- surf-bukkit-api and surf-core-paper plugins
./gradlew :surf-stats-paper:buildThe plugin jar is produced at surf-stats-paper/build/libs/.
server:
name: "my-server" # Unique identifier for this server (must be changed)
label: "My Server" # Human-readable display nameThe plugin will refuse to start if server.name is left as my-server.
logLevel: DEBUG
credentials:
host: localhost
port: 3306
database: my_database
username: my_user
password: my_password
pool:
sizing:
initialSize: 10
minIdle: 0
maxSize: 10
timeouts:
maxAcquireTimeMillis: 10000
maxCreateConnectionTimeMillis: 30000
maxValidationTimeMillis: -1
maxIdleTimeMillis: 60000
maxLifeTimeMillis: 1800000The plugin reads Minecraft's native <world>/stats/<uuid>.json files and writes the data to a relational database, attributing each entry to the configured server name.
Statistics are processed at three points:
- Player quit — 1 second after disconnect (gives Minecraft time to flush the stats file)
- World save — 5 seconds after a world save cycle completes (debounced across overworld/nether/end)
- Server shutdown — synchronously during plugin disable, ensuring all online players' stats are saved before the database connection closes
All async processing uses a shared plugin-scoped CoroutineScope that is cancelled on shutdown to prevent post-disable coroutine leaks.
Other plugins can access the API through Bukkit's ServicesManager:
val api = server.servicesManager.getRegistration(SurfStatsApi::class.java)?.provider
?: error("SurfStats not available")
// Load a player's stats
val stats: PlayerStats? = api.getPlayerStats(playerUuid, playerName)
// Access individual stat values
val blocksMined = stats?.getStat("minecraft:mined", "minecraft:stone")
// Get all categories
val categories: Set<String> = stats?.categories() ?: emptySet()Other plugins can save custom statistics into the minecraft:custom category:
val api = server.servicesManager.getRegistration(SurfStatsApi::class.java)?.provider
?: error("SurfStats not available")
// Save a single custom stat
api.saveCustomStat(playerUuid, playerName, "my_plugin:kills", 42L)
// Save multiple custom stats at once (single transaction)
api.saveCustomStats(playerUuid, playerName, mapOf(
"my_plugin:kills" to 42L,
"my_plugin:deaths" to 7L
))Both methods are suspend functions and must be called from a coroutine context. The database service must be available or an IllegalStateException is thrown.
SurfStatsApi— main entry point for reading and processing statsPlayerStats— a player's full statistics (uuid, name, dataVersion, stat entries)StatEntry— a single statistic: category (e.g.minecraft:mined), key (e.g.minecraft:stone), valuePlayerStatsBatch— aPlayerStatspaired with a server name, ready for database insertion
dependencies {
compileOnly(project(":surf-stats-api"))
}The plugin creates and manages these tables:
| Table | Purpose |
|---|---|
servers |
Registered server names and labels |
players |
Player UUIDs, names, data versions, timestamps |
stat_categories |
Unique stat category names (e.g. minecraft:mined) |
stat_keys |
Unique stat key names (e.g. minecraft:stone) |
player_stats |
Stat values per player, per key, per server |
SLNE Dev Team