From 12c03139473d49ed74dbb5d7431d5fd584a06265 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 1 Aug 2026 11:49:07 -0400 Subject: [PATCH] feat: add Spigot event producer --- .gitea/workflows/ci.yml | 46 ++++ .gitea/workflows/release.yml | 141 ++++++++++ .gitignore | 5 + README.md | 129 +++++++++ build.gradle.kts | 54 ++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 ++++++++++++++++++ gradlew.bat | 82 ++++++ settings.gradle.kts | 1 + .../SpigotEventProducerPlugin.java | 177 +++++++++++++ .../chat/PrivateMessageParser.java | 77 ++++++ .../spigotevents/delivery/BatchSender.java | 9 + .../spigotevents/delivery/EventPipeline.java | 171 ++++++++++++ .../spigotevents/delivery/IngestClient.java | 44 ++++ .../delivery/OutboxDispatcher.java | 30 +++ .../spigotevents/event/CloudEventFactory.java | 68 +++++ .../dmg/spigotevents/event/OutboundEvent.java | 15 ++ .../spigotevents/event/PlayerIdentity.java | 11 + .../spigotevents/event/ServerIdentity.java | 13 + .../listener/GameEventListener.java | 83 ++++++ .../dmg/spigotevents/outbox/SqliteOutbox.java | 114 ++++++++ .../dmg/spigotevents/outbox/StoredEvent.java | 4 + .../dmg/spigotevents/stats/MapChunker.java | 55 ++++ .../stats/PlayerSnapshotCollector.java | 210 +++++++++++++++ .../spigotevents/stats/SnapshotScheduler.java | 90 +++++++ src/main/resources/config.yml | 28 ++ src/main/resources/plugin.yml | 6 + .../chat/PrivateMessageParserTest.java | 27 ++ .../delivery/EventPipelineTest.java | 51 ++++ .../delivery/OutboxDeliveryTest.java | 58 ++++ .../event/CloudEventFactoryTest.java | 40 +++ .../spigotevents/outbox/SqliteOutboxTest.java | 34 +++ .../spigotevents/stats/MapChunkerTest.java | 31 +++ 34 files changed, 2159 insertions(+) create mode 100644 .gitea/workflows/ci.yml create mode 100644 .gitea/workflows/release.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 build.gradle.kts create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 src/main/java/games/dmg/spigotevents/SpigotEventProducerPlugin.java create mode 100644 src/main/java/games/dmg/spigotevents/chat/PrivateMessageParser.java create mode 100644 src/main/java/games/dmg/spigotevents/delivery/BatchSender.java create mode 100644 src/main/java/games/dmg/spigotevents/delivery/EventPipeline.java create mode 100644 src/main/java/games/dmg/spigotevents/delivery/IngestClient.java create mode 100644 src/main/java/games/dmg/spigotevents/delivery/OutboxDispatcher.java create mode 100644 src/main/java/games/dmg/spigotevents/event/CloudEventFactory.java create mode 100644 src/main/java/games/dmg/spigotevents/event/OutboundEvent.java create mode 100644 src/main/java/games/dmg/spigotevents/event/PlayerIdentity.java create mode 100644 src/main/java/games/dmg/spigotevents/event/ServerIdentity.java create mode 100644 src/main/java/games/dmg/spigotevents/listener/GameEventListener.java create mode 100644 src/main/java/games/dmg/spigotevents/outbox/SqliteOutbox.java create mode 100644 src/main/java/games/dmg/spigotevents/outbox/StoredEvent.java create mode 100644 src/main/java/games/dmg/spigotevents/stats/MapChunker.java create mode 100644 src/main/java/games/dmg/spigotevents/stats/PlayerSnapshotCollector.java create mode 100644 src/main/java/games/dmg/spigotevents/stats/SnapshotScheduler.java create mode 100644 src/main/resources/config.yml create mode 100644 src/main/resources/plugin.yml create mode 100644 src/test/java/games/dmg/spigotevents/chat/PrivateMessageParserTest.java create mode 100644 src/test/java/games/dmg/spigotevents/delivery/EventPipelineTest.java create mode 100644 src/test/java/games/dmg/spigotevents/delivery/OutboxDeliveryTest.java create mode 100644 src/test/java/games/dmg/spigotevents/event/CloudEventFactoryTest.java create mode 100644 src/test/java/games/dmg/spigotevents/outbox/SqliteOutboxTest.java create mode 100644 src/test/java/games/dmg/spigotevents/stats/MapChunkerTest.java diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..8df692d --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: gradle + + - name: Validate conventional commits + if: github.event_name == 'pull_request' + run: | + npx -y \ + -p @commitlint/cli \ + -p @commitlint/config-conventional \ + commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.sha }}" \ + --extends @commitlint/config-conventional + + - name: Build and test + run: ./gradlew clean check jar + + - name: Name development artifact + run: | + short_sha=$(printf '%s' "$GITHUB_SHA" | cut -c1-7) + cp build/libs/spigot-event-producer-0.1.0-SNAPSHOT.jar \ + "build/libs/spigot-event-producer-dev-${short_sha}.jar" + + - name: Upload development artifact + uses: actions/upload-artifact@v3 + with: + name: spigot-event-producer-${{ github.sha }} + path: build/libs/spigot-event-producer-dev-*.jar + if-no-files-found: error diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..93493dd --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,141 @@ +name: Release + +on: + push: + branches: + - main + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITEA_TOKEN }} + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: gradle + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + + - name: Build and test before release + run: ./gradlew clean check + + - name: Capture previous tag + id: previous_tag + run: | + if git describe --tags --abbrev=0 >/dev/null 2>&1; then + echo "value=$(git describe --tags --abbrev=0)" >> "$GITHUB_OUTPUT" + else + echo "value=" >> "$GITHUB_OUTPUT" + fi + + - name: Run semantic-release + env: + GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + npx -y \ + -p semantic-release \ + -p @semantic-release/commit-analyzer \ + -p @semantic-release/release-notes-generator \ + semantic-release \ + --branches main \ + --plugins @semantic-release/commit-analyzer,@semantic-release/release-notes-generator + + - name: Capture current tag + id: current_tag + run: | + if git describe --tags --exact-match HEAD >/dev/null 2>&1; then + echo "value=$(git describe --tags --exact-match HEAD)" >> "$GITHUB_OUTPUT" + else + echo "value=" >> "$GITHUB_OUTPUT" + fi + + - name: Resolve release + id: release + env: + PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }} + CURRENT_TAG: ${{ steps.current_tag.outputs.value }} + run: | + if [ -n "$CURRENT_TAG" ] && [ "$CURRENT_TAG" != "$PREVIOUS_TAG" ]; then + echo "created=true" >> "$GITHUB_OUTPUT" + echo "tag=$CURRENT_TAG" >> "$GITHUB_OUTPUT" + echo "version=${CURRENT_TAG#v}" >> "$GITHUB_OUTPUT" + else + echo "created=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build versioned plugin + if: steps.release.outputs.created == 'true' + env: + VERSION: ${{ steps.release.outputs.version }} + run: ./gradlew clean jar -PreleaseVersion="$VERSION" + + - name: Upload release workflow artifact + if: steps.release.outputs.created == 'true' + uses: actions/upload-artifact@v3 + with: + name: spigot-event-producer-${{ steps.release.outputs.version }} + path: build/libs/spigot-event-producer-${{ steps.release.outputs.version }}.jar + if-no-files-found: error + + - name: Create Gitea release and upload plugin + if: steps.release.outputs.created == 'true' + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_SERVER_URL: ${{ github.server_url }} + REPOSITORY: ${{ github.repository }} + TAG: ${{ steps.release.outputs.tag }} + VERSION: ${{ steps.release.outputs.version }} + PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }} + run: | + api_url="${GITEA_SERVER_URL}/api/v1" + jar="build/libs/spigot-event-producer-${VERSION}.jar" + export RELEASE_BODY + if [ -n "$PREVIOUS_TAG" ]; then + RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD") + else + RELEASE_BODY=$(git log --pretty='format:- %s (%h)' HEAD) + fi + payload=$(node -e ' + const payload = { + tag_name: process.env.TAG, + name: process.env.TAG, + body: process.env.RELEASE_BODY, + draft: false, + prerelease: false + }; + process.stdout.write(JSON.stringify(payload)); + ') + response=$(curl --fail-with-body --silent --show-error \ + -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/json" \ + "${api_url}/repos/${REPOSITORY}/releases" \ + --data "$payload") + release_id=$(printf '%s' "$response" | node -e ' + let input = ""; + process.stdin.on("data", chunk => input += chunk); + process.stdin.on("end", () => { + const response = JSON.parse(input); + if (!response.id) process.exit(1); + process.stdout.write(String(response.id)); + }); + ') + curl --fail-with-body --silent --show-error \ + -X POST \ + -H "Authorization: token ${GITEA_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${jar}" \ + "${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=spigot-event-producer-${VERSION}.jar" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..adb24a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +*.iml +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..835765f --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# Spigot Event Producer + +A Spigot 26.2 plugin that records Minecraft activity as CloudEvents 1.0, persists it in a local SQLite outbox, and sends newline-delimited batches to `game-ingest-server`. + +## Requirements + +- Spigot 26.2 +- Java 17 or newer +- Network access to the ingest endpoint + +## Build and install + +```bash +./gradlew clean test jar +cp build/libs/spigot-event-producer-0.1.0-SNAPSHOT.jar /path/to/server/plugins/ +``` + +Start the Minecraft server once to create `plugins/SpigotEventProducer/config.yml`. Set a meaningful, stable `server-name`. When `server-id` is empty, the plugin generates and persists a UUID. Do not reuse that generated UUID on another server. + +The default ingest endpoint is: + +```text +https://events.dmg.games/events +``` + +## Events + +Every event is a structured CloudEvent 1.0 JSON object with these common attributes: + +- `source`: `urn:minecraft-server:` +- `serverid` and `servername`: CloudEvent extensions identifying the server +- `gameversion`: the Bukkit game version +- `playerid`: player UUID for player events +- `data.player_id` and `data.player_name`: stable UUID and current player name + +Produced event types: + +| Type | Trigger | +| --- | --- | +| `games.dmg.minecraft.server.started` | Plugin/server startup | +| `games.dmg.minecraft.server.stopped` | Plugin/server shutdown | +| `games.dmg.minecraft.player.joined` | Player joins | +| `games.dmg.minecraft.player.quit` | Player quits | +| `games.dmg.minecraft.chat` | Non-cancelled public player chat | +| `games.dmg.minecraft.private_message` | A recognized private-message command | +| `games.dmg.minecraft.player.location` | Location snapshot | +| `games.dmg.minecraft.player.statistics` | Partitioned statistic snapshot | + +### Chat and private messages + +Public chat events contain the complete message text. + +Private-message events recognize `/msg`, `/message`, `/tell`, `/w`, `/whisper`, `/pm`, `/reply`, and `/r`, including namespaced variants such as `/minecraft:tell`. Explicit commands contain the destination token. Reply commands set `reply: true` and omit the recipient so downstream processing can infer it from earlier ordered events. The event also reports whether the command was cancelled. + +Spigot has no universal private-message event. Commands implemented with unrelated aliases or entirely custom plugin logic will not be recognized until their syntax is added. A recognized command event reports observation of the command, not guaranteed delivery to its recipient. + +### Location snapshots + +Each online player is sampled every 60 seconds by default. Events include world UUID/name, coordinates, yaw, pitch, game mode, and biome. + +### Statistic snapshots + +Every Bukkit statistic is read, including untyped, block, item, and entity statistics. Zero values are retained. Invalid statistic/material/entity combinations rejected by Bukkit are skipped. + +Large snapshots are divided by category and size. All parts share a `snapshot_id`, capture start/completion timestamps, and include `part` and `parts` fields. Statistic reads are spread across ticks using a configurable operation budget; a player remains queued until all combinations have been read. Empty categories still emit an explicit part. + +## Delivery and durability + +The outbox is stored at: + +```text +plugins/SpigotEventProducer/event-outbox.sqlite3 +``` + +SQLite writes and queue-size decisions run on a dedicated persistence worker. NDJSON construction and HTTP requests use a separate delivery worker, so a slow ingest request cannot prevent newly observed events from reaching SQLite. Bukkit state is only read on Bukkit-managed threads. + +The worker sends every five minutes by default. It may send earlier when the configured event or byte threshold is reached. Requests remain below the ingest service's default 1 MiB and 1,000-event limits. Events are removed only after HTTP `202 Accepted`; failures remain in SQLite and are retried. + +Delivery is at least once. If the ingest server accepts a request but its response is lost, the plugin retries the same stable CloudEvent IDs. Consumers should deduplicate by CloudEvent `id` when necessary. + +A shutdown event is persisted before the plugin closes its outbox. If it is not uploaded during shutdown, it is delivered after the next startup. + +## Configuration + +```yaml +server-name: "minecraft-server" +server-id: "" + +ingest-url: "https://events.dmg.games/events" +send-interval-seconds: 300 +snapshot-interval-seconds: 60 +http-timeout-seconds: 15 + +batch: + max-events: 1000 + max-bytes: 900000 + +dispatch-threshold: + events: 1000 + bytes: 900000 + +statistics: + chunk-json-bytes: 200000 + operations-per-tick: 1000 +``` + +All intervals and limits must be positive. Keep `batch.max-bytes` below the ingest server's configured body limit and `batch.max-events` at or below its maximum events per request. + +## Releases + +Gitea Actions runs the Gradle checks for every push and pull request and stores a development JAR as a workflow artifact. Pushes to `main` also run semantic-release using conventional commits: + +- `fix:` creates a patch release; +- `feat:` creates a minor release; +- a breaking-change footer or `!` creates a major release. + +A successful release creates a `vX.Y.Z` tag and attaches `spigot-event-producer-X.Y.Z.jar` permanently to the corresponding Gitea Release. The release workflow requires a repository secret named `GITEA_TOKEN` with repository contents write permission. + +For a local versioned build: + +```bash +./gradlew clean check jar -PreleaseVersion=1.2.3 +``` + +## Privacy and operations + +This plugin deliberately records complete public and private message text, player identifiers, precise locations, and activity statistics. Server operators should disclose that collection, restrict access to the plugin data directory and ingest database, use HTTPS, and configure retention appropriate to their jurisdiction. + +The outbox has no automatic retention limit because dropping unsent events would violate durability. Monitor disk usage if the ingest service is unavailable for an extended period. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..61abd41 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + java +} + +group = "games.dmg" +version = providers.gradleProperty("releaseVersion") + .orElse("0.1.0-SNAPSHOT") + .get() + +repositories { + mavenCentral() + maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(17) + } +} + +tasks.withType().configureEach { + options.compilerArgs.add("-Xlint:deprecation") +} + +dependencies { + compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT") + implementation("org.xerial:sqlite-jdbc:3.50.3.0") + + testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT") + testImplementation(platform("org.junit:junit-bom:5.13.4")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.test { + useJUnitPlatform() +} + +val pluginVersion = version + +tasks.processResources { + filesMatching("plugin.yml") { + expand("version" to pluginVersion) + } +} + +tasks.jar { + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + manifest { + attributes["Multi-Release"] = "true" + } + from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) }) + exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA") +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..a351597 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..203529c --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..7e60b72 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS=-Dfile.encoding=UTF-8 "-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..8bf41e0 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "spigot-event-producer" diff --git a/src/main/java/games/dmg/spigotevents/SpigotEventProducerPlugin.java b/src/main/java/games/dmg/spigotevents/SpigotEventProducerPlugin.java new file mode 100644 index 0000000..4aa3fe0 --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/SpigotEventProducerPlugin.java @@ -0,0 +1,177 @@ +package games.dmg.spigotevents; + +import games.dmg.spigotevents.delivery.EventPipeline; +import games.dmg.spigotevents.delivery.IngestClient; +import games.dmg.spigotevents.delivery.OutboxDispatcher; +import games.dmg.spigotevents.event.CloudEventFactory; +import games.dmg.spigotevents.event.ServerIdentity; +import games.dmg.spigotevents.listener.GameEventListener; +import games.dmg.spigotevents.outbox.SqliteOutbox; +import games.dmg.spigotevents.stats.PlayerSnapshotCollector; +import games.dmg.spigotevents.stats.SnapshotScheduler; +import java.net.URI; +import java.nio.file.Path; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Level; +import org.bukkit.Bukkit; +import org.bukkit.plugin.java.JavaPlugin; + +public final class SpigotEventProducerPlugin extends JavaPlugin { + private CloudEventFactory eventFactory; + private EventPipeline pipeline; + private SnapshotScheduler snapshotScheduler; + + @Override + public void onEnable() { + saveDefaultConfig(); + try { + PluginSettings settings = loadSettings(); + String gameVersion = Bukkit.getBukkitVersion().split("-", 2)[0]; + eventFactory = new CloudEventFactory( + new ServerIdentity(settings.serverId(), settings.serverName()), gameVersion); + + Path databasePath = getDataFolder().toPath().resolve("event-outbox.sqlite3"); + SqliteOutbox outbox = new SqliteOutbox(databasePath); + var ingestClient = new IngestClient(settings.ingestUrl(), settings.httpTimeout()); + var dispatcher = new OutboxDispatcher( + outbox, ingestClient, settings.maxBatchEvents(), settings.maxBatchBytes()); + pipeline = new EventPipeline( + outbox, + dispatcher, + settings.sendInterval(), + settings.dispatchThresholdEvents(), + settings.dispatchThresholdBytes(), + (message, error) -> getLogger().log(Level.WARNING, message, error)); + + getServer().getPluginManager().registerEvents( + new GameEventListener(eventFactory, pipeline, Bukkit.getOnlinePlayers()), this); + var collector = new PlayerSnapshotCollector( + eventFactory, pipeline, settings.statisticChunkBytes()); + snapshotScheduler = new SnapshotScheduler( + this, + collector, + settings.snapshotIntervalSeconds(), + settings.statisticOperationsPerTick()); + snapshotScheduler.start(); + + Map data = new LinkedHashMap<>(); + data.put("plugin_version", getDescription().getVersion()); + data.put("bukkit_version", Bukkit.getBukkitVersion()); + data.put("server_implementation", Bukkit.getVersion()); + pipeline.publish(eventFactory.create("server.started", data)); + getLogger().info("Producing events as server '" + settings.serverName() + + "' (" + settings.serverId() + ")"); + } catch (Exception exception) { + getLogger().log(Level.SEVERE, "Could not initialize SpigotEventProducer", exception); + closeResources(); + getServer().getPluginManager().disablePlugin(this); + } + } + + @Override + public void onDisable() { + if (snapshotScheduler != null) { + snapshotScheduler.close(); + snapshotScheduler = null; + } + if (pipeline != null && eventFactory != null) { + try { + pipeline.publishAndWait( + eventFactory.create("server.stopped", Map.of()), Duration.ofSeconds(20)); + } catch (Exception exception) { + getLogger().log(Level.WARNING, "Could not persist the server shutdown event", exception); + } + } + closeResources(); + } + + private PluginSettings loadSettings() { + String serverName = requireText("server-name"); + String configuredId = getConfig().getString("server-id", "").trim(); + UUID serverId; + if (configuredId.isEmpty()) { + serverId = UUID.randomUUID(); + getConfig().set("server-id", serverId.toString()); + saveConfig(); + } else { + serverId = UUID.fromString(configuredId); + } + + if (serverName.length() > 128) { + throw new IllegalArgumentException("server-name must be 128 characters or fewer"); + } + int maxBatchBytes = positiveInt("batch.max-bytes"); + int statisticChunkBytes = positiveInt("statistics.chunk-json-bytes"); + if (statisticChunkBytes > maxBatchBytes - 8_192) { + throw new IllegalArgumentException( + "statistics.chunk-json-bytes must leave at least 8192 bytes of batch headroom"); + } + + return new PluginSettings( + serverName, + serverId, + URI.create(requireText("ingest-url")), + Duration.ofSeconds(positiveLong("send-interval-seconds")), + positiveLong("snapshot-interval-seconds"), + Duration.ofSeconds(positiveLong("http-timeout-seconds")), + positiveInt("batch.max-events"), + maxBatchBytes, + positiveLong("dispatch-threshold.events"), + positiveLong("dispatch-threshold.bytes"), + statisticChunkBytes, + positiveInt("statistics.operations-per-tick")); + } + + private String requireText(String path) { + String value = getConfig().getString(path, "").trim(); + if (value.isEmpty()) { + throw new IllegalArgumentException(path + " must not be blank"); + } + return value; + } + + private int positiveInt(String path) { + int value = getConfig().getInt(path); + if (value < 1) { + throw new IllegalArgumentException(path + " must be positive"); + } + return value; + } + + private long positiveLong(String path) { + long value = getConfig().getLong(path); + if (value < 1) { + throw new IllegalArgumentException(path + " must be positive"); + } + return value; + } + + private void closeResources() { + if (pipeline != null) { + try { + pipeline.close(); + } catch (Exception exception) { + getLogger().log(Level.WARNING, "Could not close the event outbox", exception); + } + pipeline = null; + } + } + + private record PluginSettings( + String serverName, + UUID serverId, + URI ingestUrl, + Duration sendInterval, + long snapshotIntervalSeconds, + Duration httpTimeout, + int maxBatchEvents, + int maxBatchBytes, + long dispatchThresholdEvents, + long dispatchThresholdBytes, + int statisticChunkBytes, + int statisticOperationsPerTick) { + } +} diff --git a/src/main/java/games/dmg/spigotevents/chat/PrivateMessageParser.java b/src/main/java/games/dmg/spigotevents/chat/PrivateMessageParser.java new file mode 100644 index 0000000..5dbc576 --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/chat/PrivateMessageParser.java @@ -0,0 +1,77 @@ +package games.dmg.spigotevents.chat; + +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +public final class PrivateMessageParser { + private static final Set EXPLICIT_COMMANDS = Set.of( + "msg", "message", "tell", "w", "whisper", "pm"); + private static final Set REPLY_COMMANDS = Set.of("reply", "r"); + + private PrivateMessageParser() { + } + + public static Optional parse(String commandLine) { + if (commandLine == null || commandLine.length() < 2 || commandLine.charAt(0) != '/') { + return Optional.empty(); + } + + int commandEnd = nextWhitespace(commandLine, 1); + if (commandEnd < 0) { + return Optional.empty(); + } + + String command = commandLine.substring(1, commandEnd).toLowerCase(Locale.ROOT); + int namespaceSeparator = command.lastIndexOf(':'); + if (namespaceSeparator >= 0) { + command = command.substring(namespaceSeparator + 1); + } + + int argumentStart = skipWhitespace(commandLine, commandEnd); + if (argumentStart >= commandLine.length()) { + return Optional.empty(); + } + + if (REPLY_COMMANDS.contains(command)) { + return Optional.of(new PrivateMessage(command, "", commandLine.substring(argumentStart))); + } + if (!EXPLICIT_COMMANDS.contains(command)) { + return Optional.empty(); + } + + int recipientEnd = nextWhitespace(commandLine, argumentStart); + if (recipientEnd < 0) { + return Optional.empty(); + } + int messageStart = skipWhitespace(commandLine, recipientEnd); + if (messageStart >= commandLine.length()) { + return Optional.empty(); + } + + return Optional.of(new PrivateMessage( + command, + commandLine.substring(argumentStart, recipientEnd), + commandLine.substring(messageStart))); + } + + private static int nextWhitespace(String value, int start) { + for (int index = start; index < value.length(); index++) { + if (Character.isWhitespace(value.charAt(index))) { + return index; + } + } + return -1; + } + + private static int skipWhitespace(String value, int start) { + int index = start; + while (index < value.length() && Character.isWhitespace(value.charAt(index))) { + index++; + } + return index; + } + + public record PrivateMessage(String command, String recipient, String message) { + } +} diff --git a/src/main/java/games/dmg/spigotevents/delivery/BatchSender.java b/src/main/java/games/dmg/spigotevents/delivery/BatchSender.java new file mode 100644 index 0000000..5b52dc9 --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/delivery/BatchSender.java @@ -0,0 +1,9 @@ +package games.dmg.spigotevents.delivery; + +import games.dmg.spigotevents.outbox.StoredEvent; +import java.util.List; + +@FunctionalInterface +public interface BatchSender { + boolean send(List events) throws Exception; +} diff --git a/src/main/java/games/dmg/spigotevents/delivery/EventPipeline.java b/src/main/java/games/dmg/spigotevents/delivery/EventPipeline.java new file mode 100644 index 0000000..f3a300a --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/delivery/EventPipeline.java @@ -0,0 +1,171 @@ +package games.dmg.spigotevents.delivery; + +import games.dmg.spigotevents.event.OutboundEvent; +import games.dmg.spigotevents.outbox.SqliteOutbox; +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.BiConsumer; + +public final class EventPipeline implements AutoCloseable { + private static final long INITIAL_RETRY_MILLIS = 5_000; + + private final SqliteOutbox outbox; + private final OutboxDispatcher dispatcher; + private final long dispatchThresholdEvents; + private final long dispatchThresholdBytes; + private final long maxRetryMillis; + private final BiConsumer errorLogger; + private final ExecutorService persistenceWorker; + private final ScheduledThreadPoolExecutor deliveryWorker; + private final AtomicBoolean dispatchScheduled = new AtomicBoolean(); + private final ScheduledFuture periodicDispatch; + private volatile boolean closed; + private volatile long nextAttemptNanos; + private volatile long retryMillis = INITIAL_RETRY_MILLIS; + + public EventPipeline( + SqliteOutbox outbox, + OutboxDispatcher dispatcher, + Duration sendInterval, + long dispatchThresholdEvents, + long dispatchThresholdBytes, + BiConsumer errorLogger) { + this.outbox = Objects.requireNonNull(outbox, "outbox"); + this.dispatcher = Objects.requireNonNull(dispatcher, "dispatcher"); + this.dispatchThresholdEvents = dispatchThresholdEvents; + this.dispatchThresholdBytes = dispatchThresholdBytes; + this.maxRetryMillis = Math.max(INITIAL_RETRY_MILLIS, sendInterval.toMillis()); + this.errorLogger = Objects.requireNonNull(errorLogger, "errorLogger"); + this.persistenceWorker = Executors.newSingleThreadExecutor(runnable -> daemonThread( + runnable, "spigot-event-persistence")); + this.deliveryWorker = new ScheduledThreadPoolExecutor( + 1, runnable -> daemonThread(runnable, "spigot-event-delivery")); + deliveryWorker.setRemoveOnCancelPolicy(true); + long intervalMillis = Math.max(1, sendInterval.toMillis()); + periodicDispatch = deliveryWorker.scheduleWithFixedDelay( + this::requestDispatch, 0, intervalMillis, TimeUnit.MILLISECONDS); + } + + public void publish(OutboundEvent event) { + if (closed) { + errorLogger.accept("Rejected event after the pipeline closed: " + event.id(), null); + return; + } + try { + persistenceWorker.execute(() -> { + try { + appendAndMaybeDispatch(event); + } catch (Exception exception) { + errorLogger.accept("Could not persist event " + event.id(), exception); + } + }); + } catch (RejectedExecutionException exception) { + errorLogger.accept("Rejected event while the pipeline was closing: " + event.id(), exception); + } + } + + public void publishAndWait(OutboundEvent event, Duration timeout) throws Exception { + if (closed) { + throw new IllegalStateException("The event pipeline is closed"); + } + persistenceWorker.submit(() -> { + appendAndMaybeDispatch(event); + return null; + }).get(timeout.toMillis(), TimeUnit.MILLISECONDS); + } + + private void appendAndMaybeDispatch(OutboundEvent event) throws Exception { + outbox.append(event); + if (outbox.pendingCount() >= dispatchThresholdEvents + || outbox.pendingBytes() >= dispatchThresholdBytes) { + requestDispatch(); + } + } + + private void requestDispatch() { + if (closed || !dispatchScheduled.compareAndSet(false, true)) { + return; + } + long delayNanos = Math.max(0, nextAttemptNanos - System.nanoTime()); + try { + deliveryWorker.schedule(this::dispatchAvailable, delayNanos, TimeUnit.NANOSECONDS); + } catch (RejectedExecutionException exception) { + dispatchScheduled.set(false); + if (!closed) { + errorLogger.accept("Could not schedule event delivery", exception); + } + } + } + + private void dispatchAvailable() { + boolean failed = false; + try { + while (!closed && outbox.pendingCount() > 0) { + if (!dispatcher.dispatchOnce()) { + failed = true; + break; + } + } + } catch (Exception exception) { + failed = true; + errorLogger.accept("Could not deliver the event outbox", exception); + } finally { + if (failed) { + nextAttemptNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(retryMillis); + retryMillis = Math.min(maxRetryMillis, retryMillis * 2); + } else { + nextAttemptNanos = 0; + retryMillis = INITIAL_RETRY_MILLIS; + } + dispatchScheduled.set(false); + if (failed && !closed) { + requestDispatch(); + } else if (!closed) { + try { + if (outbox.pendingCount() >= dispatchThresholdEvents + || outbox.pendingBytes() >= dispatchThresholdBytes) { + requestDispatch(); + } + } catch (Exception exception) { + errorLogger.accept("Could not inspect the event outbox", exception); + } + } + } + } + + @Override + public void close() throws Exception { + closed = true; + periodicDispatch.cancel(false); + + persistenceWorker.shutdown(); + boolean persistenceStopped = persistenceWorker.awaitTermination(20, TimeUnit.SECONDS); + if (!persistenceStopped) { + persistenceWorker.shutdownNow(); + persistenceStopped = persistenceWorker.awaitTermination(5, TimeUnit.SECONDS); + } + + deliveryWorker.shutdownNow(); + boolean deliveryStopped = deliveryWorker.awaitTermination(20, TimeUnit.SECONDS); + if (!deliveryStopped) { + throw new IllegalStateException("Timed out while stopping event delivery"); + } + if (!persistenceStopped) { + throw new IllegalStateException("Timed out while persisting queued events"); + } + outbox.close(); + } + + private static Thread daemonThread(Runnable runnable, String name) { + Thread thread = new Thread(runnable, name); + thread.setDaemon(true); + return thread; + } +} diff --git a/src/main/java/games/dmg/spigotevents/delivery/IngestClient.java b/src/main/java/games/dmg/spigotevents/delivery/IngestClient.java new file mode 100644 index 0000000..352e84c --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/delivery/IngestClient.java @@ -0,0 +1,44 @@ +package games.dmg.spigotevents.delivery; + +import games.dmg.spigotevents.outbox.StoredEvent; +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.List; +import java.util.Objects; +import java.util.stream.Collectors; + +public final class IngestClient implements BatchSender { + private final URI endpoint; + private final Duration requestTimeout; + private final HttpClient client; + + public IngestClient(URI endpoint, Duration requestTimeout) { + this.endpoint = Objects.requireNonNull(endpoint, "endpoint"); + this.requestTimeout = Objects.requireNonNull(requestTimeout, "requestTimeout"); + this.client = HttpClient.newBuilder() + .connectTimeout(requestTimeout) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + } + + @Override + public boolean send(List events) throws Exception { + if (events.isEmpty()) { + return true; + } + String body = events.stream() + .map(StoredEvent::payload) + .collect(Collectors.joining("\n", "", "\n")); + HttpRequest request = HttpRequest.newBuilder(endpoint) + .timeout(requestTimeout) + .header("Content-Type", "application/x-ndjson") + .header("Accept", "application/json, application/problem+json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.discarding()); + return response.statusCode() == 202; + } +} diff --git a/src/main/java/games/dmg/spigotevents/delivery/OutboxDispatcher.java b/src/main/java/games/dmg/spigotevents/delivery/OutboxDispatcher.java new file mode 100644 index 0000000..d1a59a6 --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/delivery/OutboxDispatcher.java @@ -0,0 +1,30 @@ +package games.dmg.spigotevents.delivery; + +import games.dmg.spigotevents.outbox.SqliteOutbox; +import java.util.Objects; + +public final class OutboxDispatcher { + private final SqliteOutbox outbox; + private final BatchSender sender; + private final int maxEvents; + private final int maxBytes; + + public OutboxDispatcher(SqliteOutbox outbox, BatchSender sender, int maxEvents, int maxBytes) { + this.outbox = Objects.requireNonNull(outbox, "outbox"); + this.sender = Objects.requireNonNull(sender, "sender"); + this.maxEvents = maxEvents; + this.maxBytes = maxBytes; + } + + public boolean dispatchOnce() throws Exception { + var events = outbox.peekBatch(maxEvents, maxBytes); + if (events.isEmpty()) { + return false; + } + if (!sender.send(events)) { + return false; + } + outbox.acknowledgeThrough(events.get(events.size() - 1).sequence()); + return true; + } +} diff --git a/src/main/java/games/dmg/spigotevents/event/CloudEventFactory.java b/src/main/java/games/dmg/spigotevents/event/CloudEventFactory.java new file mode 100644 index 0000000..81203cd --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/event/CloudEventFactory.java @@ -0,0 +1,68 @@ +package games.dmg.spigotevents.event; + +import com.google.gson.Gson; +import com.google.gson.JsonObject; +import java.time.Clock; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Supplier; + +public final class CloudEventFactory { + private static final Gson GSON = new Gson(); + private static final String TYPE_PREFIX = "games.dmg.minecraft."; + + private final ServerIdentity server; + private final String gameVersion; + private final Clock clock; + private final Supplier eventIds; + + public CloudEventFactory(ServerIdentity server, String gameVersion) { + this(server, gameVersion, Clock.systemUTC(), UUID::randomUUID); + } + + CloudEventFactory( + ServerIdentity server, + String gameVersion, + Clock clock, + Supplier eventIds) { + this.server = Objects.requireNonNull(server, "server"); + this.gameVersion = Objects.requireNonNull(gameVersion, "gameVersion"); + this.clock = Objects.requireNonNull(clock, "clock"); + this.eventIds = Objects.requireNonNull(eventIds, "eventIds"); + } + + public OutboundEvent create(String eventName, PlayerIdentity player, Map eventData) { + UUID eventId = eventIds.get(); + JsonObject event = new JsonObject(); + event.addProperty("specversion", "1.0"); + event.addProperty("id", eventId.toString()); + event.addProperty("source", "urn:minecraft-server:" + server.id()); + event.addProperty("type", TYPE_PREFIX + eventName); + event.addProperty("time", clock.instant().toString()); + event.addProperty("datacontenttype", "application/json"); + event.addProperty("serverid", server.id().toString()); + event.addProperty("servername", server.name()); + event.addProperty("gameversion", gameVersion); + + Map data = new LinkedHashMap<>(); + data.put("server_id", server.id().toString()); + data.put("server_name", server.name()); + data.put("game_version", gameVersion); + if (player != null) { + event.addProperty("playerid", player.id().toString()); + data.put("player_id", player.id().toString()); + data.put("player_name", player.name()); + } + if (eventData != null) { + data.putAll(eventData); + } + event.add("data", GSON.toJsonTree(data)); + return new OutboundEvent(eventId.toString(), GSON.toJson(event)); + } + + public OutboundEvent create(String eventName, Map eventData) { + return create(eventName, null, eventData); + } +} diff --git a/src/main/java/games/dmg/spigotevents/event/OutboundEvent.java b/src/main/java/games/dmg/spigotevents/event/OutboundEvent.java new file mode 100644 index 0000000..2c6d422 --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/event/OutboundEvent.java @@ -0,0 +1,15 @@ +package games.dmg.spigotevents.event; + +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +public record OutboundEvent(String id, String payload) { + public OutboundEvent { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(payload, "payload"); + } + + public int byteSize() { + return payload.getBytes(StandardCharsets.UTF_8).length + 1; + } +} diff --git a/src/main/java/games/dmg/spigotevents/event/PlayerIdentity.java b/src/main/java/games/dmg/spigotevents/event/PlayerIdentity.java new file mode 100644 index 0000000..edde920 --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/event/PlayerIdentity.java @@ -0,0 +1,11 @@ +package games.dmg.spigotevents.event; + +import java.util.Objects; +import java.util.UUID; + +public record PlayerIdentity(UUID id, String name) { + public PlayerIdentity { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(name, "name"); + } +} diff --git a/src/main/java/games/dmg/spigotevents/event/ServerIdentity.java b/src/main/java/games/dmg/spigotevents/event/ServerIdentity.java new file mode 100644 index 0000000..8f4f93f --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/event/ServerIdentity.java @@ -0,0 +1,13 @@ +package games.dmg.spigotevents.event; + +import java.util.Objects; +import java.util.UUID; + +public record ServerIdentity(UUID id, String name) { + public ServerIdentity { + Objects.requireNonNull(id, "id"); + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("Server name must not be blank"); + } + } +} diff --git a/src/main/java/games/dmg/spigotevents/listener/GameEventListener.java b/src/main/java/games/dmg/spigotevents/listener/GameEventListener.java new file mode 100644 index 0000000..9d624df --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/listener/GameEventListener.java @@ -0,0 +1,83 @@ +package games.dmg.spigotevents.listener; + +import games.dmg.spigotevents.chat.PrivateMessageParser; +import games.dmg.spigotevents.delivery.EventPipeline; +import games.dmg.spigotevents.event.CloudEventFactory; +import games.dmg.spigotevents.event.PlayerIdentity; +import java.util.Collection; +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +public final class GameEventListener implements Listener { + private final CloudEventFactory eventFactory; + private final EventPipeline pipeline; + private final Map playerIdentities = + Collections.synchronizedMap(new IdentityHashMap<>()); + + public GameEventListener( + CloudEventFactory eventFactory, + EventPipeline pipeline, + Collection onlinePlayers) { + this.eventFactory = eventFactory; + this.pipeline = pipeline; + for (Player player : onlinePlayers) { + playerIdentities.put(player, new PlayerIdentity(player.getUniqueId(), player.getName())); + } + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + playerIdentities.put(player, new PlayerIdentity(player.getUniqueId(), player.getName())); + publishPlayer("player.joined", player, Map.of()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onQuit(PlayerQuitEvent event) { + Player player = event.getPlayer(); + publishPlayer("player.quit", player, Map.of()); + playerIdentities.remove(player); + } + + @SuppressWarnings("deprecation") + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onChat(AsyncPlayerChatEvent event) { + publishPlayer("chat", event.getPlayer(), Map.of( + "message", event.getMessage(), + "asynchronous", event.isAsynchronous())); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onCommand(PlayerCommandPreprocessEvent event) { + PrivateMessageParser.parse(event.getMessage()).ifPresent(message -> { + Map data = new LinkedHashMap<>(); + data.put("message", message.message()); + data.put("command", message.command()); + data.put("reply", message.recipient().isEmpty()); + if (!message.recipient().isEmpty()) { + data.put("recipient", message.recipient()); + } + data.put("cancelled", event.isCancelled()); + publishPlayer("private_message", event.getPlayer(), data); + }); + } + + private void publishPlayer(String type, Player player, Map data) { + PlayerIdentity identity = playerIdentities.get(player); + if (identity == null) { + identity = new PlayerIdentity(player.getUniqueId(), player.getName()); + playerIdentities.put(player, identity); + } + pipeline.publish(eventFactory.create(type, identity, data)); + } +} diff --git a/src/main/java/games/dmg/spigotevents/outbox/SqliteOutbox.java b/src/main/java/games/dmg/spigotevents/outbox/SqliteOutbox.java new file mode 100644 index 0000000..1765653 --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/outbox/SqliteOutbox.java @@ -0,0 +1,114 @@ +package games.dmg.spigotevents.outbox; + +import games.dmg.spigotevents.event.OutboundEvent; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; + +public final class SqliteOutbox implements AutoCloseable { + private final Connection connection; + + public SqliteOutbox(Path databasePath) throws SQLException, IOException, ClassNotFoundException { + Path absolutePath = databasePath.toAbsolutePath(); + Files.createDirectories(absolutePath.getParent()); + Class.forName("org.sqlite.JDBC", true, SqliteOutbox.class.getClassLoader()); + connection = DriverManager.getConnection("jdbc:sqlite:" + absolutePath); + initialize(); + } + + private void initialize() throws SQLException { + try (var statement = connection.createStatement()) { + statement.execute("PRAGMA journal_mode=WAL"); + statement.execute("PRAGMA synchronous=FULL"); + statement.execute("PRAGMA busy_timeout=5000"); + statement.execute(""" + CREATE TABLE IF NOT EXISTS pending_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + event_id TEXT NOT NULL UNIQUE, + payload TEXT NOT NULL, + byte_size INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + """); + } + } + + public synchronized void append(OutboundEvent event) throws SQLException { + try (var statement = connection.prepareStatement( + "INSERT OR IGNORE INTO pending_events(event_id, payload, byte_size) VALUES (?, ?, ?)")) { + statement.setString(1, event.id()); + statement.setString(2, event.payload()); + statement.setInt(3, event.byteSize()); + statement.executeUpdate(); + } + } + + public synchronized List peekBatch(int maxEvents, int maxBytes) throws SQLException { + if (maxEvents < 1 || maxBytes < 1) { + throw new IllegalArgumentException("Batch limits must be positive"); + } + List events = new ArrayList<>(); + int bytes = 0; + try (var statement = connection.prepareStatement(""" + SELECT sequence, event_id, payload, byte_size + FROM pending_events + ORDER BY sequence + LIMIT ? + """)) { + statement.setInt(1, maxEvents); + try (var rows = statement.executeQuery()) { + while (rows.next()) { + int eventBytes = rows.getInt("byte_size"); + if (bytes + eventBytes > maxBytes) { + if (events.isEmpty()) { + throw new SQLException( + "Outbox event " + rows.getString("event_id") + + " is larger than the configured batch byte limit"); + } + break; + } + events.add(new StoredEvent( + rows.getLong("sequence"), + rows.getString("event_id"), + rows.getString("payload"), + eventBytes)); + bytes += eventBytes; + } + } + } + return List.copyOf(events); + } + + public synchronized void acknowledgeThrough(long sequence) throws SQLException { + try (var statement = connection.prepareStatement( + "DELETE FROM pending_events WHERE sequence <= ?")) { + statement.setLong(1, sequence); + statement.executeUpdate(); + } + } + + public synchronized long pendingCount() throws SQLException { + try (var statement = connection.createStatement(); + var rows = statement.executeQuery("SELECT COUNT(*) FROM pending_events")) { + return rows.getLong(1); + } + } + + public synchronized long pendingBytes() throws SQLException { + try (var statement = connection.createStatement(); + var rows = statement.executeQuery( + "SELECT COALESCE(SUM(byte_size), 0) FROM pending_events")) { + return rows.getLong(1); + } + } + + @Override + public synchronized void close() throws SQLException { + connection.close(); + } +} diff --git a/src/main/java/games/dmg/spigotevents/outbox/StoredEvent.java b/src/main/java/games/dmg/spigotevents/outbox/StoredEvent.java new file mode 100644 index 0000000..9b07fb3 --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/outbox/StoredEvent.java @@ -0,0 +1,4 @@ +package games.dmg.spigotevents.outbox; + +public record StoredEvent(long sequence, String eventId, String payload, int byteSize) { +} diff --git a/src/main/java/games/dmg/spigotevents/stats/MapChunker.java b/src/main/java/games/dmg/spigotevents/stats/MapChunker.java new file mode 100644 index 0000000..17601f4 --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/stats/MapChunker.java @@ -0,0 +1,55 @@ +package games.dmg.spigotevents.stats; + +import com.google.gson.Gson; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class MapChunker { + private static final Gson GSON = new Gson(); + + private MapChunker() { + } + + public static List> chunk(Map values, int maxJsonBytes) { + if (maxJsonBytes < 2) { + throw new IllegalArgumentException("Maximum JSON bytes must be at least 2"); + } + List> chunks = new ArrayList<>(); + Map current = new LinkedHashMap<>(); + int currentBytes = 2; // Opening and closing braces. + + for (var entry : values.entrySet()) { + Map singleton = new LinkedHashMap<>(); + singleton.put(entry.getKey(), entry.getValue()); + int entryBytes = jsonBytes(singleton) - 2; + int separatorBytes = current.isEmpty() ? 0 : 1; + if (currentBytes + separatorBytes + entryBytes > maxJsonBytes) { + if (current.isEmpty()) { + throw new IllegalArgumentException("One map entry exceeds the chunk byte limit"); + } + chunks.add(immutableCopy(current)); + current = new LinkedHashMap<>(); + currentBytes = 2; + separatorBytes = 0; + } + current.put(entry.getKey(), entry.getValue()); + currentBytes += separatorBytes + entryBytes; + } + if (!current.isEmpty()) { + chunks.add(immutableCopy(current)); + } + return List.copyOf(chunks); + } + + private static Map immutableCopy(Map values) { + return Collections.unmodifiableMap(new LinkedHashMap<>(values)); + } + + private static int jsonBytes(Map values) { + return GSON.toJson(values).getBytes(StandardCharsets.UTF_8).length; + } +} diff --git a/src/main/java/games/dmg/spigotevents/stats/PlayerSnapshotCollector.java b/src/main/java/games/dmg/spigotevents/stats/PlayerSnapshotCollector.java new file mode 100644 index 0000000..b0c2558 --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/stats/PlayerSnapshotCollector.java @@ -0,0 +1,210 @@ +package games.dmg.spigotevents.stats; + +import games.dmg.spigotevents.delivery.EventPipeline; +import games.dmg.spigotevents.event.CloudEventFactory; +import games.dmg.spigotevents.event.PlayerIdentity; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.Statistic; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; + +public final class PlayerSnapshotCollector { + private static final Statistic[] STATISTICS = Statistic.values(); + private static final Material[] MATERIALS = Material.values(); + private static final EntityType[] ENTITY_TYPES = EntityType.values(); + + private final CloudEventFactory eventFactory; + private final EventPipeline pipeline; + private final int statisticChunkBytes; + + public PlayerSnapshotCollector( + CloudEventFactory eventFactory, + EventPipeline pipeline, + int statisticChunkBytes) { + this.eventFactory = eventFactory; + this.pipeline = pipeline; + this.statisticChunkBytes = statisticChunkBytes; + } + + public void captureLocation(Player player) { + Location location = player.getLocation(); + Map data = new LinkedHashMap<>(); + data.put("world_id", location.getWorld().getUID().toString()); + data.put("world_name", location.getWorld().getName()); + data.put("x", location.getX()); + data.put("y", location.getY()); + data.put("z", location.getZ()); + data.put("yaw", location.getYaw()); + data.put("pitch", location.getPitch()); + data.put("game_mode", player.getGameMode().name().toLowerCase()); + var biome = location.getBlock().getBiome(); + var biomeKey = biome.getKeyOrNull(); + data.put("biome", biomeKey == null ? biome.toString() : biomeKey.toString()); + publish("player.location", identity(player), data); + } + + public StatisticsCapture beginStatistics(Player player) { + return new StatisticsCapture(identity(player)); + } + + private void finishStatistics(StatisticsCapture capture) { + String completedAt = Instant.now().toString(); + publishStatisticCategory(capture, completedAt, "untyped", capture.untyped); + publishStatisticCategory(capture, completedAt, "block", capture.blocks); + publishStatisticCategory(capture, completedAt, "item", capture.items); + publishStatisticCategory(capture, completedAt, "entity", capture.entities); + } + + private void publishStatisticCategory( + StatisticsCapture capture, + String completedAt, + String category, + Map statistics) { + List> chunks = MapChunker.chunk(statistics, statisticChunkBytes); + if (chunks.isEmpty()) { + chunks = List.of(Map.of()); + } + for (int index = 0; index < chunks.size(); index++) { + Map data = new LinkedHashMap<>(); + data.put("snapshot_id", capture.snapshotId.toString()); + data.put("capture_started_at", capture.startedAt); + data.put("capture_completed_at", completedAt); + data.put("category", category); + data.put("part", index + 1); + data.put("parts", chunks.size()); + data.put("statistics", chunks.get(index)); + publish("player.statistics", capture.player, data); + } + } + + private void publish(String type, PlayerIdentity player, Map data) { + pipeline.publish(eventFactory.create(type, player, data)); + } + + private static PlayerIdentity identity(Player player) { + return new PlayerIdentity(player.getUniqueId(), player.getName()); + } + + public final class StatisticsCapture { + private final PlayerIdentity player; + private final UUID snapshotId = UUID.randomUUID(); + private final String startedAt = Instant.now().toString(); + private final Map untyped = new LinkedHashMap<>(); + private final Map blocks = new LinkedHashMap<>(); + private final Map items = new LinkedHashMap<>(); + private final Map entities = new LinkedHashMap<>(); + private int statisticIndex; + private int subtypeIndex; + private boolean complete; + + private StatisticsCapture(PlayerIdentity player) { + this.player = player; + } + + public UUID playerId() { + return player.id(); + } + + public boolean process(Player currentPlayer, int operationBudget) { + if (complete) { + return true; + } + if (!currentPlayer.getUniqueId().equals(player.id())) { + throw new IllegalArgumentException("Statistics player does not match this capture"); + } + + int operations = 0; + while (operations < operationBudget && statisticIndex < STATISTICS.length) { + Statistic statistic = STATISTICS[statisticIndex]; + switch (statistic.getType()) { + case UNTYPED -> { + readUntyped(currentPlayer, statistic, untyped); + operations++; + advanceStatistic(); + } + case BLOCK -> operations += processMaterials( + currentPlayer, statistic, blocks, true, operationBudget - operations); + case ITEM -> operations += processMaterials( + currentPlayer, statistic, items, false, operationBudget - operations); + case ENTITY -> operations += processEntities( + currentPlayer, statistic, entities, operationBudget - operations); + } + } + + if (statisticIndex >= STATISTICS.length) { + complete = true; + finishStatistics(this); + } + return complete; + } + + private int processMaterials( + Player currentPlayer, + Statistic statistic, + Map target, + boolean blocksOnly, + int budget) { + int operations = 0; + while (operations < budget && subtypeIndex < MATERIALS.length) { + Material material = MATERIALS[subtypeIndex++]; + operations++; + if ((blocksOnly && !material.isBlock()) || (!blocksOnly && !material.isItem())) { + continue; + } + try { + target.put( + statistic.getKey() + "/" + material.getKeyOrThrow(), + currentPlayer.getStatistic(statistic, material)); + } catch (IllegalArgumentException ignored) { + // Bukkit rejects combinations not represented by the game. + } + } + if (subtypeIndex >= MATERIALS.length) { + advanceStatistic(); + } + return operations; + } + + private int processEntities( + Player currentPlayer, + Statistic statistic, + Map target, + int budget) { + int operations = 0; + while (operations < budget && subtypeIndex < ENTITY_TYPES.length) { + EntityType entityType = ENTITY_TYPES[subtypeIndex++]; + operations++; + try { + target.put( + statistic.getKey() + "/" + entityType.getKeyOrThrow(), + currentPlayer.getStatistic(statistic, entityType)); + } catch (IllegalArgumentException ignored) { + // Bukkit rejects unsupported entity types. + } + } + if (subtypeIndex >= ENTITY_TYPES.length) { + advanceStatistic(); + } + return operations; + } + + private void advanceStatistic() { + statisticIndex++; + subtypeIndex = 0; + } + } + + private static void readUntyped(Player player, Statistic statistic, Map target) { + try { + target.put(statistic.getKey().toString(), player.getStatistic(statistic)); + } catch (IllegalArgumentException ignored) { + // Bukkit rejects statistics not represented by this game version. + } + } +} diff --git a/src/main/java/games/dmg/spigotevents/stats/SnapshotScheduler.java b/src/main/java/games/dmg/spigotevents/stats/SnapshotScheduler.java new file mode 100644 index 0000000..a442a7b --- /dev/null +++ b/src/main/java/games/dmg/spigotevents/stats/SnapshotScheduler.java @@ -0,0 +1,90 @@ +package games.dmg.spigotevents.stats; + +import java.util.ArrayDeque; +import java.util.HashSet; +import java.util.Queue; +import java.util.Set; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitTask; + +public final class SnapshotScheduler implements AutoCloseable { + private final Plugin plugin; + private final PlayerSnapshotCollector collector; + private final long intervalTicks; + private final int statisticOperationsPerTick; + private final Queue statisticQueue = new ArrayDeque<>(); + private final Set queuedPlayers = new HashSet<>(); + private PlayerSnapshotCollector.StatisticsCapture activeCapture; + private BukkitTask snapshotTask; + private BukkitTask statisticWorkerTask; + + public SnapshotScheduler( + Plugin plugin, + PlayerSnapshotCollector collector, + long intervalSeconds, + int statisticOperationsPerTick) { + this.plugin = plugin; + this.collector = collector; + this.intervalTicks = Math.multiplyExact(intervalSeconds, 20L); + this.statisticOperationsPerTick = statisticOperationsPerTick; + } + + public void start() { + snapshotTask = Bukkit.getScheduler().runTaskTimer( + plugin, this::beginSnapshot, intervalTicks, intervalTicks); + statisticWorkerTask = Bukkit.getScheduler().runTaskTimer( + plugin, this::continueStatistics, 1L, 1L); + } + + private void beginSnapshot() { + for (Player player : Bukkit.getOnlinePlayers()) { + collector.captureLocation(player); + if (queuedPlayers.add(player.getUniqueId())) { + statisticQueue.add(player.getUniqueId()); + } + } + } + + private void continueStatistics() { + if (activeCapture == null) { + UUID nextPlayer = statisticQueue.poll(); + if (nextPlayer == null) { + return; + } + Player player = Bukkit.getPlayer(nextPlayer); + if (player == null || !player.isOnline()) { + queuedPlayers.remove(nextPlayer); + return; + } + activeCapture = collector.beginStatistics(player); + } + + UUID playerId = activeCapture.playerId(); + Player player = Bukkit.getPlayer(playerId); + if (player == null || !player.isOnline()) { + queuedPlayers.remove(playerId); + activeCapture = null; + return; + } + if (activeCapture.process(player, statisticOperationsPerTick)) { + queuedPlayers.remove(playerId); + activeCapture = null; + } + } + + @Override + public void close() { + if (snapshotTask != null) { + snapshotTask.cancel(); + } + if (statisticWorkerTask != null) { + statisticWorkerTask.cancel(); + } + activeCapture = null; + statisticQueue.clear(); + queuedPlayers.clear(); + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..1b99702 --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,28 @@ +# A human-readable stable name for this Minecraft server. +server-name: "minecraft-server" + +# Generated once and persisted here when left empty. Do not copy the generated +# value to another server; it is the stable identity used in CloudEvent source. +server-id: "" + +ingest-url: "https://events.dmg.games/events" +send-interval-seconds: 300 +snapshot-interval-seconds: 60 +http-timeout-seconds: 15 + +batch: + max-events: 1000 + # Leaves headroom below the ingest server's default 1 MiB request limit. + max-bytes: 900000 + +# A send is also requested when either threshold is reached. This decision and +# all HTTP/SQLite work happen on the outbox worker, never the server thread. +dispatch-threshold: + events: 1000 + bytes: 900000 + +statistics: + # Statistic maps are partitioned before becoming CloudEvents. + chunk-json-bytes: 200000 + # Maximum statistic/material/entity combinations read on the main thread per tick. + operations-per-tick: 1000 diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..6d2e56e --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,6 @@ +name: SpigotEventProducer +version: '${version}' +main: games.dmg.spigotevents.SpigotEventProducerPlugin +api-version: '26.2' +author: dmg.games +description: Writes Minecraft CloudEvents to a durable outbox and delivers them to game-ingest-server. diff --git a/src/test/java/games/dmg/spigotevents/chat/PrivateMessageParserTest.java b/src/test/java/games/dmg/spigotevents/chat/PrivateMessageParserTest.java new file mode 100644 index 0000000..0446929 --- /dev/null +++ b/src/test/java/games/dmg/spigotevents/chat/PrivateMessageParserTest.java @@ -0,0 +1,27 @@ +package games.dmg.spigotevents.chat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class PrivateMessageParserTest { + @Test + void parsesAnExplicitPrivateMessageWithoutLosingItsText() { + var parsed = PrivateMessageParser.parse("/msg Alex hello from spawn"); + + assertTrue(parsed.isPresent()); + assertEquals("msg", parsed.orElseThrow().command()); + assertEquals("Alex", parsed.orElseThrow().recipient()); + assertEquals("hello from spawn", parsed.orElseThrow().message()); + } + + @Test + void representsReplyCommandsWithoutInventingARecipient() { + var parsed = PrivateMessageParser.parse("/reply same place as before"); + + assertTrue(parsed.isPresent()); + assertTrue(parsed.orElseThrow().recipient().isEmpty()); + assertEquals("same place as before", parsed.orElseThrow().message()); + } +} diff --git a/src/test/java/games/dmg/spigotevents/delivery/EventPipelineTest.java b/src/test/java/games/dmg/spigotevents/delivery/EventPipelineTest.java new file mode 100644 index 0000000..4415433 --- /dev/null +++ b/src/test/java/games/dmg/spigotevents/delivery/EventPipelineTest.java @@ -0,0 +1,51 @@ +package games.dmg.spigotevents.delivery; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import games.dmg.spigotevents.event.OutboundEvent; +import games.dmg.spigotevents.outbox.SqliteOutbox; +import java.nio.file.Path; +import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class EventPipelineTest { + @TempDir + Path temporaryDirectory; + + @Test + void persistsNewEventsWhileAnHttpDeliveryIsBlocked() throws Exception { + CountDownLatch deliveryStarted = new CountDownLatch(1); + CountDownLatch releaseDelivery = new CountDownLatch(1); + try (var outbox = new SqliteOutbox(temporaryDirectory.resolve("outbox.db"))) { + BatchSender blockedSender = events -> { + deliveryStarted.countDown(); + releaseDelivery.await(5, TimeUnit.SECONDS); + return false; + }; + var dispatcher = new OutboxDispatcher(outbox, blockedSender, 1_000, 900_000); + var pipeline = new EventPipeline( + outbox, + dispatcher, + Duration.ofMinutes(5), + 1, + 900_000, + (message, error) -> { }); + try { + pipeline.publish(new OutboundEvent("one", "{\"id\":\"one\"}")); + assertTrue(deliveryStarted.await(2, TimeUnit.SECONDS)); + + pipeline.publishAndWait( + new OutboundEvent("two", "{\"id\":\"two\"}"), Duration.ofSeconds(2)); + + assertEquals(2, outbox.pendingCount()); + } finally { + releaseDelivery.countDown(); + pipeline.close(); + } + } + } +} diff --git a/src/test/java/games/dmg/spigotevents/delivery/OutboxDeliveryTest.java b/src/test/java/games/dmg/spigotevents/delivery/OutboxDeliveryTest.java new file mode 100644 index 0000000..313cd26 --- /dev/null +++ b/src/test/java/games/dmg/spigotevents/delivery/OutboxDeliveryTest.java @@ -0,0 +1,58 @@ +package games.dmg.spigotevents.delivery; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.sun.net.httpserver.HttpServer; +import games.dmg.spigotevents.event.OutboundEvent; +import games.dmg.spigotevents.outbox.SqliteOutbox; +import java.net.InetSocketAddress; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Queue; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class OutboxDeliveryTest { + @TempDir + Path temporaryDirectory; + + @Test + void sendsNdjsonAndOnlyAcknowledgesAnAcceptedBatch() throws Exception { + Queue responses = new ArrayDeque<>(); + responses.add(500); + responses.add(202); + AtomicReference requestBody = new AtomicReference<>(); + AtomicReference contentType = new AtomicReference<>(); + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/events", exchange -> { + requestBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)); + contentType.set(exchange.getRequestHeaders().getFirst("Content-Type")); + int status = responses.remove(); + exchange.sendResponseHeaders(status, -1); + exchange.close(); + }); + server.start(); + + try (var outbox = new SqliteOutbox(temporaryDirectory.resolve("outbox.db"))) { + outbox.append(new OutboundEvent("one", "{\"id\":\"one\"}")); + var client = new IngestClient( + URI.create("http://127.0.0.1:" + server.getAddress().getPort() + "/events"), + Duration.ofSeconds(2)); + var dispatcher = new OutboxDispatcher(outbox, client, 1_000, 900_000); + + dispatcher.dispatchOnce(); + assertEquals(1, outbox.pendingCount()); + + dispatcher.dispatchOnce(); + assertEquals(0, outbox.pendingCount()); + assertEquals("application/x-ndjson", contentType.get()); + assertEquals("{\"id\":\"one\"}\n", requestBody.get()); + } finally { + server.stop(0); + } + } +} diff --git a/src/test/java/games/dmg/spigotevents/event/CloudEventFactoryTest.java b/src/test/java/games/dmg/spigotevents/event/CloudEventFactoryTest.java new file mode 100644 index 0000000..60d7d8b --- /dev/null +++ b/src/test/java/games/dmg/spigotevents/event/CloudEventFactoryTest.java @@ -0,0 +1,40 @@ +package games.dmg.spigotevents.event; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.google.gson.JsonParser; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class CloudEventFactoryTest { + @Test + void createsAnIngestCompatiblePlayerCloudEvent() { + UUID serverId = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + UUID playerId = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + var factory = new CloudEventFactory( + new ServerIdentity(serverId, "survival-one"), + "26.2", + Clock.fixed(Instant.parse("2026-08-01T10:15:30Z"), ZoneOffset.UTC), + () -> UUID.fromString("cccccccc-cccc-cccc-cccc-cccccccccccc")); + + OutboundEvent event = factory.create( + "chat", + new PlayerIdentity(playerId, "Alex"), + Map.of("message", "hello \"world\"")); + var json = JsonParser.parseString(event.payload()).getAsJsonObject(); + + assertEquals("1.0", json.get("specversion").getAsString()); + assertEquals("cccccccc-cccc-cccc-cccc-cccccccccccc", json.get("id").getAsString()); + assertEquals("urn:minecraft-server:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", json.get("source").getAsString()); + assertEquals("games.dmg.minecraft.chat", json.get("type").getAsString()); + assertEquals("2026-08-01T10:15:30Z", json.get("time").getAsString()); + assertEquals("survival-one", json.get("servername").getAsString()); + assertEquals(playerId.toString(), json.get("playerid").getAsString()); + assertEquals("Alex", json.getAsJsonObject("data").get("player_name").getAsString()); + assertEquals("hello \"world\"", json.getAsJsonObject("data").get("message").getAsString()); + } +} diff --git a/src/test/java/games/dmg/spigotevents/outbox/SqliteOutboxTest.java b/src/test/java/games/dmg/spigotevents/outbox/SqliteOutboxTest.java new file mode 100644 index 0000000..1f60139 --- /dev/null +++ b/src/test/java/games/dmg/spigotevents/outbox/SqliteOutboxTest.java @@ -0,0 +1,34 @@ +package games.dmg.spigotevents.outbox; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import games.dmg.spigotevents.event.OutboundEvent; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SqliteOutboxTest { + @TempDir + Path temporaryDirectory; + + @Test + void retainsEventsAcrossRestartAndAcknowledgesOnlyTheSentPrefix() throws Exception { + Path database = temporaryDirectory.resolve("outbox.db"); + try (var outbox = new SqliteOutbox(database)) { + outbox.append(new OutboundEvent("one", "{\"id\":\"one\"}")); + outbox.append(new OutboundEvent("two", "{\"id\":\"two\"}")); + } + + try (var reopened = new SqliteOutbox(database)) { + var batch = reopened.peekBatch(1, 1_000); + assertEquals(1, batch.size()); + assertEquals("one", batch.get(0).eventId()); + assertEquals(2, reopened.pendingCount()); + + reopened.acknowledgeThrough(batch.get(0).sequence()); + + assertEquals(1, reopened.pendingCount()); + assertEquals("two", reopened.peekBatch(10, 1_000).get(0).eventId()); + } + } +} diff --git a/src/test/java/games/dmg/spigotevents/stats/MapChunkerTest.java b/src/test/java/games/dmg/spigotevents/stats/MapChunkerTest.java new file mode 100644 index 0000000..7079305 --- /dev/null +++ b/src/test/java/games/dmg/spigotevents/stats/MapChunkerTest.java @@ -0,0 +1,31 @@ +package games.dmg.spigotevents.stats; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.google.gson.Gson; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class MapChunkerTest { + @Test + void splitsLargeStatisticMapsWithoutDroppingValues() { + Map statistics = new LinkedHashMap<>(); + for (int index = 0; index < 100; index++) { + statistics.put("minecraft:test_statistic_" + index, index); + } + + var chunks = MapChunker.chunk(statistics, 250); + Map reconstructed = new LinkedHashMap<>(); + Gson gson = new Gson(); + for (var chunk : chunks) { + assertTrue(gson.toJson(chunk).getBytes(StandardCharsets.UTF_8).length <= 250); + reconstructed.putAll(chunk); + } + + assertTrue(chunks.size() > 1); + assertEquals(statistics, reconstructed); + } +}