A Paper plugin + standalone microservice that captures Minecraft per-player statistics and persists them to a relational database. The Paper plugin reads Minecraft's native stats JSON files and ships absolute current values; the microservice owns all database access (including server-side delta computation) and the two communicate over RabbitMQ.
| Module | Description |
|---|---|
surf-stats-api |
Public API interfaces and serializable data models |
surf-stats-core/surf-stats-core-common |
Shared RabbitMQ packets and opt-out mappings |
surf-stats-core/surf-stats-core-client |
Client-side implementation (file parsing, packet sending) |
surf-stats-paper |
Paper plugin (event listeners, periodic save, commands, opt-out menu) |
surf-stats-microservice |
Standalone microservice that handles RabbitMQ packets and writes to the database |
- Paper 1.21+ (Folia is supported)
- Java 21+
surf-rabbitmq-paper— requiredsurf-clan-paper— soft dependency (used to attribute diff entries to clans)surf-api-paperandsurf-core-paper(transitive)
- Java 21+
- A RabbitMQ broker reachable by both the plugin and the microservice
- An R2DBC-compatible database (configured via the bundled
surf-databaseintegration)
./gradlew buildOutput artifacts:
- Plugin jar:
surf-stats-paper/build/libs/ - Microservice jar:
surf-stats-microservice/build/libs/
Surf Stats does not ship its own config.yml or database.yml. Configuration is delegated to the underlying infrastructure plugins:
- Server identity is provided by
surf-core-paper(SurfCoreApi.getCurrentServerName()/getCurrentServerDisplayName()). The plugin will register itself in theserverstable on startup. - RabbitMQ connection is configured by
surf-rabbitmq-paperon the plugin side and by the bundledServerRabbitMQApion the microservice side. - Database connection is owned by the microservice via
surf-database(R2DBC).
┌────────────────┐ stats JSON ┌───────────────────┐ RabbitMQ ┌──────────────────┐
│ Minecraft │ ──────────────► │ surf-stats-paper │ ──────────────► │ surf-stats- │
│ <world>/stats/ │ │ (read & ship) │ │ microservice │
└────────────────┘ └───────────────────┘ │ (delta + persist)│
└──────────────────┘
The Paper plugin reads Minecraft's native <world>/stats/<uuid>.json files and ships absolute current values to the microservice over RabbitMQ. The microservice owns the database, computes per-tuple deltas against the player_stats.last_diff_value baseline, and writes both the current row and the history row in a single transaction.
Statistics are processed at four points:
- Player join — loads the player's initial snapshot into memory.
- Player quit — 1 second after disconnect (gives Minecraft time to flush the stats file), computes final diffs and saves.
- Periodic — every 5 minutes, all tracked players are flushed to disk and saved.
- Server shutdown — final flush + save for all tracked players before disconnect.
All async work uses Folia-compatible coroutines (mccoroutine-folia) and a plugin-scoped CoroutineScope that is cancelled on shutdown.
There are two parallel persistence paths:
PlayerStatsTable(player_stats) — UPSERTed on(player_uuid, category, key, server_name). Thevaluecolumn always reflects the current absolute value (written bysaveStats); thelast_diff_valuecolumn tracks the per-tuple baseline used for diff computation (written bysaveDiffStats).PlayerStatsHistoryTable(player_stats_history) — append-only INSERTs of deltas withcreated_attimestamp and optionalclan_uuid. Suitable for time-series analysis. Deltas are computed server-side:delta = entry.value - last_diff_value. Only rows withdelta > 0are inserted; the baseline is always advanced to the new value.
The API is exposed as a Bukkit service. Use the helpers from surf-api-core:
import dev.slne.surf.stats.api.SurfStatsApi
// Companion object delegates to the registered service
SurfStatsApi.processPlayerStats(playerUuid)
val stats = SurfStatsApi.getPlayerStats(playerUuid)interface SurfStatsApi {
suspend fun processPlayerStats(playerUuid: UUID)
suspend fun processAllPlayerStats(uuids: Set<UUID>)
suspend fun getPlayerStats(playerUuid: UUID): PlayerStats
suspend fun saveStats(playerUuid: UUID, stats: PlayerStats)
suspend fun saveDiffStats(playerUuid: UUID, stats: PlayerStats)
}| Method | Purpose |
|---|---|
processPlayerStats |
Load current stats for one player and ship them on both the current and diff paths |
processAllPlayerStats |
Same as above, batched in parallel for many players |
getPlayerStats |
Load the player's current absolute stats from disk |
saveStats |
Send a CURRENT-type batch (UPSERT player_stats.value) |
saveDiffStats |
Send a DIFFERENCE-type batch — pass absolute current values; the microservice computes the delta against last_diff_value and appends to player_stats_history |
Breaking change:
saveDiffStatsnow expects absolute current values, not pre-computed deltas. Sending deltas under the new contract will make the per-tuple baseline drift and corrupt history. The first call per(player, category, key, server)writesentry.valueas the first delta (baseline starts at 0).
All methods are suspend. Callers must run them inside a coroutine scope (e.g. mccoroutine's plugin.launch { … }).
Other plugins can persist their own statistics through saveStats and saveDiffStats. The microservice does not whitelist categories or keys — unknown ones are auto-registered into stat_categories / stat_keys.
import dev.slne.surf.api.core.messages.adventure.key
import dev.slne.surf.core.api.common.SurfCoreApi
import dev.slne.surf.stats.api.SurfStatsApi
import dev.slne.surf.stats.api.model.PlayerStats
import dev.slne.surf.stats.api.model.StatEntry
val customStats = PlayerStats(
playerUuid = playerUuid,
serverName = SurfCoreApi.getCurrentServerName(),
stats = listOf(
StatEntry(key("myplugin:economy"), key("coins_earned"), 150L),
StatEntry(key("myplugin:economy"), key("coins_spent"), 80L),
)
)
// "Current value" semantics — overwrites previous row for the same (player, category, key, server)
SurfStatsApi.saveStats(playerUuid, customStats)
// "Event" semantics — pass absolute values; the microservice computes the delta
// against last_diff_value and appends a history row when delta > 0.
SurfStatsApi.saveDiffStats(playerUuid, customStats)Things to know:
- Categories and keys are
net.kyori.adventure.key.Key(namespaced). Use any namespace you own (e.g.myplugin:). - Values are
Longonly. - Player opt-outs (
player_stat_optouts) apply to custom stats too — opted-out(category, key)pairs are filtered out silently before insertion. - For custom stats, pass the absolute current value to either method. Pre-computing deltas client-side and shipping them via
saveDiffStatsis no longer supported and will corrupt the per-tuple baseline. - The
serverNameyou pass must reference a registered server in theserverstable. UseSurfCoreApi.getCurrentServerName()to stay consistent.
SurfStatsApi— service interface; access via the companion object orrequiredService<SurfStatsApi>().PlayerStats— a player's stats for one server:playerUuid,serverName,List<StatEntry>. ImplementsCollection<StatEntry>.StatEntry—category: SerializableKey,key: SerializableKey,value: Long.PlayerStatsBatch— internal RabbitMQ payload (player + stats + server + optional clan).OptOutInfo/OptOutType— opt-out records and IN/OUT toggles.
dependencies {
compileOnly(project(":surf-stats-api"))
}| Command | Permission | Description |
|---|---|---|
/stats |
surf.stats.command.generic |
Root command |
/stats optout |
surf.stats.command.opt-out |
Opens the opt-out menu where players can disable specific statistics |
The microservice creates and manages these tables:
| Table | Purpose |
|---|---|
servers |
Registered server names, labels, active flag |
stat_categories |
Distinct stat category keys (e.g. minecraft:mined, myplugin:economy) |
stat_keys |
Distinct stat keys (e.g. minecraft:stone, coins_earned) |
player_stats |
Current absolute value per (player, category, key, server) plus last_diff_value baseline used for server-side delta computation — UPSERT |
player_stats_history |
Append-only delta entries with created_at + optional clan_uuid |
player_stat_optouts |
Per-player opt-out list of (category, key) pairs to exclude from persistence |
SLNE Dev Team