commit 180a55a72dddb15b16097ddda6c1f651be6db456 Author: Michael Burgess Date: Mon Aug 17 12:13:40 2026 -0400 feat: add comprehensive player statistics logging diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e81a89f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.gradle/ +build/ +.idea/ +*.iml diff --git a/README.md b/README.md new file mode 100644 index 0000000..3966448 --- /dev/null +++ b/README.md @@ -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 `` in the journal. + +## Storage + +### Flat file + +Default configuration: + +```yaml +storage: + type: flatfile +``` + +Player counters are stored under: + +```text +plugins/RosePlayerStats/players/.properties +``` + +Each file includes both: + +```text +meta.uuid= +meta.last_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 +/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/`. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..7d8491b --- /dev/null +++ b/build.gradle.kts @@ -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().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") +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..dbe66e1 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -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" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..a51ec4f --- /dev/null +++ b/gradlew.bat @@ -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% diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..effaffa --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "RosePlayerStats" diff --git a/src/main/java/net/therosegarden/playerstats/FlatFileStatsStorage.java b/src/main/java/net/therosegarden/playerstats/FlatFileStatsStorage.java new file mode 100644 index 0000000..a9c6f27 --- /dev/null +++ b/src/main/java/net/therosegarden/playerstats/FlatFileStatsStorage.java @@ -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(); + } +} diff --git a/src/main/java/net/therosegarden/playerstats/PlayerRecord.java b/src/main/java/net/therosegarden/playerstats/PlayerRecord.java new file mode 100644 index 0000000..115ecb1 --- /dev/null +++ b/src/main/java/net/therosegarden/playerstats/PlayerRecord.java @@ -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 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 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 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; + } + } +} diff --git a/src/main/java/net/therosegarden/playerstats/PlayerStatsCommand.java b/src/main/java/net/therosegarden/playerstats/PlayerStatsCommand.java new file mode 100644 index 0000000..86ecf3e --- /dev/null +++ b/src/main/java/net/therosegarden/playerstats/PlayerStatsCommand.java @@ -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 ")); + 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 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 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; + } +} diff --git a/src/main/java/net/therosegarden/playerstats/PlayerStatsListener.java b/src/main/java/net/therosegarden/playerstats/PlayerStatsListener.java new file mode 100644 index 0000000..cb192f1 --- /dev/null +++ b/src/main/java/net/therosegarden/playerstats/PlayerStatsListener.java @@ -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 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 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 + " " : 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', ' '); + } +} diff --git a/src/main/java/net/therosegarden/playerstats/RosePlayerStatsPlugin.java b/src/main/java/net/therosegarden/playerstats/RosePlayerStatsPlugin.java new file mode 100644 index 0000000..51bd42c --- /dev/null +++ b/src/main/java/net/therosegarden/playerstats/RosePlayerStatsPlugin.java @@ -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)); + } +} diff --git a/src/main/java/net/therosegarden/playerstats/SqlStatsStorage.java b/src/main/java/net/therosegarden/playerstats/SqlStatsStorage.java new file mode 100644 index 0000000..37a2e7c --- /dev/null +++ b/src/main/java/net/therosegarden/playerstats/SqlStatsStorage.java @@ -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 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; + } + } +} diff --git a/src/main/java/net/therosegarden/playerstats/StatsService.java b/src/main/java/net/therosegarden/playerstats/StatsService.java new file mode 100644 index 0000000..d44e3e1 --- /dev/null +++ b/src/main/java/net/therosegarden/playerstats/StatsService.java @@ -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 records = new ConcurrentHashMap<>(); + private final ConcurrentLinkedQueue 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 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) { + } +} diff --git a/src/main/java/net/therosegarden/playerstats/StatsStorage.java b/src/main/java/net/therosegarden/playerstats/StatsStorage.java new file mode 100644 index 0000000..1217ac1 --- /dev/null +++ b/src/main/java/net/therosegarden/playerstats/StatsStorage.java @@ -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 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 { + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..e10bba7 --- /dev/null +++ b/src/main/resources/config.yml @@ -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 + # 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' diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..5938dd6 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -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