Compare commits

...
4 Commits
Author SHA1 Message Date
dmg 99707958e8 fix(tyrant): support Purpur 26.3 (#1)
Release / release (push) Successful in 1m41s
CI / build (push) Successful in 1m18s
OpenProject: https://project.dmg.games/work_packages/418
2026-10-17 03:53:16 +00:00
dmg 755b9e8d03 fix(tyrant): support Purpur 26.3
CI / build (pull_request) Successful in 1m1s
CI / build (push) Successful in 1m24s
2026-10-16 23:50:55 -04:00
dmg 8cfee3ce39 feat(tyrant): migrate distribution to Java 25 and Purpur
Release / release (push) Successful in 6m13s
CI / build (push) Successful in 31s
BREAKING CHANGE: New releases require Java 25/Purpur 26.2 and use purpur-tyrant-<version>.jar. Runtime SpigotTyrant identity, persisted state and item keys remain unchanged. Replace the old JAR rather than installing both distributions.
2026-09-12 13:03:37 -04:00
dmg f69378fb2b fix(tyrant): synchronize rejected captured-mob transfers
Release / release (push) Successful in 3m14s
CI / build (push) Successful in 1m36s
2026-09-12 09:25:04 -04:00
16 changed files with 788 additions and 34 deletions

No files matched your search

+2 -2
View File
@@ -17,7 +17,7 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 25
cache: gradle
- name: Validate conventional commits
@@ -35,6 +35,6 @@ jobs:
- name: Upload development artifact
uses: actions/upload-artifact@v3
with:
name: spigot-tyrant-${{ github.sha }}
name: purpur-tyrant-${{ github.sha }}
path: build/libs/*.jar
if-no-files-found: error
+5 -5
View File
@@ -21,7 +21,7 @@ jobs:
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 17
java-version: 25
cache: gradle
- name: Set up Node.js
@@ -86,8 +86,8 @@ jobs:
if: steps.release.outputs.created == 'true'
uses: actions/upload-artifact@v3
with:
name: spigot-tyrant-${{ steps.release.outputs.version }}
path: build/libs/spigot-tyrant-${{ steps.release.outputs.version }}.jar
name: purpur-tyrant-${{ steps.release.outputs.version }}
path: build/libs/purpur-tyrant-${{ steps.release.outputs.version }}.jar
if-no-files-found: error
- name: Create Gitea release and upload plugin
@@ -101,7 +101,7 @@ jobs:
PREVIOUS_TAG: ${{ steps.previous_tag.outputs.value }}
run: |
api_url="${GITEA_SERVER_URL}/api/v1"
jar="build/libs/spigot-tyrant-${VERSION}.jar"
jar="build/libs/purpur-tyrant-${VERSION}.jar"
export RELEASE_BODY
if [ -n "$PREVIOUS_TAG" ]; then
RELEASE_BODY=$(git log --pretty='format:- %s (%h)' "${PREVIOUS_TAG}..HEAD")
@@ -138,4 +138,4 @@ jobs:
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${jar}" \
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=spigot-tyrant-${VERSION}.jar"
"${api_url}/repos/${REPOSITORY}/releases/${release_id}/assets?name=purpur-tyrant-${VERSION}.jar"
+3 -3
View File
@@ -1,9 +1,9 @@
# spigot-tyrant agent entrypoint
# purpur-tyrant agent entrypoint
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/spigot-tyrant/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/spigot-tyrant/` stories. Also follow the parent workspace `AGENTS.md` when present.
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/purpur-tyrant/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/purpur-tyrant/` stories. Also follow the parent workspace `AGENTS.md` when present.
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/spigot-tyrant/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-tyrant/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
+23
View File
@@ -0,0 +1,23 @@
# Purpur Tyrant
The asymmetric Tyrant/Vigilante game plugin, targeting Java 25 and Purpur 26.3 build 2651.
## Build and releases
```sh
./gradlew clean check jar
```
New artifacts use `build/libs/purpur-tyrant-<version>.jar`. Gitea CI checks pushes/pull requests; approved conventional commits on main drive versioned releases. Distribution tests inspect the actual JAR, version, Java 25 bytecode, retained runtime identity and workflow names.
The full verification command compiles against Purpur API `26.3.build.2651-stable`, downloads build 2651 from the Purpur API, verifies SHA-256 `0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598`, and starts a clean loopback-only Purpur server. The native lifecycle check loads, enables, commands, and disables the packaged plugin while proving persisted role/game state and a captured-entity snapshot survive the cycle. It uses generated build-local server state, not the untracked Compose environment, and is automated compatibility evidence rather than a live-player test.
## Migration compatibility
The repository and checkout are now `purpur-tyrant`, previously `spigot-tyrant`. Runtime identity remains **SpigotTyrant**, including `plugins/SpigotTyrant/`, Java entrypoints/packages, command/permission names and persistent keys for roles, captured mobs and legacy items. The migration changes the build/distribution target, not gameplay or persisted schemas. New releases no longer support Java 17.
Replace the old plugin JAR rather than installing both distributions. Old tags/assets remain available; both the origin GitOps v1.24.0 and separately prepared local v1.25.0 URLs were verified to redirect and download identical bytes after the repository rename. No deployment or GitOps push is implied by publication.
Unrelated local testing files are preserved; their commands may still reference the historical artifact names.
See the [canonical project](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/purpur-tyrant/index.md), [stories](https://git.garvis.dev/dmg/somc-okf/src/branch/main/user-stories/purpur-tyrant/index.md), and [development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md).
+69 -5
View File
@@ -1,3 +1,6 @@
import java.net.URI
import java.security.MessageDigest
plugins {
java
}
@@ -9,23 +12,24 @@ version = providers.gradleProperty("releaseVersion")
repositories {
mavenCentral()
maven("https://hub.spigotmc.org/nexus/content/repositories/snapshots/")
maven("https://repo.purpurmc.org/snapshots/")
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
languageVersion = JavaLanguageVersion.of(25)
}
}
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.addAll(listOf("-Xlint:all", "-Werror"))
// Retain supported legacy Bukkit APIs while preserving runtime compatibility.
options.compilerArgs.addAll(listOf("-Xlint:all,-deprecation,-removal", "-Werror"))
}
dependencies {
compileOnly("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
testImplementation("org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT")
testImplementation("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
testImplementation(platform("org.junit:junit-bom:5.13.4"))
testImplementation("org.junit.jupiter:junit-jupiter")
testImplementation("org.mockito:mockito-core:5.18.0")
@@ -35,8 +39,68 @@ dependencies {
tasks.test {
useJUnitPlatform()
dependsOn(tasks.jar)
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
systemProperty("distribution.version", project.version.toString())
}
val nativeRuntime = layout.buildDirectory.dir("native-runtime")
val downloadNativeRuntime = tasks.register("downloadNativeRuntime") {
val launcher = nativeRuntime.map { it.file("purpur-26.3-2651.jar") }
outputs.file(launcher)
doLast {
val file = launcher.get().asFile
file.parentFile.mkdirs()
val connection = URI("https://api.purpurmc.org/v2/purpur/26.3/2651/download")
.toURL().openConnection()
connection.connectTimeout = 30_000
connection.readTimeout = 120_000
val bytes = connection.getInputStream().use { it.readBytes() }
val digest = MessageDigest.getInstance("SHA-256")
.digest(bytes).joinToString("") { "%02x".format(it) }
check(digest == "0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598") {
"Purpur 2651 checksum mismatch"
}
file.writeBytes(bytes)
}
}
val prepareNativeRuntime = tasks.register<JavaExec>("prepareNativeRuntime") {
dependsOn(downloadNativeRuntime)
javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(25) }
classpath = files(nativeRuntime.map { it.file("purpur-26.3-2651.jar") })
mainClass = "io.papermc.paperclip.Main"
jvmArgs("-Dpaperclip.patchonly=true")
workingDir(nativeRuntime)
outputs.dir(nativeRuntime.map { it.dir("versions") })
outputs.dir(nativeRuntime.map { it.dir("libraries") })
}
val nativeTest = sourceSets.create("nativeTest")
dependencies {
add(nativeTest.implementationConfigurationName, platform("org.junit:junit-bom:5.13.4"))
add(nativeTest.implementationConfigurationName, "org.junit.jupiter:junit-jupiter")
add(nativeTest.runtimeOnlyConfigurationName, "org.junit.platform:junit-platform-launcher")
}
val nativeRuntimeJars = files(fileTree(nativeRuntime) {
include("versions/**/*.jar", "libraries/**/*.jar")
}).builtBy(prepareNativeRuntime)
nativeTest.compileClasspath += sourceSets.main.get().output + nativeRuntimeJars
nativeTest.runtimeClasspath += sourceSets.main.get().runtimeClasspath + nativeRuntimeJars
val nativeLifecycleTest = tasks.register<Test>("nativeLifecycleTest") {
description = "Loads, enables, and disables the packaged plugin on the pinned Purpur runtime"
dependsOn(tasks.jar)
testClassesDirs = nativeTest.output.classesDirs
classpath = nativeTest.runtimeClasspath
useJUnitPlatform()
maxHeapSize = "1G"
workingDir(nativeRuntime)
systemProperty("distribution.jar", tasks.jar.get().archiveFile.get().asFile.absolutePath)
systemProperty(
"purpur.launcher",
nativeRuntime.map { it.file("purpur-26.3-2651.jar") }.get().asFile.absolutePath
)
}
tasks.check { dependsOn(nativeLifecycleTest) }
val pluginVersion = version
tasks.processResources {
+1 -1
View File
@@ -1 +1 @@
rootProject.name = "spigot-tyrant"
rootProject.name = "purpur-tyrant"
@@ -31,6 +31,19 @@ public final class CapturedMobInventoryService {
}
}
Set<UUID> retained = new HashSet<>();
ItemStack cursor = player.getItemOnCursor();
if (items.isCapturedMob(cursor)) {
UUID captureId = items.captureId(cursor).orElseThrow();
if (items.owner(cursor).filter(state.playerId()::equals).isPresent()
&& stored.containsKey(captureId) && retained.add(captureId)) {
if (cursor.getAmount() > 1) {
cursor.setAmount(1);
player.setItemOnCursor(cursor);
}
} else {
player.setItemOnCursor(null);
}
}
ItemStack[] contents = player.getInventory().getContents();
for (int index = 0; index < contents.length; index++) {
ItemStack item = contents[index];
@@ -42,6 +55,9 @@ public final class CapturedMobInventoryService {
&& stored.containsKey(captureId) && retained.add(captureId);
if (!valid) {
player.getInventory().setItem(index, null);
} else if (item.getAmount() > 1) {
item.setAmount(1);
player.getInventory().setItem(index, item);
}
}
for (Map.Entry<UUID, CapturedMob> entry : stored.entrySet()) {
@@ -205,7 +205,7 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
getServer().getPluginManager().registerEvents(
new TamerListener(
stateManager, abilityItems, capturedMobItems, capturedMobs,
settings, getServer()
settings, getServer(), task -> getServer().getScheduler().runTask(this, task)
),
this
);
@@ -33,6 +33,8 @@ public final class TamerListener implements Listener {
private final CapturedMobService capturedMobs;
private final PluginSettings settings;
private final Server server;
private final java.util.function.Consumer<Runnable> nextTick;
private final java.util.Set<UUID> pendingInventoryRefresh = new java.util.HashSet<>();
public TamerListener(
TyrantStateManager stateManager,
@@ -40,7 +42,8 @@ public final class TamerListener implements Listener {
CapturedMobItemService capturedItems,
CapturedMobService capturedMobs,
PluginSettings settings,
Server server
Server server,
java.util.function.Consumer<Runnable> nextTick
) {
this.stateManager = stateManager;
this.abilityItems = abilityItems;
@@ -48,6 +51,7 @@ public final class TamerListener implements Listener {
this.capturedMobs = capturedMobs;
this.settings = settings;
this.server = server;
this.nextTick = nextTick;
}
@EventHandler(priority = EventPriority.HIGH)
@@ -162,15 +166,82 @@ public final class TamerListener implements Listener {
@EventHandler
public void onInventoryClick(InventoryClickEvent event) {
if (capturedItems.isCapturedMob(event.getCurrentItem())
|| capturedItems.isCapturedMob(event.getCursor())) {
if (!(event.getWhoClicked() instanceof Player player)
|| event.isShiftClick()
|| event.getClickedInventory() == null
|| !event.getClickedInventory().equals(player.getInventory())) {
event.setCancelled(true);
Player player = event.getWhoClicked() instanceof Player clicked ? clicked : null;
ItemStack swapSource = null;
if (player != null) {
if (event.getHotbarButton() >= 0 && event.getHotbarButton() < 9) {
swapSource = player.getInventory().getItem(event.getHotbarButton());
} else if (event.getClick() == org.bukkit.event.inventory.ClickType.SWAP_OFFHAND) {
swapSource = player.getInventory().getItemInOffHand();
}
}
if (capturedItems.isCapturedMob(event.getCurrentItem())
|| capturedItems.isCapturedMob(event.getCursor()) || capturedItems.isCapturedMob(swapSource)) {
var action = event.getAction();
if (player == null || event.isCancelled() || event.isShiftClick()
|| action == org.bukkit.event.inventory.InventoryAction.MOVE_TO_OTHER_INVENTORY
|| action == org.bukkit.event.inventory.InventoryAction.CLONE_STACK
|| action == org.bukkit.event.inventory.InventoryAction.COLLECT_TO_CURSOR
|| action == org.bukkit.event.inventory.InventoryAction.UNKNOWN
|| event.getClickedInventory() == null
|| !event.getClickedInventory().equals(player.getInventory())
|| !ownedBy(player, event.getCurrentItem()) || !ownedBy(player, event.getCursor())
|| !ownedBy(player, swapSource)) {
event.setCancelled(true);
if (player != null) {
refreshInventory(player);
}
}
}
}
@EventHandler
public void onInventoryDrag(org.bukkit.event.inventory.InventoryDragEvent event) {
if (!capturedItems.isCapturedMob(event.getOldCursor())
&& event.getNewItems().values().stream().noneMatch(capturedItems::isCapturedMob)) {
return;
}
Player player = event.getWhoClicked() instanceof Player dragged ? dragged : null;
if (player == null || event.isCancelled() || !ownedBy(player, event.getOldCursor())
|| event.getNewItems().values().stream().anyMatch(item -> !ownedBy(player, item))
|| event.getRawSlots().stream().anyMatch(raw ->
!player.getInventory().equals(event.getView().getInventory(raw)))) {
event.setCancelled(true);
if (player != null) {
refreshInventory(player);
}
}
}
@EventHandler
public void onInventoryMove(org.bukkit.event.inventory.InventoryMoveItemEvent event) {
if (capturedItems.isCapturedMob(event.getItem())) {
event.setCancelled(true);
}
}
private boolean ownedBy(Player player, ItemStack item) {
return !capturedItems.isCapturedMob(item)
|| capturedItems.owner(item).filter(player.getUniqueId()::equals).isPresent();
}
private void refreshInventory(Player player) {
UUID id = player.getUniqueId();
if (!pendingInventoryRefresh.add(id)) {
return;
}
// The native click transaction must finish before forcing its authoritative contents to the client.
try {
nextTick.accept(() -> {
pendingInventoryRefresh.remove(id);
if (player.isOnline()) {
player.updateInventory();
}
});
} catch (RuntimeException exception) {
pendingInventoryRefresh.remove(id);
throw exception;
}
}
@EventHandler
@@ -0,0 +1,155 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.time.Duration;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.junit.jupiter.api.Test;
/** Exercises the packaged plugin through a real, isolated Purpur server lifecycle. */
final class SpigotTyrantLifecycleNativeTest {
private static final String TYRANT_ID = "11111111-1111-1111-1111-111111111111";
@Test
void packagedPluginLoadsStateAndCapturedEntitySnapshotAcrossEnableAndDisable()
throws Exception {
Path runtime = Path.of("").toAbsolutePath();
Path plugins = Files.createDirectories(runtime.resolve("plugins"));
Path dataDirectory = Files.createDirectories(plugins.resolve("SpigotTyrant"));
Files.copy(
Path.of(System.getProperty("distribution.jar")),
plugins.resolve("purpur-tyrant-native-test.jar"),
StandardCopyOption.REPLACE_EXISTING
);
try (var config = getClass().getClassLoader().getResourceAsStream("config.yml")) {
if (config == null) {
throw new AssertionError("Missing packaged default config");
}
Files.copy(config, dataDirectory.resolve("config.yml"));
}
Files.writeString(dataDirectory.resolve("state.yml"), """
game:
lifecycle: RUNNING
tyrant-id: 11111111-1111-1111-1111-111111111111
tyrant-level: 3
unspent-choices: 2
purchases: [ASSASSIN]
claimed-armor: [HELMET]
players:
11111111-1111-1111-1111-111111111111:
name: NativeTyrant
tyrant-class: TAMER
captured-mobs:
- entity-type: ZOMBIE
data:
snapshot: '{id:"minecraft:zombie",CustomName:"Native"}'
""");
Files.writeString(runtime.resolve("eula.txt"), "eula=true\n");
Files.writeString(runtime.resolve("server.properties"), """
online-mode=false
server-port=0
query.port=0
enable-query=false
enable-rcon=false
spawn-protection=0
view-distance=2
simulation-distance=2
level-name=native-lifecycle-world
motd=Spigot Tyrant native lifecycle
""");
Process process = new ProcessBuilder(
Path.of(System.getProperty("java.home"), "bin", "java").toString(),
"-Xms512M",
"-Xmx1G",
"-jar",
System.getProperty("purpur.launcher"),
"--nogui"
).directory(runtime.toFile()).redirectErrorStream(true).start();
CountDownLatch started = new CountDownLatch(1);
CompletableFuture<String> output = CompletableFuture.supplyAsync(() ->
captureOutput(process, started)
);
try {
assertTrue(started.await(120, TimeUnit.SECONDS), () ->
"Purpur did not finish startup:\n" + completedOutput(output)
);
process.outputWriter(StandardCharsets.UTF_8).append("tyrantadmin status\n")
.append("tyrantadmin grantpoint\n").flush();
awaitPersistedProgress(dataDirectory.resolve("state.yml"));
process.outputWriter(StandardCharsets.UTF_8).append("stop\n").flush();
assertTrue(process.waitFor(60, TimeUnit.SECONDS), "Purpur did not stop cleanly");
} finally {
if (process.isAlive()) {
process.destroyForcibly();
process.waitFor(10, TimeUnit.SECONDS);
}
}
String log = output.get(10, TimeUnit.SECONDS);
assertEquals(0, process.exitValue(), log);
assertTrue(log.contains("Enabling SpigotTyrant"), log);
assertTrue(log.contains("Spigot Tyrant enabled."), log);
assertTrue(log.contains("Lifecycle: RUNNING"), log);
assertTrue(log.contains("Tyrant: " + TYRANT_ID), log);
assertTrue(log.contains("NativeTyrant (" + TYRANT_ID + "): class=TAMER"), log);
assertTrue(log.contains("Disabling SpigotTyrant"), log);
PersistentState saved = new YamlTyrantStateRepository(
dataDirectory.resolve("state.yml")
).load();
assertEquals(4, saved.game().tyrantLevel());
assertEquals(3, saved.game().unspentChoices());
CapturedMob captured = saved.players().values().iterator().next().capturedMobs().getFirst();
assertEquals("ZOMBIE", captured.entityType());
assertEquals(
"{id:\"minecraft:zombie\",CustomName:\"Native\"}",
captured.data().get("snapshot")
);
}
private static String captureOutput(Process process, CountDownLatch started) {
StringBuilder output = new StringBuilder();
try (var reader = new BufferedReader(new InputStreamReader(
process.getInputStream(), StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
output.append(line).append('\n');
if (line.contains("Done (") && line.contains("For help, type")) {
started.countDown();
}
}
return output.toString();
} catch (IOException exception) {
throw new CompletionException(exception);
}
}
private static void awaitPersistedProgress(Path stateFile) throws Exception {
long deadline = System.nanoTime() + Duration.ofSeconds(20).toNanos();
while (System.nanoTime() < deadline) {
PersistentState state = new YamlTyrantStateRepository(stateFile).load();
if (state.game().tyrantLevel() == 4 && state.game().unspentChoices() == 3) {
return;
}
Thread.sleep(100);
}
throw new TimeoutException("Tyrant state did not reflect the native grantpoint command");
}
private static String completedOutput(CompletableFuture<String> output) {
return output.isDone() ? output.join() : "<server still running>";
}
}
@@ -33,6 +33,7 @@ class BukkitRoleControlItemServiceTest {
void setup() {
Plugin plugin = mock(Plugin.class);
when(plugin.getName()).thenReturn("SpigotTyrant");
when(plugin.namespace()).thenReturn("spigottyrant");
service = new BukkitRoleControlItemService(plugin, PluginSettings.from(Map.of()));
when(player.getUniqueId()).thenReturn(owner);
when(player.getInventory()).thenReturn(inventory);
@@ -0,0 +1,359 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.logging.Logger;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.entity.Player;
import org.bukkit.event.inventory.ClickType;
import org.bukkit.event.inventory.InventoryAction;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.InventoryView;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.bukkit.plugin.Plugin;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class CapturedMobInventorySynchronizationTest {
@TempDir Path directory;
private final UUID owner = UUID.randomUUID(), captureId = UUID.randomUUID();
private final Player player = mock(Player.class);
private final PlayerInventory inventory = mock(PlayerInventory.class);
private final Inventory top = mock(Inventory.class);
private final InventoryView view = mock(InventoryView.class);
private final Server server = mock(Server.class);
private final ItemStack[] contents = new ItemStack[41];
private final AtomicReference<ItemStack> cursor = new AtomicReference<>();
private final List<Runnable> scheduled = new ArrayList<>();
private TyrantStateManager manager;
private CapturedMobItemService items;
private TamerListener listener;
private ItemStack egg;
private Path stateFile;
@BeforeEach
void setup() throws Exception {
when(player.getUniqueId()).thenReturn(owner);
when(player.getName()).thenReturn("Tamer");
when(player.isOnline()).thenReturn(true);
when(player.getInventory()).thenReturn(inventory);
when(player.getItemOnCursor()).thenAnswer(call -> cursor.get());
doAnswer(call -> { cursor.set(call.getArgument(0)); return null; })
.when(player).setItemOnCursor(nullable(ItemStack.class));
when(inventory.getContents()).thenAnswer(call -> contents.clone());
when(inventory.getItem(anyInt())).thenAnswer(call -> contents[call.getArgument(0, Integer.class)]);
doAnswer(call -> { contents[call.getArgument(0, Integer.class)] = call.getArgument(1); return null; })
.when(inventory).setItem(anyInt(), nullable(ItemStack.class));
when(view.getPlayer()).thenReturn(player);
when(view.getBottomInventory()).thenReturn(inventory);
when(view.getTopInventory()).thenReturn(top);
when(top.getSize()).thenReturn(27);
when(top.getContents()).thenReturn(new ItemStack[27]);
when(view.getInventory(anyInt())).thenAnswer(call -> {
int raw = call.getArgument(0);
return raw < 0 || raw >= 68 ? null : raw < 27 ? top : inventory;
});
when(view.convertSlot(anyInt())).thenAnswer(call -> call.getArgument(0, Integer.class) - 27);
when(view.getItem(anyInt())).thenAnswer(call -> {
int raw = call.getArgument(0);
return raw >= 27 && raw < 68 ? contents[raw - 27] : null;
});
when(view.getCursor()).thenAnswer(call -> cursor.get());
Plugin plugin = mock(Plugin.class);
when(plugin.getName()).thenReturn("SpigotTyrant");
when(plugin.namespace()).thenReturn("spigottyrant");
items = new BukkitCapturedMobItemService(plugin, PluginSettings.from(Map.of()));
egg = egg(owner, captureId);
contents[3] = egg;
stateFile = directory.resolve("state.yml");
manager = new TyrantStateManager(new YamlTyrantStateRepository(stateFile), Logger.getAnonymousLogger());
var mob = new CapturedMob("COW", Map.of("capture-id", captureId.toString(), "snapshot", "stored-cow"));
var state = new PlayerState(owner, "Tamer", Optional.empty(), Optional.empty(), TyrantClass.TAMER,
Optional.empty(), Map.of(), Set.of(), List.of(mob));
manager.updatePlayer(owner, "Tamer", ignored -> state);
assertTrue(manager.saveIfDirty());
listener = new TamerListener(manager, mock(AbilityItemService.class), items,
new CapturedMobService(), PluginSettings.from(Map.of()), server, scheduled::add);
}
@Test
void rejectedShiftKeepsAuthoritativeEggAndCustodyAndResendsAfterTransaction() throws Exception {
var before = manager.snapshot();
byte[] bytes = Files.readAllBytes(stateFile);
var event = click(30, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
listener.onInventoryClick(event);
assertTrue(event.isCancelled());
assertSame(egg, contents[3]);
assertTrue(java.util.Arrays.stream(top.getContents()).allMatch(java.util.Objects::isNull));
verify(player, never()).updateInventory();
assertEquals(1, scheduled.size(), "A rejected transfer needs a post-transaction full inventory refresh");
scheduled.remove(0).run();
verify(player).updateInventory();
assertEquals(before, manager.snapshot());
assertArrayEquals(bytes, Files.readAllBytes(stateFile));
assertEquals(1, new YamlTyrantStateRepository(stateFile).load().players().get(owner).capturedMobs().size());
verify(server, never()).getEntityFactory();
verify(inventory, never()).setItem(anyInt(), nullable(ItemStack.class));
}
@Test
void repeatedRejectedTransfersCoalesceRefreshWithoutChangingEggOrCustody() throws Exception {
var before = manager.snapshot();
byte[] bytes = Files.readAllBytes(stateFile);
for (int tick = 0; tick < 3; tick++) {
for (int attempt = 0; attempt < 5; attempt++) {
var event = click(30, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
listener.onInventoryClick(event);
assertTrue(event.isCancelled());
assertSame(egg, contents[3]);
}
assertEquals(1, scheduled.size(), "Repeated rejects in one tick need one authoritative refresh");
scheduled.remove(0).run();
}
verify(player, times(3)).updateInventory();
assertEquals(before, manager.snapshot());
assertArrayEquals(bytes, Files.readAllBytes(stateFile));
assertEquals(1, egg.getAmount());
assertTrue(java.util.Arrays.stream(top.getContents()).allMatch(java.util.Objects::isNull));
}
@Test
void logoutBeforeRefreshDoesNotMutateOrReissueAnything() {
var before = manager.snapshot();
listener.onInventoryClick(click(30, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1));
when(player.isOnline()).thenReturn(false);
scheduled.remove(0).run();
verify(player, never()).updateInventory();
assertSame(egg, contents[3]);
assertEquals(before, manager.snapshot());
}
@Test
void rejectedSchedulingDoesNotPermanentlySuppressLaterRefresh() {
int[] attempts = {0};
listener = new TamerListener(manager, mock(AbilityItemService.class), items,
new CapturedMobService(), PluginSettings.from(Map.of()), server, task -> {
if (attempts[0]++ == 0) { throw new IllegalStateException("scheduler unavailable"); }
scheduled.add(task);
});
var first = click(30, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
assertThrows(IllegalStateException.class, () -> listener.onInventoryClick(first));
assertTrue(first.isCancelled());
listener.onInventoryClick(click(30, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1));
assertEquals(1, scheduled.size());
scheduled.remove(0).run();
verify(player).updateInventory();
}
@Test
void recoveryDoesNotDuplicateAnEggHeldOnTheCursor() {
contents[3] = null;
cursor.set(egg);
when(inventory.addItem(any(ItemStack[].class))).thenAnswer(call -> {
ItemStack[] added = (ItemStack[]) call.getRawArguments()[0];
for (ItemStack item : added) {
for (int index = 0; index < contents.length; index++) {
if (contents[index] == null) { contents[index] = item; break; }
}
}
return new java.util.HashMap<Integer, ItemStack>();
});
var before = manager.snapshot();
var recovery = new CapturedMobInventoryService(items, PluginSettings.from(Map.of()));
try (var constructed = mockConstruction(ItemStack.class, (item, context) -> markEgg(item, owner, captureId))) {
for (int attempt = 0; attempt < 3; attempt++) {
recovery.reconcile(player, manager.player(owner, "Tamer"));
}
assertEquals(0, constructed.constructed().size(), "Cursor-held egg already represents the stored mob");
assertTrue(java.util.Arrays.stream(contents).allMatch(java.util.Objects::isNull));
assertSame(egg, cursor.get());
assertEquals(before, manager.snapshot());
}
}
@Test
void reconciliationKeepsCursorCopyAndRemovesOnlyDuplicateEggs() {
cursor.set(egg);
egg.setAmount(2);
contents[3] = egg(owner, captureId);
ItemStack ordinary = mock(ItemStack.class);
contents[4] = ordinary;
var before = manager.snapshot();
new CapturedMobInventoryService(items, PluginSettings.from(Map.of()))
.reconcile(player, manager.player(owner, "Tamer"));
assertSame(egg, cursor.get());
assertEquals(1, egg.getAmount());
assertNull(contents[3]);
assertSame(ordinary, contents[4]);
assertEquals(before, manager.snapshot());
}
@Test
void cursorEggIsRemovedOnlyWhenCustodyIsGoneOrOwnershipIsInvalid() {
contents[3] = null;
cursor.set(egg);
var released = new CapturedMobService().release(manager.player(owner, "Tamer"), captureId);
var recovery = new CapturedMobInventoryService(items, PluginSettings.from(Map.of()));
recovery.reconcile(player, released);
assertNull(cursor.get(), "A released mob must not leave a cursor token behind");
contents[3] = egg;
cursor.set(egg(UUID.randomUUID(), captureId));
recovery.reconcile(player, manager.player(owner, "Tamer"));
assertNull(cursor.get(), "Foreign items cannot be retained");
assertSame(egg, contents[3]);
}
@Test
void hiddenHotbarTransferIsRejectedAndResynchronized() {
contents[3] = null;
contents[2] = egg;
var event = click(0, ClickType.NUMBER_KEY, InventoryAction.HOTBAR_SWAP, 2);
listener.onInventoryClick(event);
assertTrue(event.isCancelled());
assertEquals(1, scheduled.size());
scheduled.remove(0).run();
verify(player).updateInventory();
assertSame(egg, contents[2]);
}
@Test
void hiddenOffhandTransferIsRejectedAndResynchronized() {
contents[3] = null;
contents[40] = egg;
when(inventory.getItemInOffHand()).thenReturn(egg);
var event = click(0, ClickType.SWAP_OFFHAND, InventoryAction.HOTBAR_SWAP, -1);
listener.onInventoryClick(event);
assertTrue(event.isCancelled());
assertEquals(1, scheduled.size());
}
@Test
void crossInventoryDragIsRejectedAndResynchronized() {
cursor.set(egg);
contents[3] = null;
var event = new org.bukkit.event.inventory.InventoryDragEvent(view, null, egg, false, Map.of(0, egg, 30, egg));
listener.onInventoryDrag(event);
assertTrue(event.isCancelled());
assertEquals(1, scheduled.size());
assertSame(egg, cursor.get());
}
@Test
void automatedTransferCannotMoveCapturedEggs() {
var hopper = new org.bukkit.event.inventory.InventoryMoveItemEvent(top, egg, mock(Inventory.class), true);
listener.onInventoryMove(hopper);
assertTrue(hopper.isCancelled());
assertTrue(scheduled.isEmpty(), "Automated moves have no predicting player to refresh");
}
@Test
void cloningCannotCreateAdditionalEggCopies() {
var clone = click(30, ClickType.MIDDLE, InventoryAction.CLONE_STACK, -1);
listener.onInventoryClick(clone);
assertTrue(clone.isCancelled());
assertEquals(1, scheduled.size());
}
@Test
void ordinaryItemsRemainUnaffectedWithoutRefreshTraffic() {
ItemStack ordinary = mock(ItemStack.class);
when(ordinary.clone()).thenReturn(ordinary);
contents[3] = ordinary;
var shift = click(30, ClickType.SHIFT_LEFT, InventoryAction.MOVE_TO_OTHER_INVENTORY, -1);
listener.onInventoryClick(shift);
assertFalse(shift.isCancelled());
var drag = new org.bukkit.event.inventory.InventoryDragEvent(view, null, ordinary, false, Map.of(0, ordinary));
listener.onInventoryDrag(drag);
assertFalse(drag.isCancelled());
var hopper = new org.bukkit.event.inventory.InventoryMoveItemEvent(top, ordinary, mock(Inventory.class), true);
listener.onInventoryMove(hopper);
assertFalse(hopper.isCancelled());
assertTrue(scheduled.isEmpty());
}
@Test
void safeInternalCursorMovementDoesNotTriggerRejectionOrRefresh() {
var pickup = click(30, ClickType.LEFT, InventoryAction.PICKUP_ALL, -1);
listener.onInventoryClick(pickup);
assertFalse(pickup.isCancelled());
contents[3] = null;
cursor.set(egg);
var place = click(31, ClickType.LEFT, InventoryAction.PLACE_ALL, -1);
listener.onInventoryClick(place);
assertFalse(place.isCancelled());
var drag = new org.bukkit.event.inventory.InventoryDragEvent(view, null, egg, false, Map.of(31, egg));
listener.onInventoryDrag(drag);
assertFalse(drag.isCancelled());
assertTrue(scheduled.isEmpty());
new CapturedMobInventoryService(items, PluginSettings.from(Map.of()))
.reconcile(player, manager.player(owner, "Tamer"));
assertSame(egg, cursor.get());
verify(inventory, never()).addItem(any(ItemStack[].class));
}
@Test
void cursorTransferAndExistingCancellationPreserveCustodyAndResynchronize() throws Exception {
var before = manager.snapshot();
byte[] bytes = Files.readAllBytes(stateFile);
contents[3] = null;
cursor.set(egg);
var external = click(0, ClickType.LEFT, InventoryAction.PLACE_ALL, -1);
listener.onInventoryClick(external);
assertTrue(external.isCancelled());
var cancelled = click(31, ClickType.LEFT, InventoryAction.PLACE_ALL, -1);
cancelled.setCancelled(true);
listener.onInventoryClick(cancelled);
assertTrue(cancelled.isCancelled());
assertEquals(1, scheduled.size());
scheduled.remove(0).run();
assertSame(egg, cursor.get());
assertEquals(before, manager.snapshot());
assertArrayEquals(bytes, Files.readAllBytes(stateFile));
verify(player).updateInventory();
verify(top, never()).setItem(anyInt(), nullable(ItemStack.class));
}
private InventoryClickEvent click(int raw, ClickType click, InventoryAction action, int button) {
return new InventoryClickEvent(view, InventoryType.SlotType.CONTAINER, raw, click, action, button);
}
private ItemStack egg(UUID id, UUID captured) {
ItemStack item = mock(ItemStack.class);
markEgg(item, id, captured);
return item;
}
private void markEgg(ItemStack item, UUID id, UUID captured) {
ItemMeta meta = mock(ItemMeta.class);
PersistentDataContainer data = mock(PersistentDataContainer.class);
when(item.clone()).thenReturn(item);
when(item.hasItemMeta()).thenReturn(true);
when(item.getItemMeta()).thenReturn(meta);
var amount = new java.util.concurrent.atomic.AtomicInteger(1);
when(item.getAmount()).thenAnswer(call -> amount.get());
doAnswer(call -> { amount.set(call.getArgument(0)); return null; }).when(item).setAmount(anyInt());
when(meta.getPersistentDataContainer()).thenReturn(data);
when(data.get(new NamespacedKey("spigottyrant", "captured-owner"), PersistentDataType.STRING))
.thenReturn(id.toString());
when(data.get(new NamespacedKey("spigottyrant", "captured-id"), PersistentDataType.STRING))
.thenReturn(captured.toString());
}
}
@@ -0,0 +1,64 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.*;
import java.io.DataInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.jar.JarFile;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
class PurpurDistributionTest {
@Test
void packagesNewNameWithoutChangingRuntimeIdentityOrBundlingTheApi() throws Exception {
Path artifact = Path.of(System.getProperty("distribution.jar"));
String version = System.getProperty("distribution.version");
assertEquals("purpur-tyrant-" + version + ".jar", artifact.getFileName().toString());
try (var jar = new JarFile(artifact.toFile())) {
Map<?, ?> metadata = new Yaml().load(jar.getInputStream(jar.getJarEntry("plugin.yml")));
assertEquals(version, metadata.get("version"));
assertEquals("SpigotTyrant", metadata.get("name"), "Retain the data directory and persistent namespace");
assertEquals("games.dmg.spigottyrant.SpigotTyrantPlugin", metadata.get("main"));
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("spigottyrant.admin"));
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/")
|| entry.getName().startsWith("net/minecraft/")
|| entry.getName().startsWith("com/comphenix/protocol/")
|| entry.getName().contains("/test/")
|| entry.getName().contains("/nativeTest/")
|| entry.getName().endsWith("Test.class")));
}
}
@Test
void pluginBytecodeTargetsJava25() throws Exception {
try (var jar = new JarFile(System.getProperty("distribution.jar"));
var bytecode = new DataInputStream(jar.getInputStream(
jar.getJarEntry("games/dmg/spigottyrant/SpigotTyrantPlugin.class")))) {
assertEquals(0xCAFEBABE, bytecode.readInt());
bytecode.readUnsignedShort();
assertEquals(69, bytecode.readUnsignedShort());
}
}
@Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.3.build.2651-stable"));
assertTrue(build.contains("purpur-26.3-2651.jar"));
assertTrue(build.contains("https://api.purpurmc.org/v2/purpur/26.3/2651/download"));
assertTrue(build.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598"));
assertFalse(build.contains("26.2.build.2618-stable"));
assertFalse(build.contains("org.spigotmc:spigot-api"));
String readme = Files.readString(Path.of("README.md"));
assertTrue(readme.contains("Purpur 26.3 build 2651"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow));
assertTrue(content.contains("java-version: 25"));
assertTrue(content.contains("purpur-tyrant-"), workflow);
assertFalse(content.contains("spigot-tyrant-"), workflow);
}
}
}
@@ -27,7 +27,7 @@ final class RoleArenaLifecycleTest {
void vacantArenaWithoutNearbyPlayersDoesNotSpawnABoss() {
Fixture f = new Fixture();
f.controller.run();
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), any());
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any());
}
@org.junit.jupiter.params.ParameterizedTest
@@ -39,7 +39,7 @@ final class RoleArenaLifecycleTest {
}
Player player = f.player(18.01, 64, 0);
f.controller.run();
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), any());
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any());
when(player.getLocation()).thenReturn(new Location(f.world, 18, 64, 0));
f.controller.run();
f.controller.run();
@@ -61,11 +61,11 @@ final class RoleArenaLifecycleTest {
when(f.world.isChunkLoaded(0, 0)).thenReturn(false);
f.controller.run();
verify(f.world, never()).getChunkAt(0, 0);
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), any());
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any());
when(f.world.isChunkLoaded(0, 0)).thenReturn(true);
when(f.chunk.isEntitiesLoaded()).thenReturn(false);
f.controller.run();
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), any());
verify(f.world, never()).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any());
when(f.chunk.isEntitiesLoaded()).thenReturn(true);
f.controller.run();
org.junit.jupiter.api.Assertions.assertEquals(1, f.spawned.size());
@@ -132,7 +132,7 @@ final class RoleArenaLifecycleTest {
f.player(15, 64, 0);
Zombie rejected = f.taggedBoss();
f.entities.remove(rejected.getUniqueId());
when(f.world.spawn(any(Location.class), eq(Zombie.class), any())).thenReturn(rejected);
when(f.world.spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any())).thenReturn(rejected);
f.controller.run();
verify(rejected).remove();
verify(f.world, never()).spawnParticle(eq(org.bukkit.Particle.PORTAL), any(Location.class),
@@ -236,13 +236,13 @@ final class RoleArenaLifecycleTest {
void failedSpawnLeavesNoOwnershipAndBacksOffBeforeRetry() {
Fixture f = new Fixture();
f.player(15, 64, 0);
when(f.world.spawn(any(Location.class), eq(Zombie.class), any()))
when(f.world.spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any()))
.thenThrow(new IllegalStateException("spawn rejected"));
org.junit.jupiter.api.Assertions.assertDoesNotThrow(f.controller::run);
for (int i = 0; i < 9; i++) {
f.controller.run();
}
verify(f.world).spawn(any(Location.class), eq(Zombie.class), any());
verify(f.world).spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any());
org.junit.jupiter.api.Assertions.assertEquals(0, f.entities.size());
f.controller.close();
}
@@ -385,7 +385,7 @@ final class RoleArenaLifecycleTest {
PlayerState.newPlayer(player.getUniqueId(), "Visitor")));
return states;
});
when(world.spawn(any(Location.class), eq(Zombie.class), any())).thenAnswer(call -> {
when(world.spawn(any(Location.class), eq(Zombie.class), org.mockito.ArgumentMatchers.<java.util.function.Consumer<Zombie>>any())).thenAnswer(call -> {
Zombie boss = newZombie();
spawned.add(boss);
java.util.function.Consumer<Zombie> initializer = call.getArgument(2);
@@ -225,6 +225,7 @@ class RoleControlInventoryMovementTest {
when(view.getCursor()).thenAnswer(call -> cursor.get());
Plugin plugin = mock(Plugin.class);
when(plugin.getName()).thenReturn("SpigotTyrant");
when(plugin.namespace()).thenReturn("spigottyrant");
var items = new BukkitRoleControlItemService(plugin, PluginSettings.from(Map.of()));
var manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(new GameState(GameLifecycle.RUNNING,
@@ -49,7 +49,7 @@ final class TamerListenerTest {
CapturedMobItemService capturedItems = mock(CapturedMobItemService.class);
TamerListener listener = new TamerListener(
manager, abilityItems, capturedItems, new CapturedMobService(),
PluginSettings.from(Map.of()), mock(Server.class)
PluginSettings.from(Map.of()), mock(Server.class), Runnable::run
);
listener.onCapture(event);