feat: add comprehensive player statistics logging

This commit is contained in:
Michael Burgess
2026-08-17 12:13:40 -04:00
commit 180a55a72d
18 changed files with 1918 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
.gradle/
build/
.idea/
*.iml
+158
View File
@@ -0,0 +1,158 @@
# RosePlayerStats
RosePlayerStats is a Paper 26.2 player-statistics plugin for The Rose Garden. It records broad player activity using both Minecraft/Paper statistics and additional RosePlayerStats counters, and can optionally maintain a detailed timestamped event journal.
Every stored player record uses the player's **UUID as the permanent identity** and also stores the player's **current/last known player name**. If a player changes their Minecraft name, the UUID remains the same and the stored name is updated.
## Requirements
- Paper 26.2
- Java 25
- MySQL/MariaDB is optional; the MariaDB JDBC driver is bundled in the plugin JAR.
## What is tracked
RosePlayerStats captures Minecraft/Paper statistic increments and adds counters for activity not represented cleanly by vanilla statistics.
Examples include:
- Player name and UUID
- First seen / last seen
- Join count, kicks, session duration, tracked play time
- Total movement distance
- Walking, sprinting, sneaking, swimming, flying, gliding, and vehicle distance
- Estimated number of steps
- Blocks broken, total and by material
- Blocks placed, total and by material
- Bucket fills/empties
- Deaths and death causes
- Kills, total and by entity type
- Damage dealt/taken
- Items picked up, dropped, consumed, broken, damaged, and mended
- Crafting and enchanting
- Inventory opens/closes/clicks
- Block and entity interactions
- Commands, total and by command
- Chat message and character counts
- Advancements
- Teleports and teleport causes
- World changes
- Respawns
- Game-mode changes
- Experience and level changes
- Fishing
- Bed interactions
- Sneak, sprint, flight, and hand-swap controls
- Every additional statistic exposed through `PlayerStatisticIncrementEvent`
Movement statistics are accumulated in memory because Paper intentionally does not fire `PlayerStatisticIncrementEvent` for some high-frequency movement statistics. The plugin records movement distance itself and derives an estimated step count from configurable average step length.
## Detailed event journal
When `journal.enabled: true`, meaningful actions are also recorded as timestamped events.
Flat-file mode writes JSON Lines files to:
```text
plugins/RosePlayerStats/events/YYYY-MM-DD.jsonl
```
SQL mode writes rows to `rose_player_events`.
Commands can contain passwords or authentication tokens. RosePlayerStats therefore supports a configurable command-redaction list. Matching commands are counted normally, but their arguments are stored as `<redacted>` in the journal.
## Storage
### Flat file
Default configuration:
```yaml
storage:
type: flatfile
```
Player counters are stored under:
```text
plugins/RosePlayerStats/players/<uuid>.properties
```
Each file includes both:
```text
meta.uuid=<uuid>
meta.last_name=<player name>
```
### MySQL / MariaDB
Set:
```yaml
storage:
type: mariadb
sql:
host: 127.0.0.1
port: 3306
database: minecraft
username: playerstats
password: change-me
parameters: 'useUnicode=true&characterEncoding=utf8&useSSL=false'
```
`type: mysql` is also accepted. The bundled MariaDB JDBC driver supports both MariaDB and MySQL servers.
RosePlayerStats automatically creates:
- `rose_players` — UUID, current player name, first seen, last seen
- `rose_player_stats` — UUID, current player name, stat key, stat value
- `rose_player_events` — timestamp, UUID, player name, event type, event detail
The counter table is key/value based, so new statistics can be added without a database migration for each new counter.
## Commands
```text
/playerstats
/playerstats <player|uuid>
/playerstats save
/playerstats reload
```
Aliases:
```text
/pstats
/stats
```
The stats display always includes both the player's current/last known name and UUID.
## Permissions
```text
roseplayerstats.view
roseplayerstats.view.others
roseplayerstats.admin
```
`roseplayerstats.view` defaults to everyone. The others default to operators.
## Build
The Gradle wrapper is included.
Windows:
```text
gradlew.bat clean build
```
Linux/macOS:
```text
./gradlew clean build
```
The output JAR is written to `build/libs/`.
+40
View File
@@ -0,0 +1,40 @@
plugins {
java
}
group = "net.therosegarden"
version = "1.0.0"
val pluginVersion = version.toString()
repositories {
mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/")
}
dependencies {
compileOnly("io.papermc.paper:paper-api:26.2.build.+")
implementation("org.mariadb.jdbc:mariadb-java-client:3.5.3")
}
java {
toolchain.languageVersion.set(JavaLanguageVersion.of(25))
}
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
options.compilerArgs.addAll(listOf("-Xlint:deprecation", "-Xlint:unchecked"))
}
tasks.processResources {
inputs.property("pluginVersion", pluginVersion)
filesMatching("plugin.yml") {
expand("version" to pluginVersion)
}
}
tasks.jar {
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) })
exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
}
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
Vendored
+82
View File
@@ -0,0 +1,82 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+1
View File
@@ -0,0 +1 @@
rootProject.name = "RosePlayerStats"
@@ -0,0 +1,104 @@
package net.therosegarden.playerstats;
import java.io.BufferedWriter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.Properties;
import java.util.UUID;
final class FlatFileStatsStorage implements StatsStorage {
private final Path playersDirectory;
private final Path eventsDirectory;
private final boolean journalEnabled;
FlatFileStatsStorage(Path dataDirectory, boolean journalEnabled) throws IOException {
this.playersDirectory = dataDirectory.resolve("players");
this.eventsDirectory = dataDirectory.resolve("events");
this.journalEnabled = journalEnabled;
Files.createDirectories(playersDirectory);
if (journalEnabled) {
Files.createDirectories(eventsDirectory);
}
}
@Override
public PlayerRecord load(UUID uuid, String currentName) throws Exception {
Path file = playersDirectory.resolve(uuid + ".properties");
long now = System.currentTimeMillis();
if (!Files.exists(file)) {
return new PlayerRecord(uuid, currentName, now);
}
Properties properties = new Properties();
try (var reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) {
properties.load(reader);
}
PlayerRecord record = PlayerRecord.fromProperties(properties);
record.touch(currentName, now);
return record;
}
@Override
public void save(PlayerRecord record) throws Exception {
Path target = playersDirectory.resolve(record.uuid() + ".properties");
Path temporary = playersDirectory.resolve(record.uuid() + ".properties.tmp");
try (var writer = Files.newBufferedWriter(temporary, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
record.toProperties().store(writer, "RosePlayerStats");
}
try {
Files.move(temporary, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING,
java.nio.file.StandardCopyOption.ATOMIC_MOVE);
} catch (java.nio.file.AtomicMoveNotSupportedException ignored) {
Files.move(temporary, target, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
}
}
@Override
public synchronized void journal(UUID uuid, String playerName, String eventType, String detail) throws Exception {
if (!journalEnabled) {
return;
}
LocalDate date = Instant.now().atZone(ZoneOffset.UTC).toLocalDate();
Path file = eventsDirectory.resolve(date + ".jsonl");
String line = "{\"timestamp\":" + System.currentTimeMillis()
+ ",\"uuid\":\"" + escape(uuid.toString()) + "\""
+ ",\"player\":\"" + escape(playerName) + "\""
+ ",\"event\":\"" + escape(eventType) + "\""
+ ",\"detail\":\"" + escape(detail) + "\"}\n";
try (BufferedWriter writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8,
StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.APPEND)) {
writer.write(line);
}
}
private static String escape(String value) {
if (value == null) {
return "";
}
StringBuilder result = new StringBuilder(value.length() + 16);
for (char c : value.toCharArray()) {
switch (c) {
case '\\' -> result.append("\\\\");
case '"' -> result.append("\\\"");
case '\n' -> result.append("\\n");
case '\r' -> result.append("\\r");
case '\t' -> result.append("\\t");
default -> {
if (c < 0x20) {
result.append(String.format("\\u%04x", (int) c));
} else {
result.append(c);
}
}
}
}
return result.toString();
}
}
@@ -0,0 +1,106 @@
package net.therosegarden.playerstats;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
final class PlayerRecord {
private final UUID uuid;
private final ConcurrentHashMap<String, Long> counters = new ConcurrentHashMap<>();
private volatile String lastName;
private volatile long firstSeen;
private volatile long lastSeen;
PlayerRecord(UUID uuid, String lastName, long now) {
this.uuid = uuid;
this.lastName = lastName == null ? "unknown" : lastName;
this.firstSeen = now;
this.lastSeen = now;
}
UUID uuid() {
return uuid;
}
String lastName() {
return lastName;
}
long firstSeen() {
return firstSeen;
}
long lastSeen() {
return lastSeen;
}
void touch(String name, long now) {
if (name != null && !name.isBlank()) {
lastName = name;
}
if (firstSeen <= 0L) {
firstSeen = now;
}
lastSeen = now;
}
long increment(String key, long amount) {
if (amount == 0L) {
return get(key);
}
return counters.merge(key, amount, Long::sum);
}
void set(String key, long value) {
counters.put(key, value);
}
long get(String key) {
return counters.getOrDefault(key, 0L);
}
Map<String, Long> countersSnapshot() {
return new HashMap<>(counters);
}
Properties toProperties() {
Properties properties = new Properties();
properties.setProperty("meta.uuid", uuid.toString());
properties.setProperty("meta.last_name", lastName);
properties.setProperty("meta.first_seen", Long.toString(firstSeen));
properties.setProperty("meta.last_seen", Long.toString(lastSeen));
for (Map.Entry<String, Long> entry : counters.entrySet()) {
properties.setProperty("counter." + entry.getKey(), Long.toString(entry.getValue()));
}
return properties;
}
static PlayerRecord fromProperties(Properties properties) {
UUID uuid = UUID.fromString(properties.getProperty("meta.uuid"));
long now = System.currentTimeMillis();
PlayerRecord record = new PlayerRecord(uuid, properties.getProperty("meta.last_name", "unknown"), now);
record.firstSeen = parseLong(properties.getProperty("meta.first_seen"), now);
record.lastSeen = parseLong(properties.getProperty("meta.last_seen"), record.firstSeen);
for (String name : properties.stringPropertyNames()) {
if (!name.startsWith("counter.")) {
continue;
}
record.counters.put(name.substring("counter.".length()), parseLong(properties.getProperty(name), 0L));
}
return record;
}
private static long parseLong(String value, long fallback) {
if (value == null) {
return fallback;
}
try {
return Long.parseLong(value);
} catch (NumberFormatException ignored) {
return fallback;
}
}
}
@@ -0,0 +1,139 @@
package net.therosegarden.playerstats;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
final class PlayerStatsCommand implements CommandExecutor, TabCompleter {
private final RosePlayerStatsPlugin plugin;
private final StatsService stats;
PlayerStatsCommand(RosePlayerStatsPlugin plugin, StatsService stats) {
this.plugin = plugin;
this.stats = stats;
}
@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if (args.length == 0) {
if (!(sender instanceof Player player)) {
sender.sendMessage(plugin.prefix() + plugin.color("&eUsage: /playerstats <player|uuid>"));
return true;
}
if (!sender.hasPermission("roseplayerstats.view")) {
sender.sendMessage(plugin.prefix() + plugin.color("&cYou do not have permission to view player statistics."));
return true;
}
show(sender, stats.record(player));
return true;
}
if (args[0].equalsIgnoreCase("save")) {
if (!sender.hasPermission("roseplayerstats.admin")) {
sender.sendMessage(plugin.prefix() + plugin.color("&cYou do not have permission to save statistics."));
return true;
}
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
stats.saveAll();
stats.flushJournal();
sender.sendMessage(plugin.prefix() + plugin.color("&aPlayer statistics and event journal flushed to storage."));
});
return true;
}
if (args[0].equalsIgnoreCase("reload")) {
if (!sender.hasPermission("roseplayerstats.admin")) {
sender.sendMessage(plugin.prefix() + plugin.color("&cYou do not have permission to reload RosePlayerStats."));
return true;
}
plugin.reloadConfig();
sender.sendMessage(plugin.prefix() + plugin.color("&aConfiguration reloaded. &7Storage backend changes require a plugin/server restart."));
return true;
}
if (!sender.hasPermission("roseplayerstats.view.others")) {
sender.sendMessage(plugin.prefix() + plugin.color("&cYou do not have permission to view another player's statistics."));
return true;
}
String lookup = String.join(" ", args);
Player online = Bukkit.getPlayerExact(lookup);
PlayerRecord record = online != null ? stats.record(online) : stats.findLoaded(lookup);
if (record == null) {
sender.sendMessage(plugin.prefix() + plugin.color("&cNo loaded statistics found for &f" + lookup + "&c. Use the UUID while the player is online at least once after plugin startup."));
return true;
}
show(sender, record);
return true;
}
private void show(CommandSender sender, PlayerRecord record) {
long estimatedSteps = record.get("movement.estimated_steps");
long distanceCm = record.get("movement.total_cm");
long playTimeMs = record.get("sessions.play_time_ms");
sender.sendMessage(plugin.prefix() + plugin.color("&d&lPlayer Statistics"));
sender.sendMessage(plugin.color("&7Player: &f" + record.lastName()));
sender.sendMessage(plugin.color("&7UUID: &f" + record.uuid()));
sender.sendMessage(plugin.color("&7Estimated steps: &f" + format(estimatedSteps)));
sender.sendMessage(plugin.color("&7Distance tracked: &f" + formatDistance(distanceCm)));
sender.sendMessage(plugin.color("&7Blocks broken: &f" + format(record.get("blocks.broken.total"))));
sender.sendMessage(plugin.color("&7Blocks placed: &f" + format(record.get("blocks.placed.total"))));
sender.sendMessage(plugin.color("&7Deaths: &f" + format(record.get("combat.deaths"))));
sender.sendMessage(plugin.color("&7Joins: &f" + format(record.get("sessions.joins"))));
sender.sendMessage(plugin.color("&7Tracked play time: &f" + formatDuration(playTimeMs)));
sender.sendMessage(plugin.color("&8Counters stored: &7" + record.countersSnapshot().size()));
}
private static String format(long value) {
return String.format(Locale.US, "%,d", value);
}
private static String formatDistance(long centimeters) {
double meters = centimeters / 100.0;
if (meters >= 1000.0) {
return String.format(Locale.US, "%,.2f km", meters / 1000.0);
}
return String.format(Locale.US, "%,.1f m", meters);
}
private static String formatDuration(long milliseconds) {
Duration duration = Duration.ofMillis(Math.max(0L, milliseconds));
long hours = duration.toHours();
long minutes = duration.minusHours(hours).toMinutes();
return hours + "h " + minutes + "m";
}
@Override
public @Nullable List<String> onTabComplete(@NotNull CommandSender sender, @NotNull Command command,
@NotNull String alias, @NotNull String[] args) {
if (args.length != 1) {
return List.of();
}
String token = args[0].toLowerCase(Locale.ROOT);
List<String> options = new ArrayList<>();
if (sender.hasPermission("roseplayerstats.admin")) {
options.add("save");
options.add("reload");
}
if (sender.hasPermission("roseplayerstats.view.others")) {
for (Player player : Bukkit.getOnlinePlayers()) {
options.add(player.getName());
}
}
options.removeIf(value -> !value.toLowerCase(Locale.ROOT).startsWith(token));
options.sort(String.CASE_INSENSITIVE_ORDER);
return options;
}
}
@@ -0,0 +1,502 @@
package net.therosegarden.playerstats;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Statistic;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.enchantment.EnchantItemEvent;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityDeathEvent;
import org.bukkit.event.entity.EntityPickupItemEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.inventory.CraftItemEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import io.papermc.paper.event.player.AsyncChatEvent;
import org.bukkit.event.player.AsyncPlayerPreLoginEvent;
import org.bukkit.event.player.PlayerAdvancementDoneEvent;
import org.bukkit.event.player.PlayerBedEnterEvent;
import org.bukkit.event.player.PlayerBedLeaveEvent;
import org.bukkit.event.player.PlayerBucketEmptyEvent;
import org.bukkit.event.player.PlayerBucketFillEvent;
import org.bukkit.event.player.PlayerChangedWorldEvent;
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerExpChangeEvent;
import org.bukkit.event.player.PlayerFishEvent;
import org.bukkit.event.player.PlayerGameModeChangeEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerItemBreakEvent;
import org.bukkit.event.player.PlayerItemConsumeEvent;
import org.bukkit.event.player.PlayerItemDamageEvent;
import org.bukkit.event.player.PlayerItemMendEvent;
import org.bukkit.event.player.PlayerJoinEvent;
import org.bukkit.event.player.PlayerKickEvent;
import org.bukkit.event.player.PlayerLevelChangeEvent;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.event.player.PlayerRespawnEvent;
import org.bukkit.event.player.PlayerStatisticIncrementEvent;
import org.bukkit.event.player.PlayerSwapHandItemsEvent;
import org.bukkit.event.player.PlayerTeleportEvent;
import org.bukkit.event.player.PlayerToggleFlightEvent;
import org.bukkit.event.player.PlayerToggleSneakEvent;
import org.bukkit.event.player.PlayerToggleSprintEvent;
import org.bukkit.inventory.ItemStack;
final class PlayerStatsListener implements Listener {
private static final PlainTextComponentSerializer PLAIN = PlainTextComponentSerializer.plainText();
private final RosePlayerStatsPlugin plugin;
private final StatsService stats;
private final Map<UUID, Long> sessionStarts = new ConcurrentHashMap<>();
PlayerStatsListener(RosePlayerStatsPlugin plugin, StatsService stats) {
this.plugin = plugin;
this.stats = stats;
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPreLogin(AsyncPlayerPreLoginEvent event) {
try {
stats.preload(event.getUniqueId(), event.getName());
} catch (Exception ex) {
plugin.getLogger().warning("Could not preload statistics for " + event.getName() + " (" + event.getUniqueId() + "): " + ex.getMessage());
stats.record(event.getUniqueId(), event.getName());
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onJoin(PlayerJoinEvent event) {
Player player = event.getPlayer();
stats.record(player);
stats.increment(player, "sessions.joins");
sessionStarts.put(player.getUniqueId(), System.currentTimeMillis());
stats.journal(player, "join", "world=" + player.getWorld().getName() + ";location=" + location(player.getLocation()));
}
@EventHandler(priority = EventPriority.MONITOR)
public void onQuit(PlayerQuitEvent event) {
finishSession(event.getPlayer(), "quit");
}
@EventHandler(priority = EventPriority.MONITOR)
public void onKick(PlayerKickEvent event) {
Player player = event.getPlayer();
stats.increment(player, "sessions.kicks");
stats.journal(player, "kick", "reason=" + safe(PLAIN.serialize(event.reason())));
}
private void finishSession(Player player, String eventType) {
Long started = sessionStarts.remove(player.getUniqueId());
if (started != null) {
stats.increment(player, "sessions.play_time_ms", Math.max(0L, System.currentTimeMillis() - started));
}
stats.journal(player, eventType, "world=" + player.getWorld().getName() + ";location=" + location(player.getLocation()));
stats.savePlayerAsync(player);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onMove(PlayerMoveEvent event) {
if (!plugin.getConfig().getBoolean("movement.enabled", true) || event instanceof PlayerTeleportEvent) {
return;
}
Location from = event.getFrom();
Location to = event.getTo();
if (to == null || from.getWorld() != to.getWorld()) {
return;
}
double dx = to.getX() - from.getX();
double dy = to.getY() - from.getY();
double dz = to.getZ() - from.getZ();
double distance = Math.sqrt((dx * dx) + (dy * dy) + (dz * dz));
double max = Math.max(1.0, plugin.getConfig().getDouble("movement.max-single-move-blocks", 12.0));
if (distance <= 0.0 || distance > max) {
return;
}
Player player = event.getPlayer();
long centimeters = Math.max(1L, Math.round(distance * 100.0));
PlayerRecord record = stats.record(player);
record.increment("movement.total_cm", centimeters);
String mode;
boolean groundStep = false;
if (player.isInsideVehicle()) {
mode = "vehicle";
} else if (player.isGliding()) {
mode = "gliding";
} else if (player.isFlying()) {
mode = "flying";
} else if (player.isSwimming()) {
mode = "swimming";
} else if (player.isSprinting()) {
mode = "sprinting";
groundStep = true;
} else if (player.isSneaking()) {
mode = "sneaking";
groundStep = true;
} else {
mode = "walking";
groundStep = true;
}
record.increment("movement." + mode + "_cm", centimeters);
if (groundStep) {
long groundCm = record.increment("movement.ground_cm", centimeters);
double stepLength = Math.max(1.0, plugin.getConfig().getDouble("movement.estimated-step-length-centimeters", 76.2));
record.set("movement.estimated_steps", Math.round(groundCm / stepLength));
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onStatistic(PlayerStatisticIncrementEvent event) {
Player player = event.getPlayer();
int change = event.getNewValue() - event.getPreviousValue();
if (change <= 0) {
return;
}
StringBuilder key = new StringBuilder("minecraft.").append(event.getStatistic().name().toLowerCase(Locale.ROOT));
if (event.getMaterial() != null) {
key.append('.').append(event.getMaterial().name().toLowerCase(Locale.ROOT));
}
if (event.getEntityType() != null) {
key.append('.').append(event.getEntityType().name().toLowerCase(Locale.ROOT));
}
stats.increment(player, key.toString(), change);
if (plugin.getConfig().getBoolean("journal.log-statistic-increments", true)) {
stats.journal(player, "statistic", "stat=" + key + ";change=" + change + ";new_value=" + event.getNewValue());
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockBreak(BlockBreakEvent event) {
Player player = event.getPlayer();
Material material = event.getBlock().getType();
stats.increment(player, "blocks.broken.total");
stats.increment(player, "blocks.broken." + material.name());
stats.journal(player, "block_break", "material=" + material + ";location=" + location(event.getBlock().getLocation()));
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockPlace(BlockPlaceEvent event) {
Player player = event.getPlayer();
Material material = event.getBlockPlaced().getType();
stats.increment(player, "blocks.placed.total");
stats.increment(player, "blocks.placed." + material.name());
stats.journal(player, "block_place", "material=" + material + ";location=" + location(event.getBlockPlaced().getLocation()));
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBucketEmpty(PlayerBucketEmptyEvent event) {
stats.increment(event.getPlayer(), "buckets.emptied.total");
stats.increment(event.getPlayer(), "buckets.emptied." + event.getBucket().name());
stats.journal(event.getPlayer(), "bucket_empty", "bucket=" + event.getBucket() + ";location=" + location(event.getBlock().getLocation()));
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBucketFill(PlayerBucketFillEvent event) {
stats.increment(event.getPlayer(), "buckets.filled.total");
stats.increment(event.getPlayer(), "buckets.filled." + event.getBucket().name());
stats.journal(event.getPlayer(), "bucket_fill", "bucket=" + event.getBucket() + ";location=" + location(event.getBlock().getLocation()));
}
@EventHandler(priority = EventPriority.MONITOR)
public void onDeath(PlayerDeathEvent event) {
Player player = event.getEntity();
stats.increment(player, "combat.deaths");
String cause = player.getLastDamageCause() == null ? "unknown" : player.getLastDamageCause().getCause().name();
stats.increment(player, "combat.deaths_by." + cause);
stats.journal(player, "death", "cause=" + cause + ";location=" + location(player.getLocation()));
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDamage(EntityDamageEvent event) {
if (!(event.getEntity() instanceof Player player)) {
return;
}
long milliHearts = Math.max(0L, Math.round(event.getFinalDamage() * 500.0));
stats.increment(player, "combat.damage_taken_millihearts", milliHearts);
stats.increment(player, "combat.damage_taken_events");
stats.increment(player, "combat.damage_taken_by." + event.getCause().name(), milliHearts);
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDamageByEntity(EntityDamageByEntityEvent event) {
Player attacker = attackingPlayer(event.getDamager());
if (attacker == null || attacker.equals(event.getEntity())) {
return;
}
long milliHearts = Math.max(0L, Math.round(event.getFinalDamage() * 500.0));
stats.increment(attacker, "combat.damage_dealt_millihearts", milliHearts);
stats.increment(attacker, "combat.damage_dealt_events");
stats.increment(attacker, "combat.damage_dealt_to." + event.getEntityType().name(), milliHearts);
}
private Player attackingPlayer(Entity damager) {
if (damager instanceof Player player) {
return player;
}
if (damager instanceof org.bukkit.entity.Projectile projectile && projectile.getShooter() instanceof Player player) {
return player;
}
return null;
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onPickup(EntityPickupItemEvent event) {
if (!(event.getEntity() instanceof Player player)) {
return;
}
ItemStack item = event.getItem().getItemStack();
stats.increment(player, "items.picked_up.total", item.getAmount());
stats.increment(player, "items.picked_up." + item.getType().name(), item.getAmount());
stats.journal(player, "item_pickup", "material=" + item.getType() + ";amount=" + item.getAmount());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onDrop(PlayerDropItemEvent event) {
ItemStack item = event.getItemDrop().getItemStack();
stats.increment(event.getPlayer(), "items.dropped.total", item.getAmount());
stats.increment(event.getPlayer(), "items.dropped." + item.getType().name(), item.getAmount());
stats.journal(event.getPlayer(), "item_drop", "material=" + item.getType() + ";amount=" + item.getAmount());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onConsume(PlayerItemConsumeEvent event) {
stats.increment(event.getPlayer(), "items.consumed.total");
stats.increment(event.getPlayer(), "items.consumed." + event.getItem().getType().name());
stats.journal(event.getPlayer(), "item_consume", "material=" + event.getItem().getType());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onCraft(CraftItemEvent event) {
if (!(event.getWhoClicked() instanceof Player player) || event.getCurrentItem() == null) {
return;
}
Material type = event.getCurrentItem().getType();
stats.increment(player, "crafts.total");
stats.increment(player, "crafts." + type.name());
stats.journal(player, "craft", "material=" + type + ";amount=" + event.getCurrentItem().getAmount());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onEnchant(EnchantItemEvent event) {
Player player = event.getEnchanter();
stats.increment(player, "enchantments.total");
stats.increment(player, "enchantments.levels_spent", event.getExpLevelCost());
stats.journal(player, "enchant", "material=" + event.getItem().getType() + ";level_cost=" + event.getExpLevelCost());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onInventoryOpen(InventoryOpenEvent event) {
if (event.getPlayer() instanceof Player player) {
stats.increment(player, "inventory.opens.total");
stats.increment(player, "inventory.opens." + event.getInventory().getType().name());
stats.journal(player, "inventory_open", "type=" + event.getInventory().getType());
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onInventoryClose(InventoryCloseEvent event) {
if (event.getPlayer() instanceof Player player) {
stats.increment(player, "inventory.closes.total");
stats.journal(player, "inventory_close", "type=" + event.getInventory().getType());
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onInventoryClick(InventoryClickEvent event) {
if (event.getWhoClicked() instanceof Player player) {
stats.increment(player, "inventory.clicks.total");
stats.increment(player, "inventory.clicks." + event.getClick().name());
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onInteract(PlayerInteractEvent event) {
Player player = event.getPlayer();
stats.increment(player, "interactions.total");
stats.increment(player, "interactions." + event.getAction().name());
if (event.getClickedBlock() != null) {
stats.increment(player, "interactions.block." + event.getClickedBlock().getType().name());
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onInteractEntity(PlayerInteractEntityEvent event) {
stats.increment(event.getPlayer(), "interactions.entity.total");
stats.increment(event.getPlayer(), "interactions.entity." + event.getRightClicked().getType().name());
stats.journal(event.getPlayer(), "entity_interact", "entity=" + event.getRightClicked().getType());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onCommand(PlayerCommandPreprocessEvent event) {
Player player = event.getPlayer();
String raw = event.getMessage().startsWith("/") ? event.getMessage().substring(1) : event.getMessage();
String command = raw.isBlank() ? "unknown" : raw.split("\\s+", 2)[0].toLowerCase(Locale.ROOT);
stats.increment(player, "commands.total");
stats.increment(player, "commands." + command);
if (plugin.getConfig().getBoolean("journal.log-command-content", true)) {
Set<String> redacted = Set.copyOf(plugin.getConfig().getStringList("journal.redact-command-arguments").stream()
.map(value -> value.toLowerCase(Locale.ROOT)).toList());
String detail = redacted.contains(command) && raw.contains(" ") ? command + " <redacted>" : raw;
stats.journal(player, "command", detail);
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onChat(AsyncChatEvent event) {
Player player = event.getPlayer();
stats.increment(player, "chat.messages");
String message = PLAIN.serialize(event.message());
stats.increment(player, "chat.characters", message.length());
if (plugin.getConfig().getBoolean("journal.log-chat-content", true)) {
stats.journal(player, "chat", message);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onAdvancement(PlayerAdvancementDoneEvent event) {
stats.increment(event.getPlayer(), "advancements.total");
stats.increment(event.getPlayer(), "advancements." + event.getAdvancement().getKey());
stats.journal(event.getPlayer(), "advancement", event.getAdvancement().getKey().toString());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onTeleport(PlayerTeleportEvent event) {
stats.increment(event.getPlayer(), "teleports.total");
stats.increment(event.getPlayer(), "teleports." + event.getCause().name());
stats.journal(event.getPlayer(), "teleport", "cause=" + event.getCause() + ";from=" + location(event.getFrom()) + ";to=" + location(event.getTo()));
}
@EventHandler(priority = EventPriority.MONITOR)
public void onChangedWorld(PlayerChangedWorldEvent event) {
stats.increment(event.getPlayer(), "world_changes.total");
stats.journal(event.getPlayer(), "world_change", "from=" + event.getFrom().getName() + ";to=" + event.getPlayer().getWorld().getName());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onRespawn(PlayerRespawnEvent event) {
stats.increment(event.getPlayer(), "respawns.total");
stats.journal(event.getPlayer(), "respawn", "location=" + location(event.getRespawnLocation()));
}
@EventHandler(priority = EventPriority.MONITOR)
public void onGameMode(PlayerGameModeChangeEvent event) {
if (event.isCancelled()) {
return;
}
stats.increment(event.getPlayer(), "gamemode_changes.total");
stats.increment(event.getPlayer(), "gamemode_changes.to." + event.getNewGameMode().name());
stats.journal(event.getPlayer(), "gamemode", "to=" + event.getNewGameMode());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onLevel(PlayerLevelChangeEvent event) {
stats.increment(event.getPlayer(), "experience.level_change_events");
if (event.getNewLevel() > event.getOldLevel()) {
stats.increment(event.getPlayer(), "experience.levels_gained", event.getNewLevel() - event.getOldLevel());
}
stats.set(event.getPlayer(), "experience.current_level", event.getNewLevel());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onExperience(PlayerExpChangeEvent event) {
if (event.getAmount() > 0) {
stats.increment(event.getPlayer(), "experience.points_gained", event.getAmount());
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onFish(PlayerFishEvent event) {
stats.increment(event.getPlayer(), "fishing.events.total");
stats.increment(event.getPlayer(), "fishing." + event.getState().name());
if (event.getCaught() != null) {
stats.journal(event.getPlayer(), "fish", "state=" + event.getState() + ";caught=" + event.getCaught().getType());
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onBedEnter(PlayerBedEnterEvent event) {
stats.increment(event.getPlayer(), "beds.enter_attempts");
stats.increment(event.getPlayer(), "beds.enter_action." + event.enterAction().toString());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onBedLeave(PlayerBedLeaveEvent event) {
stats.increment(event.getPlayer(), "beds.leaves");
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onItemDamage(PlayerItemDamageEvent event) {
stats.increment(event.getPlayer(), "items.durability_damage", event.getDamage());
stats.increment(event.getPlayer(), "items.durability_damage." + event.getItem().getType().name(), event.getDamage());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onItemBreak(PlayerItemBreakEvent event) {
stats.increment(event.getPlayer(), "items.broken.total");
stats.increment(event.getPlayer(), "items.broken." + event.getBrokenItem().getType().name());
stats.journal(event.getPlayer(), "item_break", "material=" + event.getBrokenItem().getType());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onItemMend(PlayerItemMendEvent event) {
stats.increment(event.getPlayer(), "items.mended.total");
stats.increment(event.getPlayer(), "items.mended_durability", event.getRepairAmount());
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onSwapHands(PlayerSwapHandItemsEvent event) {
stats.increment(event.getPlayer(), "controls.swap_hands");
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onToggleSneak(PlayerToggleSneakEvent event) {
if (event.isSneaking()) {
stats.increment(event.getPlayer(), "controls.sneak_started");
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onToggleSprint(PlayerToggleSprintEvent event) {
if (event.isSprinting()) {
stats.increment(event.getPlayer(), "controls.sprint_started");
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onToggleFlight(PlayerToggleFlightEvent event) {
stats.increment(event.getPlayer(), "controls.flight_toggles");
}
private static String location(Location location) {
if (location == null || location.getWorld() == null) {
return "unknown";
}
return location.getWorld().getName() + ":" + location.getBlockX() + "," + location.getBlockY() + "," + location.getBlockZ();
}
private static String safe(String value) {
return value == null ? "" : value.replace('\n', ' ').replace('\r', ' ');
}
}
@@ -0,0 +1,96 @@
package net.therosegarden.playerstats;
import java.nio.file.Path;
import java.util.Objects;
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer;
import org.bukkit.Bukkit;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
public final class RosePlayerStatsPlugin extends JavaPlugin {
private static final LegacyComponentSerializer AMPERSAND_COLORS = LegacyComponentSerializer.legacyAmpersand();
private static final LegacyComponentSerializer SECTION_COLORS = LegacyComponentSerializer.legacySection();
private StatsService stats;
@Override
public void onEnable() {
saveDefaultConfig();
try {
StatsStorage storage = createStorage();
stats = new StatsService(this, storage);
} catch (Exception ex) {
getLogger().severe("Could not initialize RosePlayerStats storage: " + ex.getMessage());
getLogger().severe("RosePlayerStats will be disabled rather than writing statistics to an unintended backend.");
Bukkit.getPluginManager().disablePlugin(this);
return;
}
Bukkit.getPluginManager().registerEvents(new PlayerStatsListener(this, stats), this);
PluginCommand command = Objects.requireNonNull(getCommand("playerstats"), "playerstats command missing from plugin.yml");
PlayerStatsCommand commandHandler = new PlayerStatsCommand(this, stats);
command.setExecutor(commandHandler);
command.setTabCompleter(commandHandler);
long autosaveSeconds = Math.max(10L, getConfig().getLong("storage.autosave-seconds", 60L));
long autosaveTicks = autosaveSeconds * 20L;
Bukkit.getScheduler().runTaskTimerAsynchronously(this, () -> {
stats.saveAll();
stats.flushJournal();
}, autosaveTicks, autosaveTicks);
Bukkit.getScheduler().runTaskTimerAsynchronously(this, stats::flushJournal, 20L, 20L);
for (var player : Bukkit.getOnlinePlayers()) {
try {
stats.preload(player.getUniqueId(), player.getName());
} catch (Exception ex) {
getLogger().warning("Could not preload already-online player " + player.getName() + ": " + ex.getMessage());
stats.record(player);
}
}
getLogger().info("RosePlayerStats enabled using " + getConfig().getString("storage.type", "flatfile")
+ " storage. Player name and UUID are recorded with statistics and journal events.");
}
@Override
public void onDisable() {
if (stats != null) {
stats.close();
}
}
private StatsStorage createStorage() throws Exception {
String type = getConfig().getString("storage.type", "flatfile").trim().toLowerCase(java.util.Locale.ROOT);
boolean journalEnabled = getConfig().getBoolean("journal.enabled", true);
if (type.equals("flatfile")) {
Path dataDirectory = getDataFolder().toPath();
return new FlatFileStatsStorage(dataDirectory, journalEnabled);
}
if (type.equals("mysql") || type.equals("mariadb")) {
String path = "storage.sql.";
return new SqlStatsStorage(
getConfig().getString(path + "host", "127.0.0.1"),
Math.max(1, getConfig().getInt(path + "port", 3306)),
getConfig().getString(path + "database", "minecraft"),
getConfig().getString(path + "username", "playerstats"),
getConfig().getString(path + "password", ""),
getConfig().getString(path + "parameters", "useUnicode=true&characterEncoding=utf8&useSSL=false"),
journalEnabled
);
}
throw new IllegalArgumentException("Unsupported storage.type '" + type + "'. Use flatfile, mysql, or mariadb.");
}
String prefix() {
return color(getConfig().getString("messages.prefix", "&8[&dPlayerStats&8] &r"));
}
String color(String text) {
return SECTION_COLORS.serialize(AMPERSAND_COLORS.deserialize(text == null ? "" : text));
}
}
@@ -0,0 +1,193 @@
package net.therosegarden.playerstats;
import java.io.IOException;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Map;
import java.util.Properties;
import java.util.UUID;
final class SqlStatsStorage implements StatsStorage {
private final String url;
private final String username;
private final String password;
private final boolean journalEnabled;
private Connection connection;
SqlStatsStorage(String host, int port, String database, String username,
String password, String parameters, boolean journalEnabled) throws Exception {
this.url = "jdbc:mariadb://" + host + ":" + port + "/" + database
+ (parameters == null || parameters.isBlank() ? "" : "?" + parameters);
this.username = username;
this.password = password;
this.journalEnabled = journalEnabled;
Class.forName("org.mariadb.jdbc.Driver");
ensureConnection();
initializeSchema();
}
private synchronized Connection ensureConnection() throws SQLException {
if (connection == null || connection.isClosed() || !connection.isValid(3)) {
closeQuietly();
connection = DriverManager.getConnection(url, username, password);
}
return connection;
}
private void initializeSchema() throws SQLException {
Connection db = ensureConnection();
try (Statement statement = db.createStatement()) {
statement.executeUpdate("""
CREATE TABLE IF NOT EXISTS rose_players (
uuid CHAR(36) NOT NULL PRIMARY KEY,
last_name VARCHAR(64) NOT NULL,
first_seen BIGINT NOT NULL,
last_seen BIGINT NOT NULL,
INDEX idx_player_name (last_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""");
statement.executeUpdate("""
CREATE TABLE IF NOT EXISTS rose_player_stats (
uuid CHAR(36) NOT NULL,
player_name VARCHAR(64) NOT NULL,
stat_key VARCHAR(190) NOT NULL,
stat_value BIGINT NOT NULL,
PRIMARY KEY (uuid, stat_key),
INDEX idx_stats_player_name (player_name),
INDEX idx_stat_key (stat_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""");
statement.executeUpdate("""
CREATE TABLE IF NOT EXISTS rose_player_events (
id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
event_time BIGINT NOT NULL,
uuid CHAR(36) NOT NULL,
player_name VARCHAR(64) NOT NULL,
event_type VARCHAR(64) NOT NULL,
detail TEXT NOT NULL,
INDEX idx_event_player_time (uuid, event_time),
INDEX idx_event_player_name (player_name),
INDEX idx_event_type_time (event_type, event_time)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""");
}
}
@Override
public synchronized PlayerRecord load(UUID uuid, String currentName) throws Exception {
Connection db = ensureConnection();
Properties properties = new Properties();
properties.setProperty("meta.uuid", uuid.toString());
properties.setProperty("meta.last_name", currentName == null ? "unknown" : currentName);
long now = System.currentTimeMillis();
properties.setProperty("meta.first_seen", Long.toString(now));
properties.setProperty("meta.last_seen", Long.toString(now));
try (PreparedStatement statement = db.prepareStatement(
"SELECT last_name, first_seen, last_seen FROM rose_players WHERE uuid=?")) {
statement.setString(1, uuid.toString());
try (ResultSet result = statement.executeQuery()) {
if (result.next()) {
properties.setProperty("meta.last_name", result.getString(1));
properties.setProperty("meta.first_seen", Long.toString(result.getLong(2)));
properties.setProperty("meta.last_seen", Long.toString(result.getLong(3)));
}
}
}
try (PreparedStatement statement = db.prepareStatement(
"SELECT stat_key, stat_value FROM rose_player_stats WHERE uuid=?")) {
statement.setString(1, uuid.toString());
try (ResultSet result = statement.executeQuery()) {
while (result.next()) {
properties.setProperty("counter." + result.getString(1), Long.toString(result.getLong(2)));
}
}
}
PlayerRecord record = PlayerRecord.fromProperties(properties);
record.touch(currentName, now);
return record;
}
@Override
public synchronized void save(PlayerRecord record) throws Exception {
Connection db = ensureConnection();
boolean previousAutoCommit = db.getAutoCommit();
db.setAutoCommit(false);
try {
try (PreparedStatement statement = db.prepareStatement("""
INSERT INTO rose_players (uuid, last_name, first_seen, last_seen)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE last_name=VALUES(last_name), first_seen=LEAST(first_seen, VALUES(first_seen)), last_seen=VALUES(last_seen)
""")) {
statement.setString(1, record.uuid().toString());
statement.setString(2, record.lastName());
statement.setLong(3, record.firstSeen());
statement.setLong(4, record.lastSeen());
statement.executeUpdate();
}
try (PreparedStatement statement = db.prepareStatement("""
INSERT INTO rose_player_stats (uuid, player_name, stat_key, stat_value)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE player_name=VALUES(player_name), stat_value=VALUES(stat_value)
""")) {
for (Map.Entry<String, Long> entry : record.countersSnapshot().entrySet()) {
statement.setString(1, record.uuid().toString());
statement.setString(2, record.lastName());
statement.setString(3, entry.getKey());
statement.setLong(4, entry.getValue());
statement.addBatch();
}
statement.executeBatch();
}
db.commit();
} catch (SQLException ex) {
db.rollback();
throw ex;
} finally {
db.setAutoCommit(previousAutoCommit);
}
}
@Override
public synchronized void journal(UUID uuid, String playerName, String eventType, String detail) throws Exception {
if (!journalEnabled) {
return;
}
Connection db = ensureConnection();
try (PreparedStatement statement = db.prepareStatement(
"INSERT INTO rose_player_events (event_time, uuid, player_name, event_type, detail) VALUES (?, ?, ?, ?, ?)")) {
statement.setLong(1, System.currentTimeMillis());
statement.setString(2, uuid.toString());
statement.setString(3, playerName == null ? "unknown" : playerName);
statement.setString(4, eventType);
statement.setString(5, detail == null ? "" : detail);
statement.executeUpdate();
}
}
@Override
public synchronized void close() throws IOException {
try {
closeQuietly();
} catch (Exception ex) {
throw new IOException(ex);
}
}
private void closeQuietly() {
if (connection != null) {
try {
connection.close();
} catch (SQLException ignored) {
}
connection = null;
}
}
}
@@ -0,0 +1,145 @@
package net.therosegarden.playerstats;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Locale;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import org.bukkit.Bukkit;
import org.bukkit.OfflinePlayer;
import org.bukkit.entity.Player;
final class StatsService {
private final RosePlayerStatsPlugin plugin;
private final StatsStorage storage;
private final ConcurrentHashMap<UUID, PlayerRecord> records = new ConcurrentHashMap<>();
private final ConcurrentLinkedQueue<JournalEntry> journalQueue = new ConcurrentLinkedQueue<>();
StatsService(RosePlayerStatsPlugin plugin, StatsStorage storage) {
this.plugin = plugin;
this.storage = storage;
}
void preload(UUID uuid, String name) throws Exception {
records.put(uuid, storage.load(uuid, name));
}
PlayerRecord record(Player player) {
long now = System.currentTimeMillis();
PlayerRecord record = records.computeIfAbsent(player.getUniqueId(),
id -> new PlayerRecord(id, player.getName(), now));
record.touch(player.getName(), now);
return record;
}
PlayerRecord record(UUID uuid, String name) {
long now = System.currentTimeMillis();
PlayerRecord record = records.computeIfAbsent(uuid, id -> new PlayerRecord(id, name, now));
record.touch(name, now);
return record;
}
PlayerRecord findLoaded(String nameOrUuid) {
try {
PlayerRecord byUuid = records.get(UUID.fromString(nameOrUuid));
if (byUuid != null) {
return byUuid;
}
} catch (IllegalArgumentException ignored) {
}
for (PlayerRecord record : records.values()) {
if (record.lastName().equalsIgnoreCase(nameOrUuid)) {
return record;
}
}
return null;
}
void increment(Player player, String key) {
increment(player, key, 1L);
}
void increment(Player player, String key, long amount) {
record(player).increment(normalizeKey(key), amount);
}
void set(Player player, String key, long value) {
record(player).set(normalizeKey(key), value);
}
void journal(Player player, String eventType, String detail) {
if (!plugin.getConfig().getBoolean("journal.enabled", true)) {
return;
}
record(player);
journalQueue.add(new JournalEntry(player.getUniqueId(), player.getName(), normalizeKey(eventType), detail));
}
void save(PlayerRecord record) {
try {
storage.save(record);
} catch (Exception ex) {
plugin.getLogger().severe("Failed saving statistics for " + record.lastName() + ": " + ex.getMessage());
}
}
void saveAll() {
try {
storage.saveAll(new ArrayList<>(records.values()));
} catch (Exception ex) {
plugin.getLogger().severe("Failed saving player statistics: " + ex.getMessage());
}
}
void flushJournal() {
JournalEntry entry;
int processed = 0;
while ((entry = journalQueue.poll()) != null) {
try {
storage.journal(entry.uuid(), entry.playerName(), entry.eventType(), entry.detail());
} catch (Exception ex) {
journalQueue.add(entry);
plugin.getLogger().severe("Failed writing player event journal: " + ex.getMessage());
break;
}
processed++;
if (processed >= 5000) {
break;
}
}
}
void savePlayerAsync(Player player) {
PlayerRecord record = record(player);
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> save(record));
}
Collection<PlayerRecord> records() {
return records.values();
}
void close() {
saveAll();
flushJournal();
try {
storage.close();
} catch (Exception ex) {
plugin.getLogger().warning("Failed closing statistics storage: " + ex.getMessage());
}
}
static String normalizeKey(String key) {
if (key == null || key.isBlank()) {
return "unknown";
}
return key.toLowerCase(Locale.ROOT)
.replace(' ', '_')
.replace(':', '.')
.replaceAll("[^a-z0-9._-]", "_");
}
private record JournalEntry(UUID uuid, String playerName, String eventType, String detail) {
}
}
@@ -0,0 +1,23 @@
package net.therosegarden.playerstats;
import java.io.Closeable;
import java.util.Collection;
import java.util.UUID;
interface StatsStorage extends Closeable {
PlayerRecord load(UUID uuid, String currentName) throws Exception;
void save(PlayerRecord record) throws Exception;
default void saveAll(Collection<PlayerRecord> records) throws Exception {
for (PlayerRecord record : records) {
save(record);
}
}
void journal(UUID uuid, String playerName, String eventType, String detail) throws Exception;
@Override
default void close() throws java.io.IOException {
}
}
+47
View File
@@ -0,0 +1,47 @@
# RosePlayerStats
# Comprehensive player statistics and event journaling.
storage:
# flatfile, mysql, or mariadb
type: flatfile
autosave-seconds: 60
sql:
host: 127.0.0.1
port: 3306
database: minecraft
username: playerstats
password: change-me
# Optional JDBC parameters appended to the connection URL.
parameters: 'useUnicode=true&characterEncoding=utf8&useSSL=false'
movement:
enabled: true
# Minecraft does not expose literal footfall counts. RosePlayerStats records
# movement distance and estimates steps using this average step length.
estimated-step-length-centimeters: 76.2
# Ignore larger deltas as teleports/server corrections.
max-single-move-blocks: 12.0
journal:
enabled: true
# Detailed event history is JSONL for flatfile storage and rows in
# rose_player_events for MySQL/MariaDB.
log-statistic-increments: true
log-chat-content: true
log-command-content: true
# These commands are still counted, but their arguments are replaced with
# <redacted> so authentication data is not stored in the event journal.
redact-command-arguments:
- login
- register
- changepassword
- password
- 2fa
- otp
- token
- auth
messages:
prefix: '&8[&dPlayerStats&8] &r'
+20
View File
@@ -0,0 +1,20 @@
name: RosePlayerStats
version: '${version}'
main: net.therosegarden.playerstats.RosePlayerStatsPlugin
api-version: '26.2'
description: Comprehensive player statistics and event journaling for The Rose Garden.
commands:
playerstats:
description: View or manage RosePlayerStats data.
aliases: [pstats, stats]
usage: /playerstats [player|save|reload]
permissions:
roseplayerstats.view:
description: View your own player statistics.
default: true
roseplayerstats.view.others:
description: View another player's statistics.
default: op
roseplayerstats.admin:
description: Save and reload RosePlayerStats.
default: op