From bce513e922190003436213024b8747209a606e3d Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 17 Oct 2026 00:11:07 -0400 Subject: [PATCH] feat(spawn): support Purpur 26.3 --- README.md | 14 +- build.gradle.kts | 68 +++++- .../dmg/triggerspawn/SafeSpawnFinder.java | 19 +- .../dmg/triggerspawn/TriggerSpawnPlugin.java | 2 +- src/main/resources/plugin.yml | 2 +- .../TriggerSpawnLifecycleNativeTest.java | 202 ++++++++++++++++++ .../dmg/triggerspawn/PluginMetadataTest.java | 2 +- .../triggerspawn/PurpurDistributionTest.java | 36 +++- .../dmg/triggerspawn/SafeSpawnFinderTest.java | 65 ++++++ .../dmg/triggerspawn/SpawnCommandTest.java | 105 +++++++++ 10 files changed, 502 insertions(+), 13 deletions(-) create mode 100644 src/nativeTest/java/games/dmg/triggerspawn/TriggerSpawnLifecycleNativeTest.java create mode 100644 src/test/java/games/dmg/triggerspawn/SafeSpawnFinderTest.java diff --git a/README.md b/README.md index 2f98ba6..16e8eaf 100644 --- a/README.md +++ b/README.md @@ -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-.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: diff --git a/build.gradle.kts b/build.gradle.kts index bbe0a1e..bc11f62 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,6 @@ +import java.net.URI +import java.security.MessageDigest + plugins { java } @@ -23,9 +26,9 @@ tasks.withType().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("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("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 { diff --git a/src/main/java/games/dmg/triggerspawn/SafeSpawnFinder.java b/src/main/java/games/dmg/triggerspawn/SafeSpawnFinder.java index 5a3c229..5b91703 100644 --- a/src/main/java/games/dmg/triggerspawn/SafeSpawnFinder.java +++ b/src/main/java/games/dmg/triggerspawn/SafeSpawnFinder.java @@ -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 solidMaterial; + private final Predicate leafMaterial; + + SafeSpawnFinder() { + this(Material::isSolid, material -> Tag.LEAVES.isTagged(material)); + } + + SafeSpawnFinder(Predicate solidMaterial, Predicate leafMaterial) { + this.solidMaterial = Objects.requireNonNull(solidMaterial); + this.leafMaterial = Objects.requireNonNull(leafMaterial); + } Optional 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); diff --git a/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java b/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java index 494ee4a..66115a0 100644 --- a/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java +++ b/src/main/java/games/dmg/triggerspawn/TriggerSpawnPlugin.java @@ -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; diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index c0303e7..6c5e8ec 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -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: diff --git a/src/nativeTest/java/games/dmg/triggerspawn/TriggerSpawnLifecycleNativeTest.java b/src/nativeTest/java/games/dmg/triggerspawn/TriggerSpawnLifecycleNativeTest.java new file mode 100644 index 0000000..2afca9e --- /dev/null +++ b/src/nativeTest/java/games/dmg/triggerspawn/TriggerSpawnLifecycleNativeTest.java @@ -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> listenerTypes = new ArrayList<>(); + Map 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 port(Class 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); + })); + } +} diff --git a/src/test/java/games/dmg/triggerspawn/PluginMetadataTest.java b/src/test/java/games/dmg/triggerspawn/PluginMetadataTest.java index d55a8eb..26281cb 100644 --- a/src/test/java/games/dmg/triggerspawn/PluginMetadataTest.java +++ b/src/test/java/games/dmg/triggerspawn/PluginMetadataTest.java @@ -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"))); } diff --git a/src/test/java/games/dmg/triggerspawn/PurpurDistributionTest.java b/src/test/java/games/dmg/triggerspawn/PurpurDistributionTest.java index f24a61f..58f2762 100644 --- a/src/test/java/games/dmg/triggerspawn/PurpurDistributionTest.java +++ b/src/test/java/games/dmg/triggerspawn/PurpurDistributionTest.java @@ -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 ", + ((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")); diff --git a/src/test/java/games/dmg/triggerspawn/SafeSpawnFinderTest.java b/src/test/java/games/dmg/triggerspawn/SafeSpawnFinderTest.java new file mode 100644 index 0000000..7b82ae6 --- /dev/null +++ b/src/test/java/games/dmg/triggerspawn/SafeSpawnFinderTest.java @@ -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); + } +} diff --git a/src/test/java/games/dmg/triggerspawn/SpawnCommandTest.java b/src/test/java/games/dmg/triggerspawn/SpawnCommandTest.java index 5043e9a..5226666 100644 --- a/src/test/java/games/dmg/triggerspawn/SpawnCommandTest.java +++ b/src/test/java/games/dmg/triggerspawn/SpawnCommandTest.java @@ -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 countdowns = new ArrayList<>(); + List 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)); + } }