feat(spawn): support Purpur 26.3
Release / release (push) Successful in 1m39s
CI / build (push) Successful in 57s

Closes OpenProject #417 after required checks and verified local acceptance.
This commit was merged in pull request #1.
This commit is contained in:
dmg committed 2026-10-17 04:13:14 +00:00
commit 0183d4ff27
10 files changed
+502 -13

No files matched your search

+12 -2
View File
@@ -1,6 +1,6 @@
# Purpur Trigger Spawn
A Purpur 26.2 plugin providing progression-gated, safe teleportation to each world's spawn area.
A Purpur 26.3 plugin providing progression-gated, safe teleportation to each world's spawn area.
Players unlock `/spawn` by killing a Warden, Ender Dragon, or Wither after the plugin is installed. Their first, second, and third unique kills give default cooldowns of 8 hours, 4 hours, and 1 hour respectively. Each new unique kill clears the current cooldown immediately; repeated kills of the same boss do not count.
@@ -10,9 +10,12 @@ The behavior under development is specified in the [SoMC OKF wiki](https://git.g
## Requirements
- Purpur 26.2 build 2618
- Purpur 26.3 build 2651
- Java 25
The automated native lifecycle boundary downloads that exact runtime and accepts it only with
SHA-256 `0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598`.
Repository, checkout and new artifacts use `purpur-trigger-spawn`. Runtime `TriggerSpawn`, Java entrypoint/packages, `plugins/TriggerSpawn/`, commands/permissions, boss progression, cooldowns and world spawn settings remain unchanged. Replace the old JAR rather than installing both distributions. Old tags/assets remain available; new releases no longer support Java 17.
## Build
@@ -22,6 +25,13 @@ Repository, checkout and new artifacts use `purpur-trigger-spawn`. Runtime `Trig
```
The plugin JAR is written to `build/libs/purpur-trigger-spawn-<version>.jar`.
`check` includes `nativeLifecycleTest`: it checksum-verifies Purpur 26.3 build 2651, prepares the
runtime in Paperclip patch-only mode, and loads, enables, and disables the packaged plugin through
Purpur's native JavaPlugin lifecycle. The isolated test verifies command/listener wiring, retained
configuration and YAML state without starting a listening server, accepting the EULA, or creating a
world. Scheduler, teleport, safe-location, world-border, persistence, metadata, bytecode, and JAR
exclusion regressions run alongside that boundary; this is automated compatibility evidence, not a
live-server or deployment claim.
On first startup, the plugin creates `plugins/TriggerSpawn/config.yml`. Its cooldown defaults are:
+66 -2
View File
@@ -1,3 +1,6 @@
import java.net.URI
import java.security.MessageDigest
plugins {
java
}
@@ -23,9 +26,9 @@ tasks.withType<JavaCompile>().configureEach {
}
dependencies {
compileOnly("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
compileOnly("org.purpurmc.purpur:purpur-api:26.3.build.2651-stable")
testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable")
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")
@@ -40,6 +43,67 @@ tasks.test {
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)
outputs.upToDateWhen {
val file = launcher.get().asFile
file.isFile && MessageDigest.getInstance("SHA-256")
.digest(file.readBytes()).joinToString("") { "%02x".format(it) }
.equals("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598")
}
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.compileOnlyConfigurationName, "org.jetbrains:annotations:26.1.0")
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("distribution.version", project.version.toString())
}
tasks.check { dependsOn(nativeLifecycleTest) }
val pluginVersion = version
tasks.processResources {
@@ -1,8 +1,10 @@
package games.dmg.triggerspawn;
import java.util.EnumSet;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;
import java.util.random.RandomGenerator;
import org.bukkit.Location;
import org.bukkit.Material;
@@ -28,6 +30,17 @@ final class SafeSpawnFinder {
Material.FIRE,
Material.SOUL_FIRE,
Material.POWDER_SNOW);
private final Predicate<Material> solidMaterial;
private final Predicate<Material> leafMaterial;
SafeSpawnFinder() {
this(Material::isSolid, material -> Tag.LEAVES.isTagged(material));
}
SafeSpawnFinder(Predicate<Material> solidMaterial, Predicate<Material> leafMaterial) {
this.solidMaterial = Objects.requireNonNull(solidMaterial);
this.leafMaterial = Objects.requireNonNull(leafMaterial);
}
Optional<Location> find(
World world,
@@ -64,13 +77,13 @@ final class SafeSpawnFinder {
return Optional.empty();
}
private static boolean isSafe(World world, int x, int feetY, int z) {
private boolean isSafe(World world, int x, int feetY, int z) {
Block ground = world.getBlockAt(x, feetY - 1, z);
Block feet = world.getBlockAt(x, feetY, z);
Block head = world.getBlockAt(x, feetY + 1, z);
Material groundType = ground.getType();
return groundType.isSolid()
&& !Tag.LEAVES.isTagged(groundType)
return solidMaterial.test(groundType)
&& !leafMaterial.test(groundType)
&& !UNSAFE_GROUND.contains(groundType)
&& isClear(feet)
&& isClear(head);
@@ -6,7 +6,7 @@ import java.util.logging.Level;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
/** Entry point for the Trigger Spawn Spigot plugin. */
/** Entry point for the Trigger Spawn Purpur plugin. */
public final class TriggerSpawnPlugin extends JavaPlugin {
private PluginSettings settings;
private SpawnStateManager stateManager;
+1 -1
View File
@@ -1,7 +1,7 @@
name: TriggerSpawn
version: '${version}'
main: games.dmg.triggerspawn.TriggerSpawnPlugin
api-version: '26.2'
api-version: '26.3'
author: dmg.games
description: Provides progression-gated safe teleportation to each world's spawn area.
commands:
@@ -0,0 +1,202 @@
package games.dmg.triggerspawn;
import static org.junit.jupiter.api.Assertions.*;
import io.papermc.paper.plugin.configuration.PluginMeta;
import io.papermc.paper.plugin.provider.classloader.ConfiguredPluginClassLoader;
import io.papermc.paper.plugin.provider.classloader.PluginClassLoaderGroup;
import java.lang.reflect.Proxy;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.jar.JarFile;
import java.util.logging.Logger;
import org.bukkit.Server;
import org.bukkit.command.PluginCommand;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.event.Listener;
import org.bukkit.plugin.PluginDescriptionFile;
import org.bukkit.plugin.PluginManager;
import org.bukkit.plugin.java.JavaPlugin;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** Exercises the packaged entrypoint through Purpur's native JavaPlugin lifecycle. */
class TriggerSpawnLifecycleNativeTest {
private static final String PLAYER_ID = "00000000-0000-0000-0000-000000000417";
private static final String WORLD_ID = "00000000-0000-0000-0000-000000002651";
@TempDir Path temporaryDirectory;
@Test
void packagedPluginLoadsEnablesRetainsYamlStateAndDisables() throws Exception {
Path dataDirectory = temporaryDirectory.resolve("TriggerSpawn");
Files.createDirectories(dataDirectory);
Path stateFile = dataDirectory.resolve("state.yml");
Files.writeString(stateFile, """
players:
%s:
name: Alex
defeated: [WARDEN]
banned: false
grant-cooldown-seconds: 1800
last-spawn-epoch-millis: 1750000000000
worlds:
%s:
name: overworld
max-distance: 12
center:
x: 12.5
y: 70.0
z: -8.5
yaw: 90.0
pitch: 10.0
""".formatted(PLAYER_ID, WORLD_ID));
Path unrelatedState = temporaryDirectory.resolve("OtherPlugin/owned.txt");
Files.createDirectories(unrelatedState.getParent());
Files.writeString(unrelatedState, "keep");
List<Class<?>> listenerTypes = new ArrayList<>();
Map<String, PluginCommand> commands = new HashMap<>();
PluginManager manager = port(PluginManager.class, (name, arguments) -> switch (name) {
case "registerEvents" -> {
listenerTypes.add(((Listener) arguments[0]).getClass());
yield null;
}
case "disablePlugin" -> throw new AssertionError("Native lifecycle initialization failed");
default -> throw new AssertionError("Unexpected plugin-manager call " + name);
});
Server server = port(Server.class, (name, arguments) -> switch (name) {
case "getPluginManager" -> manager;
case "getPluginCommand" -> commands.get(arguments[0]);
case "getLogger" -> Logger.getLogger("TriggerSpawn lifecycle fixture");
case "getWarningState" -> org.bukkit.Warning.WarningState.DEFAULT;
case "getName" -> "Purpur";
case "getVersion" -> "Purpur 2651 (MC: 26.3)";
case "getMinecraftVersion" -> "26.3";
case "isPrimaryThread" -> true;
default -> throw new AssertionError("Unexpected server call " + name);
});
try (var loader = new Loader(server, dataDirectory)) {
JavaPlugin plugin = (JavaPlugin) loader.loadClass(
"games.dmg.triggerspawn.TriggerSpawnPlugin").getConstructor().newInstance();
var commandConstructor = PluginCommand.class.getDeclaredConstructor(
String.class, org.bukkit.plugin.Plugin.class);
commandConstructor.setAccessible(true);
commands.put("spawn", commandConstructor.newInstance("spawn", plugin));
commands.put("spawnadmin", commandConstructor.newInstance("spawnadmin", plugin));
assertEquals("TriggerSpawn", plugin.getName());
assertEquals("26.3", loader.getConfiguration().getAPIVersion());
assertEquals("TriggerSpawn", dataDirectory.getFileName().toString());
assertFalse(plugin.isEnabled());
plugin.setEnabled(true);
try {
assertTrue(plugin.isEnabled());
assertEquals(Set.of("SpawnCommand", "BossKillListener"), listenerTypes.stream()
.map(Class::getSimpleName).collect(java.util.stream.Collectors.toSet()));
assertEquals("SpawnCommand", commands.get("spawn").getExecutor().getClass().getSimpleName());
assertEquals("SpawnAdminCommand", commands.get("spawnadmin").getExecutor().getClass().getSimpleName());
assertEquals("SpawnAdminCommand", commands.get("spawnadmin").getTabCompleter().getClass().getSimpleName());
assertTrue(Files.isRegularFile(dataDirectory.resolve("config.yml")));
Files.delete(stateFile);
assertFalse(Files.exists(stateFile), "Disable must recreate state loaded during enable");
} finally {
plugin.setEnabled(false);
}
assertFalse(plugin.isEnabled());
}
YamlConfiguration saved = YamlConfiguration.loadConfiguration(stateFile.toFile());
assertEquals("Alex", saved.getString("players." + PLAYER_ID + ".name"));
assertEquals(List.of("WARDEN"), saved.getStringList("players." + PLAYER_ID + ".defeated"));
assertEquals(1800L, saved.getLong("players." + PLAYER_ID + ".grant-cooldown-seconds"));
assertEquals("overworld", saved.getString("worlds." + WORLD_ID + ".name"));
assertEquals(12, saved.getInt("worlds." + WORLD_ID + ".max-distance"));
assertEquals(12.5, saved.getDouble("worlds." + WORLD_ID + ".center.x"));
assertEquals("keep", Files.readString(unrelatedState));
}
static final class Loader extends URLClassLoader implements ConfiguredPluginClassLoader {
private static final String PACKAGE = "games.dmg.triggerspawn.";
private final Server server;
private final Path dataDirectory;
private final Path jar = Path.of(System.getProperty("distribution.jar"));
private final PluginDescriptionFile description;
private JavaPlugin plugin;
Loader(Server server, Path dataDirectory) throws Exception {
super(new URL[] {Path.of(System.getProperty("distribution.jar")).toUri().toURL()},
TriggerSpawnLifecycleNativeTest.class.getClassLoader());
this.server = server;
this.dataDirectory = dataDirectory;
try (var archive = new JarFile(jar.toFile());
var input = archive.getInputStream(archive.getJarEntry("plugin.yml"))) {
description = new PluginDescriptionFile(input);
}
}
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
if (!name.startsWith(PACKAGE) || name.equals(getClass().getName())) {
return super.loadClass(name, resolve);
}
Class<?> loaded = findLoadedClass(name);
if (loaded == null) {
loaded = findClass(name);
}
if (resolve) {
resolveClass(loaded);
}
return loaded;
}
}
@Override
public Class<?> loadClass(String name, boolean resolve, boolean global, boolean libraries)
throws ClassNotFoundException {
return loadClass(name, resolve);
}
@Override public PluginMeta getConfiguration() { return description; }
@Override public JavaPlugin getPlugin() { return plugin; }
@Override public PluginClassLoaderGroup getGroup() { return null; }
@Override
public void init(JavaPlugin created) {
plugin = created;
created.init(server, description, dataDirectory.toFile(), jar.toFile(), this, description,
Logger.getLogger("TriggerSpawn lifecycle fixture"));
}
}
@FunctionalInterface
interface Call {
Object invoke(String name, Object[] arguments);
}
static <T> T port(Class<T> type, Call call) {
return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] {type},
(object, method, arguments) -> {
Object[] values = arguments == null ? new Object[0] : arguments;
if (method.getDeclaringClass() == Object.class) {
return switch (method.getName()) {
case "equals" -> object == values[0];
case "hashCode" -> System.identityHashCode(object);
case "toString" -> "Lifecycle port " + type.getSimpleName();
default -> throw new AssertionError(method.getName());
};
}
return call.invoke(method.getName(), values);
}));
}
}
@@ -18,7 +18,7 @@ final class PluginMetadataTest {
}
assertEquals("TriggerSpawn", metadata.get("name"));
assertEquals("26.2", metadata.get("api-version"));
assertEquals("26.3", metadata.get("api-version"));
assertEquals("games.dmg.triggerspawn.TriggerSpawnPlugin", metadata.get("main"));
assertNotNull(Class.forName((String) metadata.get("main")));
}
@@ -6,6 +6,7 @@ import java.io.DataInputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Set;
import java.util.jar.JarFile;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
@@ -21,9 +22,29 @@ class PurpurDistributionTest {
assertEquals(version, metadata.get("version"));
assertEquals("TriggerSpawn", metadata.get("name"), "Retain the data directory and persistent namespace");
assertEquals("games.dmg.triggerspawn.TriggerSpawnPlugin", metadata.get("main"));
assertTrue(((Map<?, ?>) metadata.get("permissions")).containsKey("triggerspawn.admin"));
assertEquals("26.3", metadata.get("api-version"));
Map<?, ?> commands = (Map<?, ?>) metadata.get("commands");
assertEquals(Set.of("spawn", "spawnadmin"), commands.keySet());
assertEquals("/spawn", ((Map<?, ?>) commands.get("spawn")).get("usage"));
assertEquals(
"/spawnadmin <set|distance|info|grant|remove|access|ban|unban|bans>",
((Map<?, ?>) commands.get("spawnadmin")).get("usage"));
assertEquals("triggerspawn.admin", ((Map<?, ?>) commands.get("spawnadmin")).get("permission"));
Map<?, ?> permissions = (Map<?, ?>) metadata.get("permissions");
assertEquals(Set.of("triggerspawn.admin"), permissions.keySet());
assertEquals("op", ((Map<?, ?>) permissions.get("triggerspawn.admin")).get("default"));
assertNull(metadata.get("depend"));
assertNull(metadata.get("softdepend"));
assertFalse(jar.stream().anyMatch(entry -> entry.getName().startsWith("org/bukkit/")
|| entry.getName().startsWith("org/purpurmc/") || entry.getName().endsWith("Test.class")));
|| entry.getName().startsWith("org/purpurmc/")
|| entry.getName().startsWith("io/papermc/")
|| entry.getName().startsWith("net/minecraft/")
|| entry.getName().startsWith("com/destroystokyo/")
|| entry.getName().startsWith("com/comphenix/protocol/")
|| entry.getName().startsWith("org/junit/")
|| entry.getName().startsWith("org/mockito/")
|| entry.getName().endsWith("Test.class")));
assertNull(jar.getJarEntry("junit-platform.properties"));
}
}
@@ -41,8 +62,17 @@ class PurpurDistributionTest {
@Test
void releaseAndCiAgreeWithThePurpurDistributionNameAndToolchain() throws Exception {
String build = Files.readString(Path.of("build.gradle.kts"));
assertTrue(build.contains("purpur-api:26.2.build.2618-stable"));
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"));
assertTrue(readme.contains("0677c1febecfbc2e5b0a7f76abd647b70a1f38d3cbdf3dd6a82e401b4262a598"));
assertFalse(readme.contains("Purpur 26.2"));
assertFalse(readme.contains("build 2618"));
for (String workflow : new String[] {"ci.yml", "release.yml"}) {
String content = Files.readString(Path.of(".gitea/workflows", workflow));
assertTrue(content.contains("java-version: 25"));
@@ -0,0 +1,65 @@
package games.dmg.triggerspawn;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.random.RandomGenerator;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.WorldBorder;
import org.junit.jupiter.api.Test;
final class SafeSpawnFinderTest {
@Test
void rejectsAConfiguredCenterOutsideTheWorldBorderBeforeInspectingBlocks() {
World world = mock(World.class);
WorldBorder border = mock(WorldBorder.class);
when(world.getWorldBorder()).thenReturn(border);
when(border.isInside(any(Location.class))).thenReturn(false);
var result = new SafeSpawnFinder().find(
world,
new SpawnLocation(10.0, 64.0, -10.0, 90.0F, 0.0F),
0,
RandomGenerator.getDefault());
assertTrue(result.isEmpty());
verify(world, never()).getBlockAt(anyInt(), anyInt(), anyInt());
}
@Test
void returnsTheNearestClearSolidLocationInsideTheBorder() {
World world = mock(World.class);
WorldBorder border = mock(WorldBorder.class);
Block ground = mock(Block.class);
Block clear = mock(Block.class);
when(world.getWorldBorder()).thenReturn(border);
when(border.isInside(any(Location.class))).thenReturn(true);
when(world.getMinHeight()).thenReturn(0);
when(world.getMaxHeight()).thenReturn(128);
when(world.getBlockAt(10, 63, -10)).thenReturn(ground);
when(world.getBlockAt(10, 64, -10)).thenReturn(clear);
when(world.getBlockAt(10, 65, -10)).thenReturn(clear);
when(ground.getType()).thenReturn(Material.STONE);
when(clear.getType()).thenReturn(Material.AIR);
when(clear.isPassable()).thenReturn(true);
var result = new SafeSpawnFinder(material -> true, material -> false).find(
world,
new SpawnLocation(10.0, 64.0, -10.0, 90.0F, 10.0F),
0,
RandomGenerator.getDefault());
Location destination = result.orElseThrow();
assertTrue(destination.getWorld() == world);
assertTrue(destination.getX() == 10.5 && destination.getY() == 64.0 && destination.getZ() == -9.5);
assertTrue(destination.getYaw() == 90.0F && destination.getPitch() == 10.0F);
}
}
@@ -1,7 +1,12 @@
package games.dmg.triggerspawn;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -9,12 +14,22 @@ import java.nio.file.Path;
import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.random.RandomGenerator;
import org.bukkit.Location;
import org.bukkit.Server;
import org.bukkit.World;
import org.bukkit.command.Command;
import org.bukkit.entity.Player;
import org.bukkit.event.player.PlayerMoveEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitScheduler;
import org.bukkit.scheduler.BukkitTask;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -53,4 +68,94 @@ final class SpawnCommandTest {
verify(player).sendMessage(contains("cannot use /spawn yet"));
verify(player).sendMessage(contains("7h"));
}
@Test
void cancellationPathsOwnOnlyPendingTasksAndRetryTeleportsThenPersistsCooldown() throws Exception {
Instant now = Instant.parse("2026-08-08T12:00:00Z");
UUID playerId = UUID.randomUUID();
UUID unrelatedPlayerId = UUID.randomUUID();
Path stateFile = temporaryDirectory.resolve("state.yml");
SpawnStateManager manager = SpawnStateManager.load(new YamlSpawnStateRepository(stateFile));
manager.putPlayer(new PlayerState(
playerId, "Alex", Set.of(BossType.WARDEN), Optional.empty(), false, Optional.empty()));
PlayerState unrelated = new PlayerState(
unrelatedPlayerId, "Steve", Set.of(BossType.WITHER), Optional.empty(), false, Optional.empty());
manager.putPlayer(unrelated);
Plugin plugin = mock(Plugin.class);
Server server = mock(Server.class);
BukkitScheduler scheduler = mock(BukkitScheduler.class);
when(plugin.getServer()).thenReturn(server);
when(server.getScheduler()).thenReturn(scheduler);
List<Runnable> countdowns = new ArrayList<>();
List<BukkitTask> tasks = List.of(
mock(BukkitTask.class),
mock(BukkitTask.class),
mock(BukkitTask.class),
mock(BukkitTask.class));
when(scheduler.runTaskTimer(eq(plugin), any(Runnable.class), eq(0L), eq(20L)))
.thenAnswer(invocation -> {
countdowns.add(invocation.getArgument(1));
return tasks.get(countdowns.size() - 1);
});
World world = mock(World.class);
UUID worldId = UUID.randomUUID();
Location start = new Location(world, 5.5, 70.0, 5.5);
Location moved = new Location(world, 6.5, 70.0, 5.5);
Location destination = new Location(world, 0.5, 65.0, 0.5, 90.0F, 0.0F);
when(world.getUID()).thenReturn(worldId);
when(world.getName()).thenReturn("overworld");
when(world.getSpawnLocation()).thenReturn(new Location(world, 0.0, 64.0, 0.0));
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Alex");
when(player.getWorld()).thenReturn(world);
when(player.getLocation()).thenReturn(start);
when(player.teleport(destination)).thenReturn(true);
SafeSpawnFinder finder = mock(SafeSpawnFinder.class);
when(finder.find(
eq(world), any(SpawnLocation.class), eq(WorldSpawnState.DEFAULT_MAX_DISTANCE),
any(RandomGenerator.class))).thenReturn(Optional.of(destination));
PluginSettings settings = PluginSettings.from(new org.bukkit.configuration.MemoryConfiguration());
SpawnCommand spawn = new SpawnCommand(
plugin,
manager,
new AccessService(manager, settings),
settings,
new SpawnAreaService(manager),
finder,
Clock.fixed(now, ZoneOffset.UTC));
assertTrue(spawn.onCommand(player, mock(Command.class), "spawn", new String[0]));
assertEquals(1, countdowns.size());
spawn.onMove(new PlayerMoveEvent(
player, start, new Location(world, 5.5, 70.0, 5.5, 180.0F, 20.0F)));
verify(tasks.get(0), never()).cancel();
spawn.onMove(new PlayerMoveEvent(player, start, moved));
verify(tasks.get(0)).cancel();
verify(player, never()).teleport(destination);
assertTrue(spawn.onCommand(player, mock(Command.class), "spawn", new String[0]));
spawn.onQuit(new PlayerQuitEvent(player, "", PlayerQuitEvent.QuitReason.DISCONNECTED));
verify(tasks.get(1)).cancel();
assertTrue(spawn.onCommand(player, mock(Command.class), "spawn", new String[0]));
spawn.cancelAll();
verify(tasks.get(2)).cancel();
assertTrue(spawn.onCommand(player, mock(Command.class), "spawn", new String[0]));
assertEquals(4, countdowns.size());
for (int tick = 0; tick < 4; tick++) {
countdowns.get(3).run();
}
verify(tasks.get(3)).cancel();
verify(player).teleport(destination);
PersistentState persisted = new YamlSpawnStateRepository(stateFile).load();
assertEquals(Optional.of(now), persisted.players().get(playerId).lastSpawnUse());
assertEquals(unrelated, persisted.players().get(unrelatedPlayerId));
}
}