4 Commits
Author SHA1 Message Date
dmg bd48e33ace fix(stats): skip registry entries without keys
Release / release (push) Successful in 2m18s
CI / build (push) Successful in 1m3s
2026-08-08 14:52:01 -04:00
dmg 03acea6ba5 fix(stats): use compatible Bukkit registry keys
Release / release (push) Successful in 2m18s
CI / build (push) Successful in 1m6s
2026-08-08 12:51:55 -04:00
dmg c76ea5b434 docs(design): add user story knowledge bundle
CI / build (push) Successful in 3m35s
Release / release (push) Successful in 4m6s
2026-08-08 12:31:15 -04:00
dmg baed3b4901 ci: harden artifact and release tooling
CI / build (push) Successful in 1m25s
Release / release (push) Successful in 1m51s
2026-08-01 11:55:45 -04:00
14 changed files with 270 additions and 14 deletions
+1 -7
View File
@@ -32,15 +32,9 @@ jobs:
- name: Build and test - name: Build and test
run: ./gradlew clean check jar 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 - name: Upload development artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: spigot-event-producer-${{ github.sha }} name: spigot-event-producer-${{ github.sha }}
path: build/libs/spigot-event-producer-dev-*.jar path: build/libs/*.jar
if-no-files-found: error if-no-files-found: error
+3 -3
View File
@@ -46,9 +46,9 @@ jobs:
GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }} GITHUB_TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: | run: |
npx -y \ npx -y \
-p semantic-release \ -p semantic-release@24.2.9 \
-p @semantic-release/commit-analyzer \ -p @semantic-release/commit-analyzer@13.0.1 \
-p @semantic-release/release-notes-generator \ -p @semantic-release/release-notes-generator@14.1.0 \
semantic-release \ semantic-release \
--branches main \ --branches main \
--plugins @semantic-release/commit-analyzer,@semantic-release/release-notes-generator --plugins @semantic-release/commit-analyzer,@semantic-release/release-notes-generator
+16
View File
@@ -0,0 +1,16 @@
---
type: Index
title: Spigot Event Producer Design
description: Entry point for the Spigot Event Producer OKF knowledge bundle.
okf_version: "0.1"
---
# Spigot Event Producer Design
This bundle documents the user-visible and operational behavior of the Spigot plugin that captures Minecraft activity and delivers it as CloudEvents.
## Explore
- [User stories](user-stories/index.md)
- [Design log](log.md)
- [Project README](../README.md)
+13
View File
@@ -0,0 +1,13 @@
---
type: Log
title: Design Log
description: Chronological record of material changes to the design knowledge bundle.
---
# Design Log
## 2026-08-08
- Established the OKF v0.1 design bundle.
- Documented the existing configuration, event production, activity capture, snapshots, durable delivery, and release workflows as user stories.
- Made snapshot collection skip Bukkit registry entries whose keys are unavailable.
+16
View File
@@ -0,0 +1,16 @@
---
type: Index
title: User Stories
description: Catalog of user stories for the Spigot Event Producer.
---
# User Stories
1. [US-001: Configure the event producer](us-001-configure-event-producer.md)
2. [US-002: Produce standard CloudEvents](us-002-produce-standard-cloud-events.md)
3. [US-003: Capture player and server activity](us-003-capture-player-activity.md)
4. [US-004: Capture periodic player snapshots](us-004-capture-player-snapshots.md)
5. [US-005: Deliver events durably](us-005-deliver-events-durably.md)
6. [US-006: Build and release the plugin](us-006-build-and-release-plugin.md)
Return to the [design index](../index.md).
@@ -0,0 +1,24 @@
---
type: User Story
title: "US-001: Configure the event producer"
description: Allow a server operator to identify a Minecraft server and tune event capture and delivery safely.
status: done
---
# US-001: Configure the event producer
As a **Minecraft server operator**, I want to configure the producer for my server and ingest service so that emitted events are identifiable and fit my operating limits.
## Acceptance criteria
- [x] A human-readable server name and stable server UUID can be configured.
- [x] When no server UUID is supplied, the plugin generates one and persists it for later starts.
- [x] The ingest URL, send interval, snapshot interval, HTTP timeout, batch limits, dispatch thresholds, statistic chunk size, and per-tick statistic budget are configurable.
- [x] Blank required values, non-positive limits, overlong server names, invalid UUIDs, and statistic chunks without sufficient batch headroom prevent initialization.
- [x] Initialization errors are logged and leave the plugin disabled rather than partially running.
## Related
- [US-002: Produce standard CloudEvents](us-002-produce-standard-cloud-events.md)
- [US-005: Deliver events durably](us-005-deliver-events-durably.md)
- [Configuration reference](../../README.md#configuration)
@@ -0,0 +1,25 @@
---
type: User Story
title: "US-002: Produce standard CloudEvents"
description: Represent captured Minecraft activity as consistently identifiable CloudEvents 1.0 records.
status: done
---
# US-002: Produce standard CloudEvents
As an **event consumer**, I want Minecraft activity represented by a consistent CloudEvents envelope so that I can process records from multiple servers and players reliably.
## Acceptance criteria
- [x] Every record is a structured CloudEvents 1.0 JSON object with a unique stable event ID, UTC time, source, type, and JSON content type.
- [x] Event types use the `games.dmg.minecraft.*` namespace.
- [x] Every event includes the stable server ID, server name, and game version as extensions and in its data.
- [x] Player events include the player UUID as an extension and include the player UUID and current name in their data.
- [x] An event retains the same ID and payload while it is retried from the outbox.
## Related
- [US-001: Configure the event producer](us-001-configure-event-producer.md)
- [US-003: Capture player and server activity](us-003-capture-player-activity.md)
- [US-004: Capture periodic player snapshots](us-004-capture-player-snapshots.md)
- [Event reference](../../README.md#events)
@@ -0,0 +1,26 @@
---
type: User Story
title: "US-003: Capture player and server activity"
description: Record server lifecycle, player sessions, public chat, and recognized private messages.
status: done
---
# US-003: Capture player and server activity
As a **server analyst**, I want significant server and player activity captured so that I can reconstruct participation and communication activity downstream.
## Acceptance criteria
- [x] Plugin startup and shutdown produce server lifecycle events.
- [x] Player joins and quits produce events with player identity.
- [x] Non-cancelled public chat produces an event containing the complete message and whether the Bukkit event was asynchronous.
- [x] Recognized explicit private-message commands capture the command, recipient token, and complete message.
- [x] Recognized reply commands are marked as replies and omit a recipient.
- [x] Private-message observations report whether the underlying command was cancelled.
- [x] Standard aliases and namespaced variants documented by the project are recognized.
## Related
- [US-002: Produce standard CloudEvents](us-002-produce-standard-cloud-events.md)
- [US-005: Deliver events durably](us-005-deliver-events-durably.md)
- [Chat and private-message behavior](../../README.md#chat-and-private-messages)
@@ -0,0 +1,29 @@
---
type: User Story
title: "US-004: Capture periodic player snapshots"
description: Periodically capture player location and comprehensive Bukkit statistics without monopolizing a server tick.
status: done
---
# US-004: Capture periodic player snapshots
As a **server analyst**, I want periodic player location and statistic snapshots so that I can analyze movement and gameplay progression over time.
## Acceptance criteria
- [x] Each configured interval captures every online player's world identity, coordinates, orientation, game mode, and biome.
- [x] Registry-backed snapshot values use Bukkit's stable `Keyed` contract so collection remains compatible with the supported Purpur runtime.
- [x] Unkeyed registry entries such as `EntityType.UNKNOWN` are skipped without throwing or interrupting snapshot collection.
- [x] Every Bukkit untyped, block, item, and entity statistic combination accepted by Bukkit is captured, including zero values.
- [x] Invalid statistic subtype combinations are skipped without stopping the snapshot.
- [x] Statistic reads are spread across ticks according to a configurable operation budget and run through Bukkit scheduling.
- [x] A player is not queued for a second statistic capture while an earlier capture remains queued or active.
- [x] Statistics are partitioned by category and configured JSON size, with shared snapshot metadata and one-based part information.
- [x] Empty statistic categories still emit an explicit part.
- [x] Offline players are removed safely from queued or active capture work.
## Related
- [US-001: Configure the event producer](us-001-configure-event-producer.md)
- [US-002: Produce standard CloudEvents](us-002-produce-standard-cloud-events.md)
- [Snapshot behavior](../../README.md#location-snapshots)
@@ -0,0 +1,27 @@
---
type: User Story
title: "US-005: Deliver events durably"
description: Persist events locally and deliver ordered, bounded batches without blocking the Minecraft server thread.
status: done
---
# US-005: Deliver events durably
As a **server operator**, I want captured events buffered durably and retried after ingest failures so that temporary outages do not silently lose activity.
## Acceptance criteria
- [x] Events are persisted in an ordered local SQLite outbox before delivery.
- [x] Persistence and HTTP delivery use dedicated workers rather than performing SQLite or network work on the Bukkit server thread.
- [x] Delivery uses newline-delimited JSON batches bounded by configured event and byte limits.
- [x] Delivery is requested periodically and may also be requested when an event-count or byte threshold is reached.
- [x] Events are acknowledged and removed only after the ingest endpoint returns HTTP `202 Accepted`.
- [x] Failed deliveries remain in the outbox and are retried with bounded exponential backoff.
- [x] Stable event IDs provide at-least-once delivery semantics suitable for downstream deduplication.
- [x] Shutdown waits for the shutdown event to be persisted and drains pending persistence work before closing the outbox.
## Related
- [US-001: Configure the event producer](us-001-configure-event-producer.md)
- [US-002: Produce standard CloudEvents](us-002-produce-standard-cloud-events.md)
- [Delivery and durability](../../README.md#delivery-and-durability)
@@ -0,0 +1,25 @@
---
type: User Story
title: "US-006: Build and release the plugin"
description: Give maintainers repeatable Java builds, automated verification, and versioned Gitea releases.
status: done
---
# US-006: Build and release the plugin
As a **plugin maintainer**, I want automated builds and releases so that tested, correctly versioned plugin artifacts can be distributed consistently.
## Acceptance criteria
- [x] The Gradle project compiles against Java 17 and the supported Spigot API.
- [x] Automated tests run as part of the Gradle check lifecycle.
- [x] Pushes and pull requests build and test the plugin in Gitea Actions.
- [x] Pull requests validate conventional commit messages.
- [x] CI stores a development JAR as a workflow artifact.
- [x] Main-branch conventional commits drive semantic versioning.
- [x] A successful release builds a versioned JAR and attaches it to the corresponding Gitea release.
## Related
- [Build and release documentation](../../README.md#releases)
- [User-story catalog](index.md)
@@ -0,0 +1,21 @@
package games.dmg.spigotevents.stats;
import org.bukkit.Keyed;
import org.bukkit.NamespacedKey;
final class BukkitRegistryKeys {
private BukkitRegistryKeys() {}
static NamespacedKey get(Keyed value) {
try {
return value.getKey();
} catch (IllegalArgumentException ignored) {
return null;
}
}
static String format(Keyed value, String fallback) {
NamespacedKey key = get(value);
return key == null ? fallback : key.toString();
}
}
@@ -44,8 +44,7 @@ public final class PlayerSnapshotCollector {
data.put("pitch", location.getPitch()); data.put("pitch", location.getPitch());
data.put("game_mode", player.getGameMode().name().toLowerCase()); data.put("game_mode", player.getGameMode().name().toLowerCase());
var biome = location.getBlock().getBiome(); var biome = location.getBlock().getBiome();
var biomeKey = biome.getKeyOrNull(); data.put("biome", BukkitRegistryKeys.format(biome, biome.toString()));
data.put("biome", biomeKey == null ? biome.toString() : biomeKey.toString());
publish("player.location", identity(player), data); publish("player.location", identity(player), data);
} }
@@ -157,9 +156,13 @@ public final class PlayerSnapshotCollector {
if ((blocksOnly && !material.isBlock()) || (!blocksOnly && !material.isItem())) { if ((blocksOnly && !material.isBlock()) || (!blocksOnly && !material.isItem())) {
continue; continue;
} }
var materialKey = BukkitRegistryKeys.get(material);
if (materialKey == null) {
continue;
}
try { try {
target.put( target.put(
statistic.getKey() + "/" + material.getKeyOrThrow(), statistic.getKey() + "/" + materialKey,
currentPlayer.getStatistic(statistic, material)); currentPlayer.getStatistic(statistic, material));
} catch (IllegalArgumentException ignored) { } catch (IllegalArgumentException ignored) {
// Bukkit rejects combinations not represented by the game. // Bukkit rejects combinations not represented by the game.
@@ -180,9 +183,13 @@ public final class PlayerSnapshotCollector {
while (operations < budget && subtypeIndex < ENTITY_TYPES.length) { while (operations < budget && subtypeIndex < ENTITY_TYPES.length) {
EntityType entityType = ENTITY_TYPES[subtypeIndex++]; EntityType entityType = ENTITY_TYPES[subtypeIndex++];
operations++; operations++;
var entityTypeKey = BukkitRegistryKeys.get(entityType);
if (entityTypeKey == null) {
continue;
}
try { try {
target.put( target.put(
statistic.getKey() + "/" + entityType.getKeyOrThrow(), statistic.getKey() + "/" + entityTypeKey,
currentPlayer.getStatistic(statistic, entityType)); currentPlayer.getStatistic(statistic, entityType));
} catch (IllegalArgumentException ignored) { } catch (IllegalArgumentException ignored) {
// Bukkit rejects unsupported entity types. // Bukkit rejects unsupported entity types.
@@ -0,0 +1,33 @@
package games.dmg.spigotevents.stats;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import org.bukkit.NamespacedKey;
import org.bukkit.Keyed;
import org.junit.jupiter.api.Test;
class BukkitRegistryKeysTest {
@Test
void formatsARegisteredKey() {
Keyed keyed = () -> NamespacedKey.minecraft("plains");
assertEquals("minecraft:plains", BukkitRegistryKeys.format(keyed, "fallback"));
}
@Test
void reportsAnUnavailableKey() {
Keyed keyed = () -> null;
assertNull(BukkitRegistryKeys.get(keyed));
}
@Test
void reportsAKeyThatBukkitRejectsAsUnavailable() {
Keyed keyed = () -> {
throw new IllegalArgumentException("Registry entry does not have a key");
};
assertNull(BukkitRegistryKeys.get(keyed));
}
}