feat: ATM10-Statistikportal
Statistikportal für den All-the-Mods-10-Server (Minecraft 1.21.1, NeoForge 21.1): - mod/: serverseitige NeoForge-Mod atmstats – Ereignisse, Live-Zähler, Servergesundheit, FTB-Dateien; Outbox auf der Festplatte, signierter gzip-Versand per HTTPS mit Backoff - backend/: Fastify + PostgreSQL – idempotenter Ingest, Projektionen, Parser für SNBT, FTB Quests/Teams, world/stats, world/advancements und latest.log; Ranglisten, Snapshots, Retention, öffentliche API, SSE-Live-Updates, Admin mit argon2/CSRF, SFTP-Worker - web/: Next.js-Dashboard – Übersicht, Ranglisten, Spielerprofile, ATM10, Server, Datenquellen, Verwaltung - shared/: Protokoll v1 und API-Typen; tools/: Simulator, Log-Anonymisierer, RCON-Client - deploy/: Docker Compose hinter Traefik, Backup; docs/: Architektur, Datenquellen, Betrieb Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
56d2078e6a
commit
e66f89f49e
214 files changed
+27077
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Disable autocrlf on generated files, they always generate with LF
|
||||
# Add any extra files or paths here to make git stop saying they
|
||||
# are changed when only line endings change.
|
||||
src/generated/**/.cache/* text eol=lf
|
||||
src/generated/**/*.json text eol=lf
|
||||
Vendored
+25
@@ -0,0 +1,25 @@
|
||||
name: Build
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Setup JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
@@ -0,0 +1,3 @@
|
||||
run/
|
||||
build/
|
||||
.gradle/
|
||||
@@ -0,0 +1,41 @@
|
||||
# atmstats (NeoForge-Mod)
|
||||
|
||||
Serverseitige Statistik-Erfassung für das ATM10-Statistikportal. Minecraft 1.21.1, NeoForge ab 21.1.0, Java 21.
|
||||
Clients brauchen die Mod nicht (`displayTest = IGNORE_ALL_VERSION`). Sie registriert keine Blöcke, Items oder Pakete.
|
||||
|
||||
## Bauen und testen
|
||||
|
||||
```bash
|
||||
./gradlew build # JAR in build/libs/atmstats-<version>.jar
|
||||
./gradlew test # Unit-Tests des Kerns
|
||||
./gradlew runServer # lokaler Testserver in run/ (eula.txt und config/atmstats-server.toml vorher anlegen)
|
||||
```
|
||||
|
||||
Für einen lokalen Ende-zu-Ende-Test: Backend starten, Token erzeugen, in `run/config/atmstats-server.toml`
|
||||
`endpoint = "http://127.0.0.1:3001"` eintragen (nicht `localhost`: Java löst das zuerst als IPv6 auf), RCON in
|
||||
`run/server.properties` aktivieren und den Server mit `node tools/rcon.mjs 127.0.0.1 25575 <passwort> stop` beenden.
|
||||
|
||||
## Was die Mod erfasst
|
||||
|
||||
| Ereignis | Auslöser |
|
||||
|---|---|
|
||||
| `server_start` / `server_stop` | Serverstart (Versionen, Modliste) / -stopp |
|
||||
| `player_join` / `player_leave` | Login/Logout echter Spieler (keine FakePlayer) |
|
||||
| `player_death` | Tod eines Spielers: Dimension, Schadensart, Killer |
|
||||
| `entity_kill` | Spieler besiegt einen Boss (`c:bosses`) oder Spieler |
|
||||
| `advancement` | Advancement mit Anzeige (keine Rezepte) |
|
||||
| `dimension_change` | Dimensionswechsel |
|
||||
| `stats_snapshot` | alle 5 min geänderte Vanilla-Zähler online Spieler (erster Snapshot nach Start vollständig) |
|
||||
| `file_sync` | geänderte Dateien: `world/ftbquests/*.snbt`, `world/ftbteams/{player,party}/*.snbt`, `config/ftbquests/quests/chapters/*.snbt`, `config/ftbquests/quests/lang/en_us/**/*.snbt_merged` (rund 4 MB beim ersten Start, danach nur Änderungen) |
|
||||
| `health` | jede Minute: Ø/P95/Max-Tickdauer, TPS, Spieler, geladene Chunks, Heap |
|
||||
|
||||
## Last
|
||||
|
||||
Auf dem Server-Thread passieren nur das Anlegen kleiner Records, `System.nanoTime()` pro Tick und alle 5 Minuten
|
||||
das Kopieren der Zählertabelle von höchstens zwei Spielern pro Tick. JSON, gzip, Dateien und HTTPS laufen im
|
||||
Thread `atmstats-worker` mit niedriger Priorität. Im lokalen Testlauf trat keine „Can't keep up“-Meldung auf.
|
||||
|
||||
## Dateien auf dem Gameserver
|
||||
|
||||
- `config/atmstats-server.toml`: Konfiguration (siehe `docs/OPERATIONS.md`)
|
||||
- `atmstats/outbox/`: noch nicht bestätigte Ereignisse; `rejected/` enthält vom Backend abgelehnte Batches mit Grund
|
||||
@@ -0,0 +1,68 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'net.neoforged.moddev' version '2.0.148'
|
||||
}
|
||||
|
||||
version = mod_version
|
||||
group = mod_group_id
|
||||
|
||||
base {
|
||||
archivesName = mod_id
|
||||
}
|
||||
|
||||
java.toolchain.languageVersion = JavaLanguageVersion.of(21)
|
||||
|
||||
neoForge {
|
||||
version = project.neo_version
|
||||
|
||||
parchment {
|
||||
mappingsVersion = project.parchment_mappings_version
|
||||
minecraftVersion = project.parchment_minecraft_version
|
||||
}
|
||||
|
||||
runs {
|
||||
// Lokaler Testserver: ./gradlew runServer (Arbeitsverzeichnis mod/run)
|
||||
server {
|
||||
server()
|
||||
programArgument '--nogui'
|
||||
}
|
||||
}
|
||||
|
||||
mods {
|
||||
"${mod_id}" {
|
||||
sourceSet(sourceSets.main)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation platform('org.junit:junit-bom:5.11.4')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
testImplementation 'com.google.code.gson:gson:2.11.0'
|
||||
}
|
||||
|
||||
tasks.named('test', Test).configure {
|
||||
useJUnitPlatform()
|
||||
// Protocol fixtures shared with the backend (../shared/fixtures/protocol)
|
||||
systemProperty 'atm.fixtures', file('../shared/fixtures/protocol').absolutePath
|
||||
testLogging { events 'failed'; exceptionFormat 'full' }
|
||||
}
|
||||
|
||||
var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) {
|
||||
var replaceProperties = [
|
||||
minecraft_version_range: minecraft_version_range,
|
||||
neo_version_range : neo_version_range,
|
||||
loader_version_range : loader_version_range,
|
||||
mod_id : mod_id,
|
||||
mod_name : mod_name,
|
||||
mod_license : mod_license,
|
||||
mod_version : mod_version,
|
||||
]
|
||||
inputs.properties replaceProperties
|
||||
expand replaceProperties
|
||||
from "src/main/templates"
|
||||
into "build/generated/sources/modMetadata"
|
||||
}
|
||||
sourceSets.main.resources.srcDir generateModMetadata
|
||||
neoForge.ideSyncTask generateModMetadata
|
||||
@@ -0,0 +1,21 @@
|
||||
org.gradle.jvmargs=-Xmx2G
|
||||
org.gradle.daemon=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
|
||||
parchment_minecraft_version=1.21.1
|
||||
parchment_mappings_version=2024.11.17
|
||||
|
||||
minecraft_version=1.21.1
|
||||
minecraft_version_range=[1.21.1,1.21.2)
|
||||
# Kompiliert gegen die NeoForge-Version von ATM10 6.1; lauffähig ab 21.1.0 (siehe neo_version_range).
|
||||
neo_version=21.1.219
|
||||
neo_version_range=[21.1.0,)
|
||||
loader_version_range=[1,)
|
||||
|
||||
mod_id=atmstats
|
||||
mod_name=ATM Stats
|
||||
mod_license=MIT
|
||||
mod_version=1.0.0
|
||||
mod_group_id=de.hahmann.atmstats
|
||||
BIN
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
Vendored
+251
@@ -0,0 +1,251 @@
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright © 2015-2021 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
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
#
|
||||
# Gradle 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 Gradle
|
||||
#
|
||||
# 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/HEAD/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
|
||||
|
||||
CLASSPATH="\\\"\\\""
|
||||
|
||||
|
||||
# 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" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
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" \
|
||||
-classpath "$CLASSPATH" \
|
||||
-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
+94
@@ -0,0 +1,94 @@
|
||||
@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 Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
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
|
||||
|
||||
goto fail
|
||||
|
||||
: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
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,9 @@
|
||||
pluginManagement {
|
||||
repositories {
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import java.util.Random;
|
||||
|
||||
/** Exponential backoff with jitter: delay in [d/2, d] where d = min(max, base * 2^attempt). */
|
||||
public final class Backoff {
|
||||
private final long baseMs;
|
||||
private final long maxMs;
|
||||
|
||||
public Backoff(long baseMs, long maxMs) {
|
||||
this.baseMs = baseMs;
|
||||
this.maxMs = maxMs;
|
||||
}
|
||||
|
||||
public long delayMs(int attempt, Random rnd) {
|
||||
long d = attempt >= 40 ? maxMs : Math.min(maxMs, baseMs << Math.max(0, attempt));
|
||||
if (d <= 0) d = maxMs; // overflow guard
|
||||
long half = d / 2;
|
||||
return half + (long) (rnd.nextDouble() * (d - half + 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import de.hahmann.atmstats.core.Events.AtmEvent;
|
||||
|
||||
/** Serializes protocol events: the record's components plus {@code type}; null fields are written explicitly. */
|
||||
public final class EventJson {
|
||||
private static final Gson GSON = new GsonBuilder().serializeNulls().disableHtmlEscaping().create();
|
||||
|
||||
private EventJson() {}
|
||||
|
||||
public static String toJson(AtmEvent event) {
|
||||
JsonObject obj = GSON.toJsonTree(event).getAsJsonObject();
|
||||
obj.addProperty("type", event.type());
|
||||
return GSON.toJson(obj);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Protocol v1 events. Field names are the JSON names; they must match shared/src/protocol.ts
|
||||
* (verified by EventJsonTest against shared/fixtures/protocol).
|
||||
*/
|
||||
public final class Events {
|
||||
private Events() {}
|
||||
|
||||
public interface AtmEvent {
|
||||
UUID id();
|
||||
|
||||
long ts();
|
||||
|
||||
String type();
|
||||
}
|
||||
|
||||
public record ModInfo(String id, String version, String name) {}
|
||||
|
||||
public record ServerStart(UUID id, long ts, String mcVersion, String neoforgeVersion, String javaVersion, List<ModInfo> mods)
|
||||
implements AtmEvent {
|
||||
public String type() { return "server_start"; }
|
||||
}
|
||||
|
||||
public record ServerStop(UUID id, long ts) implements AtmEvent {
|
||||
public String type() { return "server_stop"; }
|
||||
}
|
||||
|
||||
public record PlayerJoin(UUID id, long ts, String uuid, String name) implements AtmEvent {
|
||||
public String type() { return "player_join"; }
|
||||
}
|
||||
|
||||
public record PlayerLeave(UUID id, long ts, String uuid, String name) implements AtmEvent {
|
||||
public String type() { return "player_leave"; }
|
||||
}
|
||||
|
||||
public record PlayerDeath(UUID id, long ts, String uuid, String name, String dimension, String cause, String killerType,
|
||||
String killerName) implements AtmEvent {
|
||||
public String type() { return "player_death"; }
|
||||
}
|
||||
|
||||
public record EntityKill(UUID id, long ts, String uuid, String name, String dimension, String victimType, boolean isBoss,
|
||||
String weapon) implements AtmEvent {
|
||||
public String type() { return "entity_kill"; }
|
||||
}
|
||||
|
||||
public record Advancement(UUID id, long ts, String uuid, String name, String advancementId, String title) implements AtmEvent {
|
||||
public String type() { return "advancement"; }
|
||||
}
|
||||
|
||||
public record DimensionChange(UUID id, long ts, String uuid, String name, String from, String to) implements AtmEvent {
|
||||
public String type() { return "dimension_change"; }
|
||||
}
|
||||
|
||||
/** Absolute counter values; {@code full} = all counters of the player, otherwise only changed ones. */
|
||||
public record StatsSnapshot(UUID id, long ts, String uuid, String name, boolean full, Map<String, Map<String, Long>> stats)
|
||||
implements AtmEvent {
|
||||
public String type() { return "stats_snapshot"; }
|
||||
}
|
||||
|
||||
public record FileSync(UUID id, long ts, String path, String sha256, String content) implements AtmEvent {
|
||||
public String type() { return "file_sync"; }
|
||||
}
|
||||
|
||||
public record Health(UUID id, long ts, long windowStart, long windowEnd, double msptAvg, double msptP95, double msptMax,
|
||||
double tps, int players, int chunks, double heapUsedMb, double heapMaxMb) implements AtmEvent {
|
||||
public String type() { return "health"; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Remembers size, mtime and content hash per file so unchanged files are neither read nor sent again.
|
||||
* Used by the worker thread only.
|
||||
*/
|
||||
public final class FileChangeTracker {
|
||||
private record State(long size, long mtime, String sha256) {}
|
||||
|
||||
private final Map<String, State> states = new HashMap<>();
|
||||
|
||||
/** True when size or mtime differ from what was last committed (or the file is new). */
|
||||
public boolean needsRead(String path, long size, long mtime) {
|
||||
State s = states.get(path);
|
||||
return s == null || s.size != size || s.mtime != mtime;
|
||||
}
|
||||
|
||||
/** Records the file's current state; true when its content differs from the last committed content. */
|
||||
public boolean commit(String path, long size, long mtime, String sha256) {
|
||||
State prev = states.put(path, new State(size, mtime, sha256));
|
||||
return prev == null || !prev.sha256.equals(sha256);
|
||||
}
|
||||
|
||||
/** Drops state of files that no longer exist. */
|
||||
public void retainOnly(Collection<String> existing) {
|
||||
states.keySet().retainAll(new HashSet<>(existing));
|
||||
}
|
||||
|
||||
public int size() {
|
||||
return states.size();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* Aggregates tick durations of one reporting window (avg, p95, max). O(1) per tick; the p95 uses the most recent
|
||||
* {@code capacity} samples. Server thread only.
|
||||
*/
|
||||
public final class HealthWindow {
|
||||
public record Stats(int ticks, double msptAvg, double msptP95, double msptMax, double tps) {}
|
||||
|
||||
private final long[] samples;
|
||||
private int next;
|
||||
private int stored;
|
||||
private long sumNanos;
|
||||
private long maxNanos;
|
||||
private int ticks;
|
||||
|
||||
public HealthWindow(int capacity) {
|
||||
this.samples = new long[capacity];
|
||||
}
|
||||
|
||||
public void record(long tickNanos) {
|
||||
samples[next] = tickNanos;
|
||||
next = (next + 1) % samples.length;
|
||||
stored = Math.min(stored + 1, samples.length);
|
||||
sumNanos += tickNanos;
|
||||
maxNanos = Math.max(maxNanos, tickNanos);
|
||||
ticks++;
|
||||
}
|
||||
|
||||
/** Returns the window's statistics and starts a new window. */
|
||||
public Stats drain() {
|
||||
if (ticks == 0) return new Stats(0, 0, 0, 0, 0);
|
||||
long[] sorted = Arrays.copyOf(samples, stored);
|
||||
Arrays.sort(sorted);
|
||||
double p95 = sorted[Math.max(0, (int) Math.ceil(0.95 * stored) - 1)] / 1e6;
|
||||
double avg = sumNanos / 1e6 / ticks;
|
||||
Stats s = new Stats(ticks, avg, p95, maxNanos / 1e6, Math.min(20.0, 1000.0 / Math.max(avg, 1e-9)));
|
||||
next = stored = ticks = 0;
|
||||
sumNanos = maxNanos = 0;
|
||||
return s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** HTTPS transport (Java 11 HttpClient, default certificate validation). */
|
||||
public final class HttpIngestClient implements IngestClient {
|
||||
private final HttpClient http;
|
||||
private final URI base;
|
||||
private final String token;
|
||||
private final String serverId;
|
||||
private final byte[] secret;
|
||||
private final Duration timeout;
|
||||
|
||||
public HttpIngestClient(URI base, String token, String serverId, byte[] secret, Duration timeout) {
|
||||
this.base = base;
|
||||
this.token = token;
|
||||
this.serverId = serverId;
|
||||
this.secret = secret;
|
||||
this.timeout = timeout;
|
||||
this.http = HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).connectTimeout(timeout).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response send(byte[] gzBody, long ts, String signature) throws IOException {
|
||||
HttpRequest req = HttpRequest.newBuilder(base.resolve("/api/ingest"))
|
||||
.timeout(timeout)
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Content-Encoding", "gzip")
|
||||
.header("X-ATM-Server", serverId)
|
||||
.header("X-ATM-Timestamp", Long.toString(ts))
|
||||
.header("X-ATM-Signature", signature)
|
||||
.POST(HttpRequest.BodyPublishers.ofByteArray(gzBody))
|
||||
.build();
|
||||
HttpResponse<String> res = exchange(req);
|
||||
return parse(res.statusCode(), res.body());
|
||||
}
|
||||
|
||||
/** Startup self-test: returns "reachable" or a human-readable reason. */
|
||||
public String ping(long now) {
|
||||
try {
|
||||
HttpRequest req = HttpRequest.newBuilder(base.resolve("/api/ingest/ping"))
|
||||
.timeout(timeout)
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("X-ATM-Timestamp", Long.toString(now))
|
||||
.header("X-ATM-Signature", Signer.sign(secret, now, new byte[0]))
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> res = exchange(req);
|
||||
if (res.statusCode() == 200) return "reachable";
|
||||
Response r = parse(res.statusCode(), res.body());
|
||||
return "HTTP " + res.statusCode() + (r.error() != null ? " " + r.error() : "");
|
||||
} catch (IOException e) {
|
||||
return e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
private HttpResponse<String> exchange(HttpRequest req) throws IOException {
|
||||
try {
|
||||
return http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IOException("interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
static Response parse(int status, String body) {
|
||||
Set<String> accepted = new HashSet<>();
|
||||
Set<String> duplicate = new HashSet<>();
|
||||
Map<String, String> rejected = new HashMap<>();
|
||||
String error = null;
|
||||
try {
|
||||
JsonElement el = JsonParser.parseString(body == null || body.isEmpty() ? "{}" : body);
|
||||
if (el.isJsonObject()) {
|
||||
JsonObject o = el.getAsJsonObject();
|
||||
if (o.has("accepted")) o.getAsJsonArray("accepted").forEach(e -> accepted.add(e.getAsString()));
|
||||
if (o.has("duplicate")) o.getAsJsonArray("duplicate").forEach(e -> duplicate.add(e.getAsString()));
|
||||
if (o.has("rejected")) {
|
||||
o.getAsJsonArray("rejected").forEach(e -> {
|
||||
JsonObject r = e.getAsJsonObject();
|
||||
String id = r.has("id") && !r.get("id").isJsonNull() ? r.get("id").getAsString() : "?";
|
||||
rejected.put(id, r.has("reason") ? r.get("reason").getAsString() : "");
|
||||
});
|
||||
}
|
||||
if (o.has("error")) error = o.get("error").getAsString();
|
||||
}
|
||||
} catch (RuntimeException ignored) {
|
||||
// non-JSON answer (e.g. proxy error page)
|
||||
}
|
||||
return new Response(status, accepted, duplicate, rejected, error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
public interface IngestClient {
|
||||
/** POST /api/ingest with a gzipped protocol batch. */
|
||||
Response send(byte[] gzBody, long ts, String signature) throws IOException;
|
||||
|
||||
/**
|
||||
* @param error backend error code for non-2xx answers (e.g. {@code timestamp_out_of_range}), else null
|
||||
*/
|
||||
record Response(int status, Set<String> accepted, Set<String> duplicate, Map<String, String> rejected, String error) {}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
/** Minimal logging seam so the core does not depend on the game's logging setup. */
|
||||
public interface LogSink {
|
||||
void info(String msg);
|
||||
|
||||
void warn(String msg);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Durable outbox on disk. Events are appended as JSON lines to {@code current.jsonl}; full segments are rotated
|
||||
* to {@code seg-<seq>.jsonl} and deleted only after the backend acknowledged them. A segment is one ingest batch.
|
||||
* Not thread-safe: used by the single worker thread only.
|
||||
*/
|
||||
public final class OutboxJournal {
|
||||
public static final int MAX_LINES = 500;
|
||||
public static final long MAX_SEGMENT_BYTES = 256 * 1024;
|
||||
private static final String CURRENT = "current.jsonl";
|
||||
private static final String SNAPSHOT_MARKER = "\"type\":\"stats_snapshot\"";
|
||||
|
||||
public record Segment(Path file, List<String> lines) {}
|
||||
|
||||
private final Path dir;
|
||||
private final long maxBytes;
|
||||
private final Duration maxAge;
|
||||
private long nextSeq;
|
||||
private int currentLines;
|
||||
private long currentBytes;
|
||||
|
||||
public OutboxJournal(Path dir, long maxBytes, Duration maxAge) throws IOException {
|
||||
this.dir = dir;
|
||||
this.maxBytes = maxBytes;
|
||||
this.maxAge = maxAge;
|
||||
Files.createDirectories(dir);
|
||||
nextSeq = closedSegments().stream().mapToLong(OutboxJournal::seqOf).max().orElse(0) + 1;
|
||||
Path current = dir.resolve(CURRENT);
|
||||
if (Files.exists(current)) {
|
||||
byte[] data = Files.readAllBytes(current);
|
||||
int end = lastNewline(data);
|
||||
if (end + 1 != data.length) {
|
||||
// A crash mid-write leaves a partial last line; drop it.
|
||||
try (FileChannel ch = FileChannel.open(current, StandardOpenOption.WRITE)) {
|
||||
ch.truncate(end + 1L);
|
||||
}
|
||||
}
|
||||
List<String> lines = readLines(current);
|
||||
currentLines = lines.size();
|
||||
currentBytes = end + 1L;
|
||||
}
|
||||
}
|
||||
|
||||
/** Appends lines durably-enough (flushed, fsync on rotation). Returns the number of lines evicted by limits. */
|
||||
public int append(List<String> lines) throws IOException {
|
||||
Path current = dir.resolve(CURRENT);
|
||||
StringBuilder pending = new StringBuilder();
|
||||
for (String line : lines) {
|
||||
long len = line.getBytes(StandardCharsets.UTF_8).length + 1L;
|
||||
if (currentLines > 0 && (currentLines >= MAX_LINES || currentBytes + len > MAX_SEGMENT_BYTES)) {
|
||||
write(current, pending);
|
||||
pending.setLength(0);
|
||||
rotate();
|
||||
}
|
||||
pending.append(line).append('\n');
|
||||
currentLines++;
|
||||
currentBytes += len;
|
||||
}
|
||||
write(current, pending);
|
||||
return enforceLimits();
|
||||
}
|
||||
|
||||
/** The oldest closed segment; rotates the current one first when nothing else is waiting. */
|
||||
public Optional<Segment> nextSendable() throws IOException {
|
||||
List<Path> closed = closedSegments();
|
||||
if (closed.isEmpty() && currentLines > 0) {
|
||||
rotate();
|
||||
closed = closedSegments();
|
||||
}
|
||||
if (closed.isEmpty()) return Optional.empty();
|
||||
Path file = closed.get(0);
|
||||
return Optional.of(new Segment(file, readLines(file)));
|
||||
}
|
||||
|
||||
public void ack(Segment segment) throws IOException {
|
||||
Files.deleteIfExists(segment.file());
|
||||
}
|
||||
|
||||
/** Moves a segment the backend refused permanently to rejected/ together with the reason. */
|
||||
public void reject(Segment segment, String reason) throws IOException {
|
||||
Path rejected = dir.resolve("rejected");
|
||||
Files.createDirectories(rejected);
|
||||
Path target = rejected.resolve(segment.file().getFileName());
|
||||
Files.move(segment.file(), target, StandardCopyOption.REPLACE_EXISTING);
|
||||
Files.writeString(rejected.resolve(segment.file().getFileName() + ".reason.txt"), reason, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** Keeps single events the backend refused (while the rest of their batch was accepted) for inspection. */
|
||||
public void rejectLines(List<String> lines, String reason) throws IOException {
|
||||
Path rejected = dir.resolve("rejected");
|
||||
Files.createDirectories(rejected);
|
||||
String name = "events-" + System.currentTimeMillis() + "-" + Integer.toHexString(lines.hashCode());
|
||||
Files.writeString(rejected.resolve(name + ".jsonl"), String.join("\n", lines) + "\n", StandardCharsets.UTF_8);
|
||||
Files.writeString(rejected.resolve(name + ".reason.txt"), reason, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public long totalBytes() throws IOException {
|
||||
long total = currentBytes;
|
||||
for (Path p : closedSegments()) total += Files.size(p);
|
||||
return total;
|
||||
}
|
||||
|
||||
public int pendingSegments() throws IOException {
|
||||
return closedSegments().size() + (currentLines > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
// --- internals ---
|
||||
|
||||
private void rotate() throws IOException {
|
||||
Path current = dir.resolve(CURRENT);
|
||||
if (currentLines == 0 || !Files.exists(current)) return;
|
||||
try (FileChannel ch = FileChannel.open(current, StandardOpenOption.WRITE)) {
|
||||
ch.force(true);
|
||||
}
|
||||
Files.move(current, dir.resolve(String.format("seg-%020d.jsonl", nextSeq++)), StandardCopyOption.ATOMIC_MOVE);
|
||||
currentLines = 0;
|
||||
currentBytes = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Over the size or age limit, stats snapshots go first (a later snapshot supersedes them), oldest segments first;
|
||||
* only then whole segments are dropped.
|
||||
*/
|
||||
private int enforceLimits() throws IOException {
|
||||
int dropped = 0;
|
||||
Instant oldest = Instant.now().minus(maxAge);
|
||||
for (Path p : closedSegments()) {
|
||||
if (Files.getLastModifiedTime(p).toInstant().isBefore(oldest)) {
|
||||
dropped += readLines(p).size();
|
||||
Files.delete(p);
|
||||
}
|
||||
}
|
||||
if (totalBytes() <= maxBytes) return dropped;
|
||||
rotate();
|
||||
for (Path p : closedSegments()) {
|
||||
if (totalBytes() <= maxBytes) return dropped;
|
||||
List<String> lines = readLines(p);
|
||||
List<String> kept = lines.stream().filter(l -> !l.contains(SNAPSHOT_MARKER)).toList();
|
||||
if (kept.size() == lines.size()) continue;
|
||||
dropped += lines.size() - kept.size();
|
||||
FileTime mtime = Files.getLastModifiedTime(p);
|
||||
if (kept.isEmpty()) {
|
||||
Files.delete(p);
|
||||
} else {
|
||||
Files.writeString(p, String.join("\n", kept) + "\n", StandardCharsets.UTF_8);
|
||||
Files.setLastModifiedTime(p, mtime);
|
||||
}
|
||||
}
|
||||
for (Path p : closedSegments()) {
|
||||
if (totalBytes() <= maxBytes) break;
|
||||
dropped += readLines(p).size();
|
||||
Files.delete(p);
|
||||
}
|
||||
return dropped;
|
||||
}
|
||||
|
||||
private void write(Path current, CharSequence text) throws IOException {
|
||||
if (text.isEmpty()) return;
|
||||
Files.writeString(current, text, StandardCharsets.UTF_8, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
|
||||
}
|
||||
|
||||
private List<Path> closedSegments() throws IOException {
|
||||
try (Stream<Path> files = Files.list(dir)) {
|
||||
return files.filter(p -> p.getFileName().toString().matches("seg-\\d{20}\\.jsonl")).sorted().toList();
|
||||
}
|
||||
}
|
||||
|
||||
private static long seqOf(Path p) {
|
||||
String n = p.getFileName().toString();
|
||||
return Long.parseLong(n.substring(4, 24));
|
||||
}
|
||||
|
||||
private static List<String> readLines(Path p) throws IOException {
|
||||
List<String> out = new ArrayList<>();
|
||||
for (String l : Files.readAllLines(p, StandardCharsets.UTF_8)) if (!l.isBlank()) out.add(l);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static int lastNewline(byte[] data) {
|
||||
for (int i = data.length - 1; i >= 0; i--) if (data[i] == '\n') return i;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonParser;
|
||||
import de.hahmann.atmstats.core.Events.AtmEvent;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.Queue;
|
||||
import java.util.Random;
|
||||
import java.util.function.LongSupplier;
|
||||
import java.util.zip.GZIPOutputStream;
|
||||
|
||||
/**
|
||||
* Moves events from the in-memory queue into the durable journal and sends one journal segment per call.
|
||||
* Runs on the mod's worker thread only. Returns how long the caller should wait before the next call.
|
||||
*/
|
||||
public final class Shipper {
|
||||
public static final long IDLE_MS = 2000;
|
||||
|
||||
private final Queue<AtmEvent> queue;
|
||||
private final OutboxJournal journal;
|
||||
private final IngestClient client;
|
||||
private final byte[] secret;
|
||||
private final Backoff backoff;
|
||||
private final LongSupplier clock;
|
||||
private final LogSink log;
|
||||
private final Random random = new Random();
|
||||
private int attempt;
|
||||
private boolean clockSkewLogged;
|
||||
|
||||
public Shipper(Queue<AtmEvent> queue, OutboxJournal journal, IngestClient client, byte[] secret, Backoff backoff,
|
||||
LongSupplier clock, LogSink log) {
|
||||
this.queue = queue;
|
||||
this.journal = journal;
|
||||
this.client = client;
|
||||
this.secret = secret;
|
||||
this.backoff = backoff;
|
||||
this.clock = clock;
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
/** Persists everything still queued; used on shutdown and before each send. On failure the events stay queued. */
|
||||
public void flushToJournal() throws IOException {
|
||||
List<AtmEvent> events = new ArrayList<>();
|
||||
AtmEvent e;
|
||||
while ((e = queue.poll()) != null) events.add(e);
|
||||
if (events.isEmpty()) return;
|
||||
List<String> lines = new ArrayList<>(events.size());
|
||||
for (AtmEvent ev : events) lines.add(EventJson.toJson(ev));
|
||||
int dropped;
|
||||
try {
|
||||
dropped = journal.append(lines);
|
||||
} catch (IOException | RuntimeException ex) {
|
||||
queue.addAll(events);
|
||||
throw ex;
|
||||
}
|
||||
if (dropped > 0) log.warn("atmstats: outbox limit reached, dropped " + dropped + " old entries (stats snapshots first)");
|
||||
}
|
||||
|
||||
public long runOnce() throws IOException {
|
||||
flushToJournal();
|
||||
var next = journal.nextSendable();
|
||||
if (next.isEmpty()) return IDLE_MS;
|
||||
OutboxJournal.Segment segment = next.get();
|
||||
|
||||
byte[] body = gzip("{\"protocol\":1,\"events\":[" + String.join(",", segment.lines()) + "]}");
|
||||
long ts = clock.getAsLong();
|
||||
IngestClient.Response res;
|
||||
try {
|
||||
res = client.send(body, ts, Signer.sign(secret, ts, body));
|
||||
} catch (IOException e) {
|
||||
return retry("backend not reachable: " + e);
|
||||
}
|
||||
|
||||
int status = res.status();
|
||||
if (status == 200) {
|
||||
// Delete the segment only when the backend accounted for every event in it.
|
||||
Set<String> confirmed = new HashSet<>(res.accepted());
|
||||
confirmed.addAll(res.duplicate());
|
||||
confirmed.addAll(res.rejected().keySet());
|
||||
List<String> rejectedLines = new ArrayList<>();
|
||||
for (String line : segment.lines()) {
|
||||
String id = idOf(line);
|
||||
if (id == null || !confirmed.contains(id)) return retry("backend did not confirm all events of the batch (unexpected response)");
|
||||
if (res.rejected().containsKey(id)) rejectedLines.add(line);
|
||||
}
|
||||
if (!rejectedLines.isEmpty()) {
|
||||
journal.rejectLines(rejectedLines, res.rejected().toString());
|
||||
log.warn("atmstats: backend rejected " + rejectedLines.size() + " invalid event(s), kept in outbox/rejected: " + res.rejected());
|
||||
}
|
||||
journal.ack(segment);
|
||||
attempt = 0;
|
||||
clockSkewLogged = false;
|
||||
return 0;
|
||||
}
|
||||
if (status == 401 && "timestamp_out_of_range".equals(res.error())) {
|
||||
if (!clockSkewLogged) {
|
||||
log.warn("atmstats: clock skew: server clock differs from backend by more than 5 minutes; check the system time");
|
||||
clockSkewLogged = true;
|
||||
}
|
||||
return retry(null);
|
||||
}
|
||||
if (status == 401 || status == 403) {
|
||||
return retry("authentication failed (" + res.error() + "): check token and hmacSecret in config/atmstats-server.toml");
|
||||
}
|
||||
if (status == 408 || status == 429 || status >= 500) {
|
||||
return retry("backend answered HTTP " + status);
|
||||
}
|
||||
// Other 4xx: the batch itself is unacceptable; retrying would block the outbox forever.
|
||||
journal.reject(segment, "HTTP " + status + " " + res.error());
|
||||
log.warn("atmstats: batch rejected by backend (HTTP " + status + ", " + res.error() + "), moved to outbox/rejected");
|
||||
attempt = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
private long retry(String reason) {
|
||||
long delay = backoff.delayMs(attempt++, random);
|
||||
if (reason != null && (attempt == 1 || attempt % 10 == 0)) {
|
||||
log.warn("atmstats: " + reason + " – retrying in " + delay / 1000 + " s (attempt " + attempt + ")");
|
||||
}
|
||||
return delay;
|
||||
}
|
||||
|
||||
private static String idOf(String line) {
|
||||
try {
|
||||
JsonElement id = JsonParser.parseString(line).getAsJsonObject().get("id");
|
||||
return id == null || id.isJsonNull() ? null : id.getAsString();
|
||||
} catch (RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] gzip(String s) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
try (GZIPOutputStream gz = new GZIPOutputStream(out)) {
|
||||
gz.write(s.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/** Request signature: hex(HMAC-SHA256(secret, timestamp + "." + hex(sha256(body)))), identical to the backend. */
|
||||
public final class Signer {
|
||||
private Signer() {}
|
||||
|
||||
public static String sign(byte[] secret, long timestampMs, byte[] body) {
|
||||
try {
|
||||
String bodyHash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(body));
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
|
||||
byte[] sig = mac.doFinal((timestampMs + "." + bodyHash).getBytes(StandardCharsets.UTF_8));
|
||||
return HexFormat.of().formatHex(sig);
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("HmacSHA256 unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static String sha256Hex(byte[] data) {
|
||||
try {
|
||||
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(data));
|
||||
} catch (GeneralSecurityException e) {
|
||||
throw new IllegalStateException("SHA-256 unavailable", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Tracks the last counters sent per player. The first snapshot after start (or after {@link #forget}) is full;
|
||||
* later ones contain only changed counters. Values are always absolute. Worker thread only.
|
||||
*/
|
||||
public final class StatsDiff {
|
||||
public record Result(boolean full, Map<String, Map<String, Long>> changed) {}
|
||||
|
||||
private final Map<String, Map<String, Map<String, Long>>> lastSent = new HashMap<>();
|
||||
|
||||
public Result apply(String player, Map<String, Map<String, Long>> current) {
|
||||
Map<String, Map<String, Long>> prev = lastSent.get(player);
|
||||
Map<String, Map<String, Long>> copy = new HashMap<>();
|
||||
current.forEach((cat, m) -> copy.put(cat, new HashMap<>(m)));
|
||||
lastSent.put(player, copy);
|
||||
if (prev == null) return new Result(true, current);
|
||||
|
||||
Map<String, Map<String, Long>> changed = new LinkedHashMap<>();
|
||||
current.forEach((cat, entries) -> {
|
||||
Map<String, Long> before = prev.getOrDefault(cat, Map.of());
|
||||
entries.forEach((key, value) -> {
|
||||
if (!value.equals(before.get(key))) changed.computeIfAbsent(cat, c -> new LinkedHashMap<>()).put(key, value);
|
||||
});
|
||||
});
|
||||
return new Result(false, changed);
|
||||
}
|
||||
|
||||
public void forget(String player) {
|
||||
lastSent.remove(player);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Which FTB files are forwarded as file_sync (paths relative to the server, "/"-separated). Mirrors the backend's
|
||||
* isQuestConfigFile: chapters and the English texts split by ftbquestslangsplitter; other languages (~35 MB),
|
||||
* reward tables and the combined lang/en_us.snbt are not sent.
|
||||
*/
|
||||
public final class SyncPaths {
|
||||
private static final Pattern WANTED = Pattern.compile(
|
||||
"world/ftbquests/[0-9a-f-]{32,36}\\.snbt"
|
||||
+ "|world/ftbteams/(party|player)/[0-9a-f-]{32,36}\\.snbt"
|
||||
+ "|config/ftbquests/quests/chapters/[^/]+\\.snbt"
|
||||
+ "|config/ftbquests/quests/lang/en_us/.+\\.snbt_merged",
|
||||
Pattern.CASE_INSENSITIVE);
|
||||
|
||||
private SyncPaths() {}
|
||||
|
||||
public static boolean isWanted(String relativePath) {
|
||||
return WANTED.matcher(relativePath).matches();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Time-ordered UUID version 7 (RFC 9562); monotonic within this process via a 12-bit counter per millisecond. */
|
||||
public final class UuidV7 {
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static long lastMs = -1;
|
||||
private static int counter;
|
||||
|
||||
private UuidV7() {}
|
||||
|
||||
public static synchronized UUID next() {
|
||||
long ms = System.currentTimeMillis();
|
||||
if (ms <= lastMs) {
|
||||
ms = lastMs;
|
||||
if (++counter > 0xFFF) {
|
||||
ms++;
|
||||
counter = 0;
|
||||
}
|
||||
} else {
|
||||
counter = RANDOM.nextInt(0x800); // leave headroom for increments
|
||||
}
|
||||
lastMs = ms;
|
||||
long msb = (ms << 16) | (0x7L << 12) | counter;
|
||||
long lsb = (RANDOM.nextLong() & 0x3FFFFFFFFFFFFFFFL) | 0x8000000000000000L;
|
||||
return new UUID(msb, lsb);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package de.hahmann.atmstats.mc;
|
||||
|
||||
import net.neoforged.neoforge.common.ModConfigSpec;
|
||||
|
||||
/** config/atmstats-server.toml */
|
||||
public final class AtmConfig {
|
||||
public static final ModConfigSpec SPEC;
|
||||
public static final ModConfigSpec.BooleanValue ENABLED;
|
||||
public static final ModConfigSpec.ConfigValue<String> ENDPOINT;
|
||||
public static final ModConfigSpec.ConfigValue<String> SERVER_ID;
|
||||
public static final ModConfigSpec.ConfigValue<String> TOKEN;
|
||||
public static final ModConfigSpec.ConfigValue<String> HMAC_SECRET;
|
||||
public static final ModConfigSpec.IntValue STATS_SNAPSHOT_MINUTES;
|
||||
public static final ModConfigSpec.IntValue QUEST_SCAN_MINUTES;
|
||||
public static final ModConfigSpec.IntValue HEALTH_INTERVAL_SECONDS;
|
||||
public static final ModConfigSpec.IntValue MAX_OUTBOX_MB;
|
||||
|
||||
static {
|
||||
ModConfigSpec.Builder b = new ModConfigSpec.Builder();
|
||||
b.comment("ATM Stats: sendet Statistiken an das Statistikportal").push("atmstats");
|
||||
ENABLED = b.comment("Erfassung aktivieren").define("enabled", true);
|
||||
ENDPOINT = b.comment("Basis-URL des Portals, z. B. https://stats.example.de").define("endpoint", "");
|
||||
SERVER_ID = b.comment("Kennung dieses Servers").define("serverId", "atm10");
|
||||
TOKEN = b.comment("Ingest-Token (Backend: npm run cli -- token:create)").define("token", "");
|
||||
HMAC_SECRET = b.comment("Gemeinsames Geheimnis für die Signatur (INGEST_HMAC_SECRET im Backend)").define("hmacSecret", "");
|
||||
STATS_SNAPSHOT_MINUTES = b.comment("Intervall für Statistik-Snapshots online Spieler").defineInRange("statsSnapshotMinutes", 5, 1, 60);
|
||||
QUEST_SCAN_MINUTES = b.comment("Intervall für die Prüfung der FTB-Quest-Dateien").defineInRange("questScanMinutes", 2, 1, 60);
|
||||
HEALTH_INTERVAL_SECONDS = b.comment("Intervall für TPS/MSPT-Meldungen").defineInRange("healthIntervalSeconds", 60, 10, 600);
|
||||
MAX_OUTBOX_MB = b.comment("Maximale Größe der Warteschlange auf der Festplatte").defineInRange("maxOutboxMb", 50, 1, 1000);
|
||||
b.pop();
|
||||
SPEC = b.build();
|
||||
}
|
||||
|
||||
private AtmConfig() {}
|
||||
|
||||
static boolean isConfigured() {
|
||||
return ENABLED.get() && !ENDPOINT.get().isBlank() && !TOKEN.get().isBlank() && !HMAC_SECRET.get().isBlank();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package de.hahmann.atmstats.mc;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import net.neoforged.fml.ModContainer;
|
||||
import net.neoforged.fml.common.Mod;
|
||||
import net.neoforged.fml.config.ModConfig;
|
||||
import net.neoforged.fml.loading.FMLEnvironment;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
|
||||
/** Server-only statistics collector for the ATM10 stats portal. Registers nothing in the game registries. */
|
||||
@Mod(AtmStatsMod.MOD_ID)
|
||||
public final class AtmStatsMod {
|
||||
public static final String MOD_ID = "atmstats";
|
||||
|
||||
public AtmStatsMod(ModContainer container) {
|
||||
container.registerConfig(ModConfig.Type.COMMON, AtmConfig.SPEC, "atmstats-server.toml");
|
||||
if (FMLEnvironment.dist.isDedicatedServer()) {
|
||||
NeoForge.EVENT_BUS.register(new GameHooks());
|
||||
} else {
|
||||
LogUtils.getLogger().info("atmstats: client/integrated server detected, collector disabled");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package de.hahmann.atmstats.mc;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import de.hahmann.atmstats.core.Backoff;
|
||||
import de.hahmann.atmstats.core.Events;
|
||||
import de.hahmann.atmstats.core.HttpIngestClient;
|
||||
import de.hahmann.atmstats.core.LogSink;
|
||||
import de.hahmann.atmstats.core.OutboxJournal;
|
||||
import de.hahmann.atmstats.core.Shipper;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.Queue;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.world.level.storage.LevelResource;
|
||||
import net.neoforged.fml.loading.FMLPaths;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/** Wiring of one server run: queue → worker thread (stats conversion, file scan, journal, HTTPS). */
|
||||
final class CollectorRuntime {
|
||||
private static final Logger LOG = LogUtils.getLogger();
|
||||
static final LogSink LOG_SINK = new LogSink() {
|
||||
public void info(String msg) { LOG.info(msg); }
|
||||
public void warn(String msg) { LOG.warn(msg); }
|
||||
};
|
||||
|
||||
private final Queue<Events.AtmEvent> queue = new ConcurrentLinkedQueue<>();
|
||||
private final StatsCollector stats;
|
||||
private final HealthMonitor health;
|
||||
private final WorkerThread worker;
|
||||
|
||||
private CollectorRuntime(MinecraftServer server) throws IOException {
|
||||
Path serverDir = server.getServerDirectory().toAbsolutePath().normalize();
|
||||
Path worldDir = server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize();
|
||||
byte[] secret = AtmConfig.HMAC_SECRET.get().getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
OutboxJournal journal = new OutboxJournal(serverDir.resolve("atmstats").resolve("outbox"),
|
||||
AtmConfig.MAX_OUTBOX_MB.get() * 1024L * 1024L, Duration.ofDays(7));
|
||||
HttpIngestClient client = new HttpIngestClient(URI.create(AtmConfig.ENDPOINT.get().replaceAll("/+$", "")), AtmConfig.TOKEN.get(),
|
||||
AtmConfig.SERVER_ID.get(), secret, Duration.ofSeconds(10));
|
||||
Shipper shipper = new Shipper(queue, journal, client, secret, new Backoff(1000, 300_000), System::currentTimeMillis, LOG_SINK);
|
||||
|
||||
stats = new StatsCollector(AtmConfig.STATS_SNAPSHOT_MINUTES.get() * 60_000L, queue::add);
|
||||
health = new HealthMonitor(AtmConfig.HEALTH_INTERVAL_SECONDS.get() * 1000L, queue::add);
|
||||
FileWatcher files = new FileWatcher(worldDir, FMLPaths.CONFIGDIR.get(), queue::add, LOG_SINK);
|
||||
worker = new WorkerThread(shipper, client, stats, files, AtmConfig.QUEST_SCAN_MINUTES.get() * 60_000L, LOG_SINK);
|
||||
}
|
||||
|
||||
static CollectorRuntime start(MinecraftServer server) throws IOException {
|
||||
CollectorRuntime rt = new CollectorRuntime(server);
|
||||
rt.worker.start();
|
||||
LOG.info("atmstats: collector started, sending to {}", AtmConfig.ENDPOINT.get());
|
||||
return rt;
|
||||
}
|
||||
|
||||
void enqueue(Events.AtmEvent event) {
|
||||
queue.add(event);
|
||||
}
|
||||
|
||||
StatsCollector stats() {
|
||||
return stats;
|
||||
}
|
||||
|
||||
HealthMonitor health() {
|
||||
return health;
|
||||
}
|
||||
|
||||
/** Stops the worker; everything still queued is written to the on-disk outbox (max ~3 s). */
|
||||
void stop() {
|
||||
worker.shutdown(Duration.ofSeconds(3));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package de.hahmann.atmstats.mc;
|
||||
|
||||
import de.hahmann.atmstats.core.Events;
|
||||
import de.hahmann.atmstats.core.FileChangeTracker;
|
||||
import de.hahmann.atmstats.core.LogSink;
|
||||
import de.hahmann.atmstats.core.Signer;
|
||||
import de.hahmann.atmstats.core.SyncPaths;
|
||||
import de.hahmann.atmstats.core.UuidV7;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Forwards changed FTB Quests/Teams files (selected by SyncPaths) verbatim as file_sync events (parsed by the backend). Worker thread only.
|
||||
* Paths are reported as "world/…" and "config/…" independent of the actual level name.
|
||||
*/
|
||||
final class FileWatcher {
|
||||
static final long MAX_FILE_BYTES = 1_500_000;
|
||||
|
||||
private record Root(Path dir, String prefix, boolean recursive) {}
|
||||
|
||||
private final List<Root> roots;
|
||||
private final FileChangeTracker tracker = new FileChangeTracker();
|
||||
private final Consumer<Events.AtmEvent> sink;
|
||||
private final LogSink log;
|
||||
|
||||
FileWatcher(Path worldDir, Path configDir, Consumer<Events.AtmEvent> sink, LogSink log) {
|
||||
Path quests = configDir.resolve("ftbquests").resolve("quests");
|
||||
// Only the directories that can contain wanted files are walked (lang/ alone holds ~35 MB).
|
||||
this.roots = List.of(
|
||||
new Root(worldDir.resolve("ftbquests"), "world/ftbquests", false),
|
||||
new Root(worldDir.resolve("ftbteams"), "world/ftbteams", true),
|
||||
new Root(quests.resolve("chapters"), "config/ftbquests/quests/chapters", false),
|
||||
new Root(quests.resolve("lang").resolve("en_us"), "config/ftbquests/quests/lang/en_us", true));
|
||||
this.sink = sink;
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
void scan() {
|
||||
List<String> seen = new ArrayList<>();
|
||||
for (Root root : roots) {
|
||||
if (!Files.isDirectory(root.dir())) continue;
|
||||
try (Stream<Path> files = root.recursive() ? Files.walk(root.dir()) : Files.list(root.dir())) {
|
||||
for (Path file : (Iterable<Path>) files.filter(Files::isRegularFile)::iterator) {
|
||||
String rel = root.prefix() + "/" + root.dir().relativize(file).toString().replace('\\', '/');
|
||||
if (!SyncPaths.isWanted(rel)) continue;
|
||||
seen.add(rel);
|
||||
check(file, rel);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.warn("atmstats: cannot scan " + root.dir() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
tracker.retainOnly(seen);
|
||||
}
|
||||
|
||||
private void check(Path file, String rel) {
|
||||
try {
|
||||
BasicFileAttributes attrs = Files.readAttributes(file, BasicFileAttributes.class);
|
||||
long size = attrs.size();
|
||||
long mtime = attrs.lastModifiedTime().toMillis();
|
||||
if (!tracker.needsRead(rel, size, mtime)) return;
|
||||
if (size > MAX_FILE_BYTES) {
|
||||
log.warn("atmstats: skipping " + rel + " (" + size + " bytes, limit " + MAX_FILE_BYTES + ")");
|
||||
tracker.commit(rel, size, mtime, "skipped");
|
||||
return;
|
||||
}
|
||||
byte[] bytes = Files.readAllBytes(file);
|
||||
String sha = Signer.sha256Hex(bytes);
|
||||
if (tracker.commit(rel, size, mtime, sha)) {
|
||||
sink.accept(new Events.FileSync(UuidV7.next(), mtime, rel, sha, new String(bytes, StandardCharsets.UTF_8)));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// FTB may be writing the file right now; try again on the next scan.
|
||||
log.warn("atmstats: cannot read " + rel + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package de.hahmann.atmstats.mc;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import de.hahmann.atmstats.core.Events;
|
||||
import de.hahmann.atmstats.core.UuidV7;
|
||||
import net.minecraft.advancements.AdvancementHolder;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.world.damagesource.DamageSource;
|
||||
import net.minecraft.world.entity.Entity;
|
||||
import net.minecraft.world.entity.LivingEntity;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.neoforged.bus.api.EventPriority;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.neoforge.common.Tags;
|
||||
import net.neoforged.neoforge.common.util.FakePlayer;
|
||||
import net.neoforged.neoforge.event.entity.living.LivingDeathEvent;
|
||||
import net.neoforged.neoforge.event.entity.player.AdvancementEvent;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||
import net.neoforged.neoforge.event.server.ServerStartedEvent;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* Game event handlers. Everything here runs on the server thread and only creates small immutable event
|
||||
* records and puts them into a lock-free queue; I/O and serialization happen on the worker thread.
|
||||
*/
|
||||
public final class GameHooks {
|
||||
private static final Logger LOG = LogUtils.getLogger();
|
||||
private CollectorRuntime runtime;
|
||||
|
||||
@SubscribeEvent
|
||||
public void onServerStarted(ServerStartedEvent event) {
|
||||
if (!AtmConfig.isConfigured()) {
|
||||
LOG.info("atmstats: not configured (endpoint/token/hmacSecret in config/atmstats-server.toml) – collector passive");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
runtime = CollectorRuntime.start(event.getServer());
|
||||
runtime.enqueue(ServerInfoCollector.serverStart(event.getServer()));
|
||||
} catch (Exception e) {
|
||||
LOG.error("atmstats: failed to start collector", e);
|
||||
runtime = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Lowest priority: other mods' shutdown work runs first; we only flush.
|
||||
@SubscribeEvent(priority = EventPriority.LOWEST)
|
||||
public void onServerStopping(ServerStoppingEvent event) {
|
||||
if (runtime == null) return;
|
||||
long now = System.currentTimeMillis();
|
||||
for (ServerPlayer p : event.getServer().getPlayerList().getPlayers()) {
|
||||
runtime.stats().captureNow(p, now);
|
||||
runtime.enqueue(new Events.PlayerLeave(UuidV7.next(), now, p.getStringUUID(), name(p)));
|
||||
}
|
||||
runtime.enqueue(new Events.ServerStop(UuidV7.next(), now));
|
||||
runtime.stop();
|
||||
runtime = null;
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onLogin(PlayerEvent.PlayerLoggedInEvent event) {
|
||||
if (runtime != null && event.getEntity() instanceof ServerPlayer p && !(p instanceof FakePlayer)) {
|
||||
runtime.enqueue(new Events.PlayerJoin(UuidV7.next(), System.currentTimeMillis(), p.getStringUUID(), name(p)));
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onLogout(PlayerEvent.PlayerLoggedOutEvent event) {
|
||||
if (runtime != null && event.getEntity() instanceof ServerPlayer p && !(p instanceof FakePlayer)) {
|
||||
long now = System.currentTimeMillis();
|
||||
runtime.stats().captureNow(p, now);
|
||||
runtime.enqueue(new Events.PlayerLeave(UuidV7.next(), now, p.getStringUUID(), name(p)));
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onDimensionChange(PlayerEvent.PlayerChangedDimensionEvent event) {
|
||||
if (runtime != null && event.getEntity() instanceof ServerPlayer p && !(p instanceof FakePlayer)) {
|
||||
runtime.enqueue(new Events.DimensionChange(UuidV7.next(), System.currentTimeMillis(), p.getStringUUID(), name(p),
|
||||
dim(event.getFrom()), dim(event.getTo())));
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onAdvancement(AdvancementEvent.AdvancementEarnEvent event) {
|
||||
if (runtime == null || !(event.getEntity() instanceof ServerPlayer p) || p instanceof FakePlayer) return;
|
||||
AdvancementHolder holder = event.getAdvancement();
|
||||
// Only advancements with a display (excludes recipe unlocks and hidden technical ones).
|
||||
holder.value().display().ifPresent(display -> runtime.enqueue(new Events.Advancement(UuidV7.next(), System.currentTimeMillis(),
|
||||
p.getStringUUID(), name(p), holder.id().toString(), display.getTitle().getString())));
|
||||
}
|
||||
|
||||
// Lowest priority and only when not canceled: totem/revival mods may cancel the death.
|
||||
@SubscribeEvent(priority = EventPriority.LOWEST)
|
||||
public void onDeath(LivingDeathEvent event) {
|
||||
if (runtime == null || event.isCanceled()) return;
|
||||
LivingEntity victim = event.getEntity();
|
||||
DamageSource source = event.getSource();
|
||||
long now = System.currentTimeMillis();
|
||||
Entity attacker = source.getEntity();
|
||||
|
||||
if (victim instanceof ServerPlayer p && !(p instanceof FakePlayer)) {
|
||||
String killerType = attacker != null ? BuiltInRegistries.ENTITY_TYPE.getKey(attacker.getType()).toString() : null;
|
||||
String killerName = attacker instanceof Player kp ? kp.getGameProfile().getName()
|
||||
: attacker != null && attacker.hasCustomName() ? attacker.getCustomName().getString() : null;
|
||||
String cause = source.typeHolder().unwrapKey().map(k -> k.location().toString()).orElse(source.getMsgId());
|
||||
runtime.enqueue(new Events.PlayerDeath(UuidV7.next(), now, p.getStringUUID(), name(p), dim(p.level().dimension()), cause,
|
||||
killerType, killerName));
|
||||
}
|
||||
|
||||
// Individual kills only for bosses and players; regular mob kills come from the stats counters.
|
||||
if (attacker instanceof ServerPlayer killer && !(killer instanceof FakePlayer) && attacker != victim) {
|
||||
boolean isBoss = victim.getType().is(Tags.EntityTypes.BOSSES);
|
||||
if (isBoss || victim instanceof Player) {
|
||||
ItemStack weapon = killer.getMainHandItem();
|
||||
runtime.enqueue(new Events.EntityKill(UuidV7.next(), now, killer.getStringUUID(), name(killer), dim(killer.level().dimension()),
|
||||
BuiltInRegistries.ENTITY_TYPE.getKey(victim.getType()).toString(), isBoss,
|
||||
weapon.isEmpty() ? null : BuiltInRegistries.ITEM.getKey(weapon.getItem()).toString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onTickPre(ServerTickEvent.Pre event) {
|
||||
if (runtime != null) runtime.health().tickStart();
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onTickPost(ServerTickEvent.Post event) {
|
||||
if (runtime == null) return;
|
||||
runtime.health().tickEnd(event.getServer());
|
||||
runtime.stats().tick(event.getServer());
|
||||
}
|
||||
|
||||
static String name(Player p) {
|
||||
return p.getGameProfile().getName();
|
||||
}
|
||||
|
||||
static String dim(ResourceKey<Level> key) {
|
||||
return key.location().toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package de.hahmann.atmstats.mc;
|
||||
|
||||
import de.hahmann.atmstats.core.Events;
|
||||
import de.hahmann.atmstats.core.HealthWindow;
|
||||
import de.hahmann.atmstats.core.UuidV7;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
|
||||
/** Measures tick durations itself (no dependency on spark) and reports one health event per interval. */
|
||||
final class HealthMonitor {
|
||||
private final HealthWindow window = new HealthWindow(2400);
|
||||
private final long intervalMs;
|
||||
private final Consumer<Events.AtmEvent> sink;
|
||||
private long tickStartNanos;
|
||||
private long windowStartMs = System.currentTimeMillis();
|
||||
|
||||
HealthMonitor(long intervalMs, Consumer<Events.AtmEvent> sink) {
|
||||
this.intervalMs = intervalMs;
|
||||
this.sink = sink;
|
||||
}
|
||||
|
||||
void tickStart() {
|
||||
tickStartNanos = System.nanoTime();
|
||||
}
|
||||
|
||||
void tickEnd(MinecraftServer server) {
|
||||
if (tickStartNanos == 0) return;
|
||||
window.record(System.nanoTime() - tickStartNanos);
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - windowStartMs < intervalMs) return;
|
||||
|
||||
HealthWindow.Stats s = window.drain();
|
||||
int chunks = 0;
|
||||
for (ServerLevel level : server.getAllLevels()) chunks += level.getChunkSource().getLoadedChunksCount();
|
||||
java.lang.Runtime rt = java.lang.Runtime.getRuntime();
|
||||
double usedMb = (rt.totalMemory() - rt.freeMemory()) / 1048576.0;
|
||||
double maxMb = rt.maxMemory() / 1048576.0;
|
||||
sink.accept(new Events.Health(UuidV7.next(), now, windowStartMs, now, round(s.msptAvg()), round(s.msptP95()), round(s.msptMax()),
|
||||
round(s.tps()), server.getPlayerList().getPlayerCount(), chunks, Math.round(usedMb), Math.round(maxMb)));
|
||||
windowStartMs = now;
|
||||
}
|
||||
|
||||
private static double round(double v) {
|
||||
return Math.round(v * 100) / 100.0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package de.hahmann.atmstats.mc;
|
||||
|
||||
import de.hahmann.atmstats.core.Events;
|
||||
import de.hahmann.atmstats.core.UuidV7;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.neoforged.fml.ModList;
|
||||
|
||||
/** Exact versions and the installed mod list, read from the running server instead of guessed. */
|
||||
final class ServerInfoCollector {
|
||||
private ServerInfoCollector() {}
|
||||
|
||||
static Events.ServerStart serverStart(MinecraftServer server) {
|
||||
ModList mods = ModList.get();
|
||||
String neoforge = mods.getModContainerById("neoforge").map(c -> c.getModInfo().getVersion().toString()).orElse("unknown");
|
||||
List<Events.ModInfo> list = mods.getMods().stream()
|
||||
.map(m -> new Events.ModInfo(m.getModId(), m.getVersion().toString(), m.getDisplayName()))
|
||||
.sorted(Comparator.comparing(Events.ModInfo::id))
|
||||
.toList();
|
||||
return new Events.ServerStart(UuidV7.next(), System.currentTimeMillis(), SharedConstants.getCurrentVersion().getName(), neoforge,
|
||||
System.getProperty("java.version"), list);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package de.hahmann.atmstats.mc;
|
||||
|
||||
import de.hahmann.atmstats.core.Events;
|
||||
import de.hahmann.atmstats.core.StatsDiff;
|
||||
import de.hahmann.atmstats.core.UuidV7;
|
||||
import it.unimi.dsi.fastutil.objects.Object2IntMap;
|
||||
import it.unimi.dsi.fastutil.objects.Object2IntOpenHashMap;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Deque;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Queue;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.function.Consumer;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.stats.Stat;
|
||||
import net.neoforged.neoforge.common.util.FakePlayer;
|
||||
|
||||
/**
|
||||
* Periodically copies the live counters of online players. The server thread only copies the counter map
|
||||
* (spread over ticks, at most {@value #PLAYERS_PER_TICK} players per tick); key conversion and diffing run on the
|
||||
* worker thread via {@link #drain()}.
|
||||
*/
|
||||
final class StatsCollector {
|
||||
static final int PLAYERS_PER_TICK = 2;
|
||||
|
||||
private record Raw(String uuid, String name, long ts, Object2IntMap<Stat<?>> counters) {}
|
||||
|
||||
private final long intervalMs;
|
||||
private final Queue<Raw> raw = new ConcurrentLinkedQueue<>();
|
||||
private final Deque<UUID> pending = new ArrayDeque<>();
|
||||
private final StatsDiff diff = new StatsDiff(); // worker thread only
|
||||
private final Consumer<Events.AtmEvent> sink;
|
||||
private long nextRunMs;
|
||||
|
||||
StatsCollector(long intervalMs, Consumer<Events.AtmEvent> sink) {
|
||||
this.intervalMs = intervalMs;
|
||||
this.sink = sink;
|
||||
this.nextRunMs = System.currentTimeMillis() + 30_000; // first snapshot shortly after start
|
||||
}
|
||||
|
||||
/** Server thread. */
|
||||
void tick(MinecraftServer server) {
|
||||
long now = System.currentTimeMillis();
|
||||
if (pending.isEmpty() && now >= nextRunMs) {
|
||||
nextRunMs = now + intervalMs;
|
||||
for (ServerPlayer p : server.getPlayerList().getPlayers()) if (!(p instanceof FakePlayer)) pending.add(p.getUUID());
|
||||
}
|
||||
for (int i = 0; i < PLAYERS_PER_TICK && !pending.isEmpty(); i++) {
|
||||
ServerPlayer p = server.getPlayerList().getPlayer(pending.poll());
|
||||
if (p != null) captureNow(p, now);
|
||||
}
|
||||
}
|
||||
|
||||
/** Server thread: copies the player's counters (a few hundred to a few thousand entries). */
|
||||
void captureNow(ServerPlayer p, long now) {
|
||||
raw.add(new Raw(p.getStringUUID(), GameHooks.name(p), now, new Object2IntOpenHashMap<>(p.getStats().stats)));
|
||||
}
|
||||
|
||||
/** Worker thread: converts captured counters to absolute, changed-only snapshots. */
|
||||
void drain() {
|
||||
Raw r;
|
||||
while ((r = raw.poll()) != null) {
|
||||
Map<String, Map<String, Long>> stats = new LinkedHashMap<>();
|
||||
for (Object2IntMap.Entry<Stat<?>> e : r.counters().object2IntEntrySet()) {
|
||||
ResourceLocation category = BuiltInRegistries.STAT_TYPE.getKey(e.getKey().getType());
|
||||
ResourceLocation key = keyOf(e.getKey());
|
||||
if (category == null || key == null || e.getIntValue() < 0) continue;
|
||||
stats.computeIfAbsent(category.toString(), c -> new LinkedHashMap<>()).put(key.toString(), (long) e.getIntValue());
|
||||
}
|
||||
StatsDiff.Result result = diff.apply(r.uuid(), stats);
|
||||
if (result.full() || !result.changed().isEmpty()) {
|
||||
sink.accept(new Events.StatsSnapshot(UuidV7.next(), r.ts(), r.uuid(), r.name(), result.full(), result.changed()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static <T> ResourceLocation keyOf(Stat<T> stat) {
|
||||
return stat.getType().getRegistry().getKey(stat.getValue());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package de.hahmann.atmstats.mc;
|
||||
|
||||
import de.hahmann.atmstats.core.HttpIngestClient;
|
||||
import de.hahmann.atmstats.core.LogSink;
|
||||
import de.hahmann.atmstats.core.Shipper;
|
||||
import java.time.Duration;
|
||||
|
||||
/** The mod's single background thread: all serialization, file and network I/O happens here. */
|
||||
final class WorkerThread extends Thread {
|
||||
private static final long IDLE_TICK_MS = 1000;
|
||||
private final Shipper shipper;
|
||||
private final HttpIngestClient client;
|
||||
private final StatsCollector stats;
|
||||
private final FileWatcher files;
|
||||
private final long fileScanMs;
|
||||
private final LogSink log;
|
||||
private volatile boolean running = true;
|
||||
|
||||
WorkerThread(Shipper shipper, HttpIngestClient client, StatsCollector stats, FileWatcher files, long fileScanMs, LogSink log) {
|
||||
super("atmstats-worker");
|
||||
setDaemon(true);
|
||||
setPriority(Thread.MIN_PRIORITY);
|
||||
this.shipper = shipper;
|
||||
this.client = client;
|
||||
this.stats = stats;
|
||||
this.files = files;
|
||||
this.fileScanMs = fileScanMs;
|
||||
this.log = log;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
// Self-test answers the question whether the host allows outbound HTTPS.
|
||||
String ping = client.ping(System.currentTimeMillis());
|
||||
if ("reachable".equals(ping)) log.info("atmstats: backend reachable");
|
||||
else log.warn("atmstats: backend NOT reachable: " + ping + " – events are kept in atmstats/outbox and retried");
|
||||
|
||||
long nextScan = 0;
|
||||
long nextSend = 0;
|
||||
while (running) {
|
||||
try {
|
||||
stats.drain();
|
||||
long now = System.currentTimeMillis();
|
||||
if (now >= nextScan) {
|
||||
files.scan();
|
||||
nextScan = now + fileScanMs;
|
||||
}
|
||||
if (now >= nextSend) {
|
||||
long delay = shipper.runOnce(); // also persists the queue
|
||||
nextSend = System.currentTimeMillis() + delay;
|
||||
if (delay == 0) continue; // backlog: send the next segment right away
|
||||
} else {
|
||||
// During backoff, still move new events to disk every second so a crash cannot lose them.
|
||||
shipper.flushToJournal();
|
||||
}
|
||||
Thread.sleep(IDLE_TICK_MS);
|
||||
} catch (InterruptedException e) {
|
||||
// shutdown requested
|
||||
} catch (Exception e) {
|
||||
log.warn("atmstats: worker error: " + e);
|
||||
sleepQuietly(5000);
|
||||
}
|
||||
}
|
||||
// Clear a pending interrupt: interruptible file channels (fsync on rotation) would otherwise abort the flush.
|
||||
Thread.interrupted();
|
||||
try {
|
||||
stats.drain();
|
||||
shipper.flushToJournal();
|
||||
} catch (Exception e) {
|
||||
log.warn("atmstats: could not persist queued events on shutdown: " + e);
|
||||
}
|
||||
}
|
||||
|
||||
void shutdown(Duration timeout) {
|
||||
running = false;
|
||||
interrupt();
|
||||
try {
|
||||
join(timeout.toMillis());
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
if (isAlive()) log.warn("atmstats: worker did not stop in time; unsent events stay in memory and are lost");
|
||||
}
|
||||
|
||||
private static void sleepQuietly(long ms) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
} catch (InterruptedException ignored) {
|
||||
// shutdown
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Zugriff auf die Zählertabelle eines Spielers (Kopie alle 5 Minuten statt Iteration über alle Registry-Einträge)
|
||||
public net.minecraft.stats.StatsCounter stats
|
||||
@@ -0,0 +1,29 @@
|
||||
modLoader="javafml"
|
||||
loaderVersion="${loader_version_range}"
|
||||
license="${mod_license}"
|
||||
|
||||
[[mods]]
|
||||
modId="${mod_id}"
|
||||
version="${mod_version}"
|
||||
displayName="${mod_name}"
|
||||
authors="Lucas Hahmann"
|
||||
description='''Serverseitige Statistik-Erfassung für das ATM10-Statistikportal. Clients benötigen diese Mod nicht.'''
|
||||
# Rein serverseitig: Clients ohne die Mod dürfen verbinden.
|
||||
displayTest="IGNORE_ALL_VERSION"
|
||||
|
||||
[[dependencies.${mod_id}]]
|
||||
modId="neoforge"
|
||||
type="required"
|
||||
versionRange="${neo_version_range}"
|
||||
ordering="NONE"
|
||||
side="SERVER"
|
||||
|
||||
[[dependencies.${mod_id}]]
|
||||
modId="minecraft"
|
||||
type="required"
|
||||
versionRange="${minecraft_version_range}"
|
||||
ordering="NONE"
|
||||
side="SERVER"
|
||||
|
||||
[[accessTransformers]]
|
||||
file="META-INF/accesstransformer.cfg"
|
||||
@@ -0,0 +1,26 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Random;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class BackoffTest {
|
||||
private final Backoff backoff = new Backoff(1000, 300_000);
|
||||
|
||||
@Test
|
||||
void capsAtFiveMinutes() {
|
||||
Random rnd = new Random(1);
|
||||
for (int attempt : new int[] {12, 30, 63, 1000}) {
|
||||
long d = backoff.delayMs(attempt, rnd);
|
||||
assertTrue(d <= 300_000 && d >= 150_000, "attempt " + attempt + " -> " + d);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void growsExponentially() {
|
||||
Random rnd = new Random(2);
|
||||
assertTrue(backoff.delayMs(0, rnd) >= 500 && backoff.delayMs(0, rnd) <= 1000);
|
||||
assertTrue(backoff.delayMs(3, rnd) >= 4000 && backoff.delayMs(3, rnd) <= 8000);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonParser;
|
||||
import de.hahmann.atmstats.core.Events.*;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** The Java serialization must produce exactly the JSON of shared/fixtures/protocol (validated by the backend). */
|
||||
class EventJsonTest {
|
||||
private static final String P = "308a693c-2bd8-4d6b-8790-670aef5be601";
|
||||
private static final String NAME = "CDOGamer";
|
||||
|
||||
private static UUID id(String suffix) {
|
||||
return UUID.fromString("0199d6a0-0000-7000-8000-0000000000" + suffix);
|
||||
}
|
||||
|
||||
private static JsonElement fixture(String type) throws IOException {
|
||||
Path dir = Path.of(System.getProperty("atm.fixtures"));
|
||||
return JsonParser.parseString(Files.readString(dir.resolve(type + ".json"), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
private static void check(AtmEvent e) throws IOException {
|
||||
assertEquals(fixture(e.type()), JsonParser.parseString(EventJson.toJson(e)), e.type());
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchesSharedFixtures() throws IOException {
|
||||
check(new ServerStart(id("01"), 1760259000000L, "1.21.1", "21.1.219", "21.0.5",
|
||||
List.of(new ModInfo("mekanism", "10.7.19", "Mekanism"), new ModInfo("ftbquests", "2101.1.36", "FTB Quests"))));
|
||||
check(new ServerStop(id("02"), 1760300000000L));
|
||||
check(new PlayerJoin(id("03"), 1760259783006L, P, NAME));
|
||||
check(new PlayerLeave(id("04"), 1760270000000L, P, NAME));
|
||||
check(new PlayerDeath(id("05"), 1760261000000L, P, NAME, "minecraft:the_nether", "minecraft:mob_attack",
|
||||
"minecraft:wither_skeleton", null));
|
||||
check(new EntityKill(id("06"), 1760262000000L, P, NAME, "minecraft:the_end", "minecraft:ender_dragon", true,
|
||||
"mekanismtools:refined_obsidian_sword"));
|
||||
check(new Advancement(id("07"), 1760260131149L, P, NAME, "mysticalagriculture:infusion", "Infusing Efficiency"));
|
||||
check(new DimensionChange(id("08"), 1760271736798L, P, NAME, "minecraft:overworld", "minecraft:the_nether"));
|
||||
Map<String, Map<String, Long>> stats = new LinkedHashMap<>();
|
||||
stats.put("minecraft:custom", new LinkedHashMap<>(Map.of("minecraft:play_time", 1175736L, "minecraft:deaths", 8L)));
|
||||
stats.put("minecraft:mined", Map.of("mekanism:osmium_ore", 12L));
|
||||
check(new StatsSnapshot(id("09"), 1760263000000L, P, NAME, false, stats));
|
||||
check(new FileSync(id("0a"), 1760264000000L, "world/ftbquests/308a693c-2bd8-4d6b-8790-670aef5be601.snbt",
|
||||
"9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "{\n\tversion: 1\n}\n"));
|
||||
check(new Health(id("0b"), 1760265060000L, 1760265000000L, 1760265060000L, 38.5, 61.2, 210.0, 20.0, 3, 1840, 2200, 8192));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class FileChangeTrackerTest {
|
||||
@Test
|
||||
void detectsNewChangedAndUnchangedFiles() {
|
||||
FileChangeTracker t = new FileChangeTracker();
|
||||
|
||||
// new file: must be read and hashed, and is reported as changed
|
||||
assertTrue(t.needsRead("a.snbt", 10, 100));
|
||||
assertTrue(t.commit("a.snbt", 10, 100, "h1"));
|
||||
|
||||
// same size + mtime: not read again
|
||||
assertFalse(t.needsRead("a.snbt", 10, 100));
|
||||
|
||||
// touched (new mtime) but identical content: read, but not reported
|
||||
assertTrue(t.needsRead("a.snbt", 10, 200));
|
||||
assertFalse(t.commit("a.snbt", 10, 200, "h1"));
|
||||
assertFalse(t.needsRead("a.snbt", 10, 200));
|
||||
|
||||
// real change
|
||||
assertTrue(t.needsRead("a.snbt", 12, 300));
|
||||
assertTrue(t.commit("a.snbt", 12, 300, "h2"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forgetsDeletedFiles() {
|
||||
FileChangeTracker t = new FileChangeTracker();
|
||||
t.commit("a.snbt", 1, 1, "h");
|
||||
t.commit("b.snbt", 1, 1, "h");
|
||||
t.retainOnly(List.of("b.snbt"));
|
||||
assertTrue(t.needsRead("a.snbt", 1, 1));
|
||||
assertFalse(t.needsRead("b.snbt", 1, 1));
|
||||
assertEquals(1, t.size());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class HealthWindowTest {
|
||||
@Test
|
||||
void aggregatesTickDurations() {
|
||||
HealthWindow w = new HealthWindow(1000);
|
||||
for (int i = 1; i <= 100; i++) w.record(i * 1_000_000L); // 1..100 ms
|
||||
HealthWindow.Stats s = w.drain();
|
||||
assertEquals(50.5, s.msptAvg(), 1e-9);
|
||||
assertEquals(95.0, s.msptP95(), 1e-9);
|
||||
assertEquals(100.0, s.msptMax(), 1e-9);
|
||||
assertEquals(1000.0 / 50.5, s.tps(), 1e-9);
|
||||
assertEquals(0, w.drain().ticks());
|
||||
}
|
||||
|
||||
@Test
|
||||
void tpsIsCappedAtTwenty() {
|
||||
HealthWindow w = new HealthWindow(1000);
|
||||
for (int i = 0; i < 10; i++) w.record(2_000_000L);
|
||||
assertEquals(20.0, w.drain().tps(), 1e-9);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsAtMostCapacitySamples() {
|
||||
HealthWindow w = new HealthWindow(10);
|
||||
for (int i = 0; i < 50; i++) w.record(1_000_000L);
|
||||
HealthWindow.Stats s = w.drain();
|
||||
assertEquals(50, s.ticks());
|
||||
assertTrue(s.msptAvg() > 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class OutboxJournalTest {
|
||||
@TempDir
|
||||
Path dir;
|
||||
|
||||
private static final Duration WEEK = Duration.ofDays(7);
|
||||
|
||||
private static String event(int i) {
|
||||
return "{\"id\":\"e" + i + "\",\"type\":\"player_join\"}";
|
||||
}
|
||||
|
||||
private static String snapshot(int i) {
|
||||
return "{\"id\":\"s" + i + "\",\"type\":\"stats_snapshot\",\"pad\":\"" + "x".repeat(200) + "\"}";
|
||||
}
|
||||
|
||||
@Test
|
||||
void survivesRestart() throws IOException {
|
||||
OutboxJournal j1 = new OutboxJournal(dir, 50_000_000, WEEK);
|
||||
j1.append(List.of(event(1), event(2)));
|
||||
OutboxJournal j2 = new OutboxJournal(dir, 50_000_000, WEEK);
|
||||
Optional<OutboxJournal.Segment> seg = j2.nextSendable();
|
||||
assertTrue(seg.isPresent());
|
||||
assertEquals(List.of(event(1), event(2)), seg.get().lines());
|
||||
}
|
||||
|
||||
@Test
|
||||
void dropsTruncatedLastLine() throws IOException {
|
||||
Files.writeString(dir.resolve("current.jsonl"), event(1) + "\n{\"id\":\"trunc", StandardCharsets.UTF_8);
|
||||
OutboxJournal j = new OutboxJournal(dir, 50_000_000, WEEK);
|
||||
j.append(List.of(event(2)));
|
||||
assertEquals(List.of(event(1), event(2)), j.nextSendable().orElseThrow().lines());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ackDeletesSegment() throws IOException {
|
||||
OutboxJournal j = new OutboxJournal(dir, 50_000_000, WEEK);
|
||||
j.append(List.of(event(1)));
|
||||
OutboxJournal.Segment s = j.nextSendable().orElseThrow();
|
||||
j.ack(s);
|
||||
assertFalse(j.nextSendable().isPresent());
|
||||
assertEquals(0, j.totalBytes());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectMovesSegmentAside() throws IOException {
|
||||
OutboxJournal j = new OutboxJournal(dir, 50_000_000, WEEK);
|
||||
j.append(List.of(event(1)));
|
||||
j.reject(j.nextSendable().orElseThrow(), "400 bad_batch");
|
||||
assertFalse(j.nextSendable().isPresent());
|
||||
try (Stream<Path> files = Files.list(dir.resolve("rejected"))) {
|
||||
assertEquals(2, files.count()); // segment + reason
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rotatesAt500Lines() throws IOException {
|
||||
OutboxJournal j = new OutboxJournal(dir, 50_000_000, WEEK);
|
||||
List<String> lines = new ArrayList<>();
|
||||
for (int i = 0; i < 501; i++) lines.add(event(i));
|
||||
j.append(lines);
|
||||
OutboxJournal.Segment first = j.nextSendable().orElseThrow();
|
||||
assertEquals(500, first.lines().size());
|
||||
j.ack(first);
|
||||
assertEquals(List.of(event(500)), j.nextSendable().orElseThrow().lines());
|
||||
}
|
||||
|
||||
@Test
|
||||
void sendsOldestSegmentFirst() throws IOException {
|
||||
OutboxJournal j = new OutboxJournal(dir, 50_000_000, WEEK);
|
||||
j.append(List.of(event(1)));
|
||||
OutboxJournal.Segment a = j.nextSendable().orElseThrow();
|
||||
j.append(List.of(event(2)));
|
||||
assertEquals(a.file(), j.nextSendable().orElseThrow().file());
|
||||
}
|
||||
|
||||
@Test
|
||||
void evictsSnapshotsBeforeEvents() throws IOException {
|
||||
// ~250 bytes per snapshot line; limit allows roughly 20 of them
|
||||
OutboxJournal j = new OutboxJournal(dir, 5_000, WEEK);
|
||||
List<String> mixed = new ArrayList<>();
|
||||
for (int i = 0; i < 40; i++) {
|
||||
mixed.add(snapshot(i));
|
||||
if (i % 10 == 0) mixed.add(event(i));
|
||||
}
|
||||
int dropped = j.append(mixed);
|
||||
assertTrue(dropped > 0);
|
||||
assertTrue(j.totalBytes() <= 5_000);
|
||||
List<String> remaining = new ArrayList<>();
|
||||
Optional<OutboxJournal.Segment> s;
|
||||
while ((s = j.nextSendable()).isPresent()) {
|
||||
remaining.addAll(s.get().lines());
|
||||
j.ack(s.get());
|
||||
}
|
||||
for (int i = 0; i < 40; i += 10) assertTrue(remaining.contains(event(i)), "event " + i + " kept");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.google.gson.JsonParser;
|
||||
import de.hahmann.atmstats.core.Events.PlayerJoin;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Deque;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Queue;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.zip.GZIPInputStream;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class ShipperTest {
|
||||
@TempDir
|
||||
Path dir;
|
||||
|
||||
private final Queue<Events.AtmEvent> queue = new ConcurrentLinkedQueue<>();
|
||||
private final List<String> logs = new ArrayList<>();
|
||||
private final FakeClient client = new FakeClient();
|
||||
private OutboxJournal journal;
|
||||
private Shipper shipper;
|
||||
|
||||
/** Answers with queued responses; by default accepts every event it receives. */
|
||||
static final class FakeClient implements IngestClient {
|
||||
final Deque<Object> script = new ArrayDeque<>();
|
||||
final List<byte[]> bodies = new ArrayList<>();
|
||||
final List<String> signatures = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public Response send(byte[] gzBody, long ts, String signature) throws IOException {
|
||||
bodies.add(gzBody);
|
||||
signatures.add(signature);
|
||||
Object next = script.isEmpty() ? 200 : script.poll();
|
||||
if (next instanceof IOException e) throw e;
|
||||
if (next instanceof Response r) return r;
|
||||
int status = (Integer) next;
|
||||
if (status == 200) return new Response(200, idsOf(gzBody), Set.of(), Map.of(), null);
|
||||
return new Response(status, Set.of(), Set.of(), Map.of(), status == 401 ? "timestamp_out_of_range" : "error");
|
||||
}
|
||||
|
||||
static Set<String> idsOf(byte[] gz) throws IOException {
|
||||
String json = new String(new GZIPInputStream(new ByteArrayInputStream(gz)).readAllBytes(), StandardCharsets.UTF_8);
|
||||
Set<String> ids = new HashSet<>();
|
||||
JsonParser.parseString(json).getAsJsonObject().getAsJsonArray("events")
|
||||
.forEach(e -> ids.add(e.getAsJsonObject().get("id").getAsString()));
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws IOException {
|
||||
journal = new OutboxJournal(dir, 50_000_000, Duration.ofDays(7));
|
||||
shipper = new Shipper(queue, journal, client, "secret".getBytes(StandardCharsets.UTF_8), new Backoff(1000, 300_000),
|
||||
() -> 1_760_000_000_000L, new LogSink() {
|
||||
public void info(String msg) { logs.add("INFO " + msg); }
|
||||
public void warn(String msg) { logs.add("WARN " + msg); }
|
||||
});
|
||||
}
|
||||
|
||||
private void enqueue(int n) {
|
||||
for (int i = 0; i < n; i++) queue.add(new PlayerJoin(UuidV7.next(), 1L, "308a693c-2bd8-4d6b-8790-670aef5be601", "CDOGamer"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acksOnSuccess() throws IOException {
|
||||
enqueue(3);
|
||||
long delay = shipper.runOnce();
|
||||
assertEquals(0, delay);
|
||||
assertEquals(1, client.bodies.size());
|
||||
assertEquals(3, FakeClient.idsOf(client.bodies.get(0)).size());
|
||||
assertEquals(Signer.sign("secret".getBytes(StandardCharsets.UTF_8), 1_760_000_000_000L, client.bodies.get(0)), client.signatures.get(0));
|
||||
assertFalse(journal.nextSendable().isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void retriesWithBackoffOn503() throws IOException {
|
||||
enqueue(2);
|
||||
client.script.add(503);
|
||||
long first = shipper.runOnce();
|
||||
assertTrue(first >= 500, "backoff delay " + first);
|
||||
assertTrue(journal.nextSendable().isPresent(), "segment kept");
|
||||
long second = shipper.runOnce();
|
||||
assertEquals(0, second);
|
||||
assertEquals(2, client.bodies.size());
|
||||
assertFalse(journal.nextSendable().isPresent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOn400() throws IOException {
|
||||
enqueue(1);
|
||||
client.script.add(400);
|
||||
shipper.runOnce();
|
||||
assertFalse(journal.nextSendable().isPresent());
|
||||
assertTrue(Files.exists(dir.resolve("rejected")));
|
||||
assertTrue(logs.stream().anyMatch(l -> l.startsWith("WARN") && l.contains("rejected")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void logsClockSkewOn401() throws IOException {
|
||||
enqueue(1);
|
||||
client.script.add(401);
|
||||
long delay = shipper.runOnce();
|
||||
assertTrue(delay > 0);
|
||||
assertTrue(journal.nextSendable().isPresent());
|
||||
assertTrue(logs.stream().anyMatch(l -> l.contains("clock skew")), logs.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsEventsWhenClientThrows() throws IOException {
|
||||
enqueue(2);
|
||||
client.script.add(new IOException("connection refused"));
|
||||
long delay = shipper.runOnce();
|
||||
assertTrue(delay > 0);
|
||||
assertEquals(2, journal.nextSendable().orElseThrow().lines().size());
|
||||
// exceptions without message (e.g. ConnectException) must still be identifiable in the log
|
||||
assertTrue(logs.stream().anyMatch(l -> l.contains("IOException: connection refused")), logs.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void flushToJournalPersistsQueuedEvents() throws IOException {
|
||||
enqueue(4);
|
||||
shipper.flushToJournal();
|
||||
assertTrue(queue.isEmpty());
|
||||
assertEquals(4, new OutboxJournal(dir, 50_000_000, Duration.ofDays(7)).nextSendable().orElseThrow().lines().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void keepsSegmentWhen200DoesNotConfirmAllIds() throws IOException {
|
||||
enqueue(2);
|
||||
// e.g. a proxy error page answered with 200: nothing confirmed
|
||||
client.script.add(new IngestClient.Response(200, Set.of(), Set.of(), Map.of(), null));
|
||||
long delay = shipper.runOnce();
|
||||
assertTrue(delay > 0);
|
||||
assertEquals(2, journal.nextSendable().orElseThrow().lines().size());
|
||||
assertTrue(logs.stream().anyMatch(l -> l.contains("did not confirm")), logs.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void individuallyRejectedEventsAreKeptInRejectedFolder() throws IOException {
|
||||
enqueue(2);
|
||||
flushAndCaptureIds();
|
||||
String bad = firstIds.get(0);
|
||||
Set<String> good = new HashSet<>(firstIds);
|
||||
good.remove(bad);
|
||||
client.script.add(new IngestClient.Response(200, good, Set.of(), Map.of(bad, "uuid: Invalid uuid"), null));
|
||||
assertEquals(0, shipper.runOnce());
|
||||
assertFalse(journal.nextSendable().isPresent());
|
||||
try (var files = Files.list(dir.resolve("rejected"))) {
|
||||
String content = files.filter(p -> p.toString().endsWith(".jsonl")).map(p -> {
|
||||
try { return Files.readString(p); } catch (IOException e) { throw new RuntimeException(e); }
|
||||
}).reduce("", String::concat);
|
||||
assertTrue(content.contains(bad), content);
|
||||
assertFalse(content.contains(new ArrayList<>(good).get(0)));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void eventsStayQueuedWhenJournalCannotBeWritten() throws IOException {
|
||||
enqueue(3);
|
||||
// make current.jsonl unwritable by putting a directory in its place
|
||||
Files.createDirectories(dir.resolve("current.jsonl"));
|
||||
try {
|
||||
shipper.flushToJournal();
|
||||
} catch (IOException expected) {
|
||||
// reported to the caller
|
||||
}
|
||||
assertEquals(3, queue.size());
|
||||
}
|
||||
|
||||
private final List<String> firstIds = new ArrayList<>();
|
||||
|
||||
private void flushAndCaptureIds() {
|
||||
for (Events.AtmEvent e : queue) firstIds.add(e.id().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void idleWhenNothingToSend() throws IOException {
|
||||
assertEquals(Shipper.IDLE_MS, shipper.runOnce());
|
||||
assertTrue(client.bodies.isEmpty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SignerTest {
|
||||
@Test
|
||||
void matchesBackendVector() {
|
||||
// Same vector as backend/test/ingest.test.ts "signature vector (shared with the mod)"
|
||||
String sig = Signer.sign("test-secret".getBytes(StandardCharsets.UTF_8), 1760000000000L, "hello".getBytes(StandardCharsets.UTF_8));
|
||||
assertEquals("c9f85674a0d206a6d8e53290fb21af4933f8c8451fb8b2aeefcdf3d2314ef043", sig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class StatsDiffTest {
|
||||
private static Map<String, Map<String, Long>> stats(long jumps, long deaths) {
|
||||
return Map.of("minecraft:custom", Map.of("minecraft:jump", jumps, "minecraft:deaths", deaths));
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstSnapshotIsFullThenOnlyChanges() {
|
||||
StatsDiff diff = new StatsDiff();
|
||||
StatsDiff.Result first = diff.apply("p", stats(1, 0));
|
||||
assertTrue(first.full());
|
||||
assertEquals(stats(1, 0), first.changed());
|
||||
|
||||
StatsDiff.Result second = diff.apply("p", stats(5, 0));
|
||||
assertFalse(second.full());
|
||||
assertEquals(Map.of("minecraft:custom", Map.of("minecraft:jump", 5L)), second.changed());
|
||||
|
||||
assertTrue(diff.apply("p", stats(5, 0)).changed().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void forgetMakesNextSnapshotFullAgain() {
|
||||
StatsDiff diff = new StatsDiff();
|
||||
diff.apply("p", stats(1, 0));
|
||||
diff.forget("p");
|
||||
assertTrue(diff.apply("p", stats(1, 0)).full());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Same rules as backend isQuestConfigFile + team/progress paths (verified against the ATM10 server layout). */
|
||||
class SyncPathsTest {
|
||||
@Test
|
||||
void sendsProgressTeamsChaptersAndEnglishTexts() {
|
||||
for (String p : List.of(
|
||||
"world/ftbquests/308a693c-2bd8-4d6b-8790-670aef5be601.snbt",
|
||||
"world/ftbteams/player/308a693c-2bd8-4d6b-8790-670aef5be601.snbt",
|
||||
"world/ftbteams/party/5e0c1a2b-0000-4000-8000-00000000abcd.snbt",
|
||||
"config/ftbquests/quests/chapters/aether.snbt",
|
||||
"config/ftbquests/quests/lang/en_us/chapter.snbt_merged",
|
||||
"config/ftbquests/quests/lang/en_us/chapters/aether.snbt_merged")) {
|
||||
assertTrue(SyncPaths.isWanted(p), p);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsOtherLanguagesRewardTablesAndCombinedLangFile() {
|
||||
for (String p : List.of(
|
||||
"config/ftbquests/quests/lang/de_de/chapters/aether.snbt_merged",
|
||||
"config/ftbquests/quests/lang/en_us.snbt",
|
||||
"config/ftbquests/quests/reward_tables/loot.snbt",
|
||||
"config/ftbquests/quests/data.snbt",
|
||||
"world/ftbteams/ftbteams.snbt",
|
||||
"world/ftbteams/server/x.snbt")) {
|
||||
assertFalse(SyncPaths.isWanted(p), p);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package de.hahmann.atmstats.core;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class UuidV7Test {
|
||||
@Test
|
||||
void isVersion7AndMonotonic() {
|
||||
List<String> ids = new ArrayList<>();
|
||||
for (int i = 0; i < 1000; i++) {
|
||||
UUID id = UuidV7.next();
|
||||
assertEquals(7, id.version());
|
||||
assertEquals(2, id.variant());
|
||||
ids.add(id.toString());
|
||||
}
|
||||
List<String> sorted = new ArrayList<>(ids);
|
||||
sorted.sort(String::compareTo);
|
||||
assertEquals(ids, sorted);
|
||||
assertEquals(1000, ids.stream().distinct().count());
|
||||
}
|
||||
|
||||
@Test
|
||||
void encodesCurrentTime() {
|
||||
long before = System.currentTimeMillis();
|
||||
UUID id = UuidV7.next();
|
||||
long ts = id.getMostSignificantBits() >>> 16;
|
||||
assertEquals(true, ts >= before && ts <= System.currentTimeMillis() + 1);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user