feat: add comprehensive player statistics logging
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
.gradle/
|
||||
build/
|
||||
.idea/
|
||||
*.iml
|
||||
@@ -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/`.
|
||||
@@ -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")
|
||||
}
|
||||
Vendored
BIN
Binary file not shown.
+10
@@ -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
|
||||
@@ -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
@@ -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%
|
||||
@@ -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 {
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user