Skip to content

Repository files navigation

Surf Stats

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.

Modules

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

Requirements

Paper plugin

  • Paper 1.21+ (Folia is supported)
  • Java 21+
  • surf-rabbitmq-paper — required
  • surf-clan-paper — soft dependency (used to attribute diff entries to clans)
  • surf-api-paper and surf-core-paper (transitive)

Microservice

  • Java 21+
  • A RabbitMQ broker reachable by both the plugin and the microservice
  • An R2DBC-compatible database (configured via the bundled surf-database integration)

Building

./gradlew build

Output artifacts:

  • Plugin jar: surf-stats-paper/build/libs/
  • Microservice jar: surf-stats-microservice/build/libs/

Configuration

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 the servers table on startup.
  • RabbitMQ connection is configured by surf-rabbitmq-paper on the plugin side and by the bundled ServerRabbitMQApi on the microservice side.
  • Database connection is owned by the microservice via surf-database (R2DBC).

How It Works

┌────────────────┐   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:

  1. Player join — loads the player's initial snapshot into memory.
  2. Player quit — 1 second after disconnect (gives Minecraft time to flush the stats file), computes final diffs and saves.
  3. Periodic — every 5 minutes, all tracked players are flushed to disk and saved.
  4. 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.

Current vs. diff stats

There are two parallel persistence paths:

  • PlayerStatsTable (player_stats) — UPSERTed on (player_uuid, category, key, server_name). The value column always reflects the current absolute value (written by saveStats); the last_diff_value column tracks the per-tuple baseline used for diff computation (written by saveDiffStats).
  • PlayerStatsHistoryTable (player_stats_history) — append-only INSERTs of deltas with created_at timestamp and optional clan_uuid. Suitable for time-series analysis. Deltas are computed server-side: delta = entry.value - last_diff_value. Only rows with delta > 0 are inserted; the baseline is always advanced to the new value.

API Usage

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

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: saveDiffStats now 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) writes entry.value as 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 { … }).

Custom Stats

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 Long only.
  • 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 saveDiffStats is no longer supported and will corrupt the per-tuple baseline.
  • The serverName you pass must reference a registered server in the servers table. Use SurfCoreApi.getCurrentServerName() to stay consistent.

Key API Types

  • SurfStatsApi — service interface; access via the companion object or requiredService<SurfStatsApi>().
  • PlayerStats — a player's stats for one server: playerUuid, serverName, List<StatEntry>. Implements Collection<StatEntry>.
  • StatEntrycategory: SerializableKey, key: SerializableKey, value: Long.
  • PlayerStatsBatch — internal RabbitMQ payload (player + stats + server + optional clan).
  • OptOutInfo / OptOutType — opt-out records and IN/OUT toggles.

Dependency (Gradle)

dependencies {
    compileOnly(project(":surf-stats-api"))
}

Commands & Permissions

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

Database Schema

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

License

SLNE Dev Team

About

Lightweight stats tracker and analytics dashboard for sessions, trends, and performance insights.

Topics

Resources

Stars

Watchers

Forks

Releases

Contributors

Languages