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:
lucashahmannandClaude Opus 5.5 committed 2026-10-13 09:33:25 +02:00
1 parent 56d2078e6a
commit e66f89f49e
214 files changed
+27077

No files matched your search

+5
View File
@@ -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
+25
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
run/
build/
.gradle/
+41
View File
@@ -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
+68
View File
@@ -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
+21
View File
@@ -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
Binary file not shown.
+7
View File
@@ -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
View File
@@ -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" "$@"
+94
View File
@@ -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
+9
View File
@@ -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);
}
}