diff --git a/.gitignore b/.gitignore index a1f8c90..4e09d63 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,20 @@ # Rust /rust/target/ /target/ +**/target/ +**/*.rs.bk +*.pdb # Rust resources rust/resources/jassets/* +!rust/resources/jassets/.gitkeep # Gradle cache .gradle/ java/.gradle/ +**/.gradle/ +.gradletasknamecache +gradle-app.setting # Build outputs build/ @@ -19,22 +26,72 @@ java/protoc-gen-ffi/bin/ java/patchbukkit-test-plugin/build/ java/patchbukkit-test-plugin/bin/ !rust/build/ +!rust/build/** -# Examples -examples/*/bin -examples/*/run +# Java compiled classes and JAR packages +*.class +*.jar +*.war +*.ear # Keep Gradle wrapper !gradle/wrapper/gradle-wrapper.jar !java/gradle/wrapper/gradle-wrapper.jar +!gradle/wrapper/gradle-wrapper.properties +!java/gradle/wrapper/gradle-wrapper.properties # Never ignore source build dirs if any exist !**/src/main/**/build/ !**/src/test/**/build/ +# JVM crash logs +hs_err_pid* +replay_pid* + +# Logs and Crash Reports +*.log +*.log.gz +logs/ +**/logs/ +crash-reports/ + +# Runtime and Test directories / artifacts +patchbukkit/ +patchbukkit-plugins/ +patchbukkit-libs/ +run/ +**/run/ +world/ +world_nether/ +world_the_end/ +*.local.nbt + +# Vanilla / Server runtime generated files +server.properties +eula.txt +ops.json +whitelist.json +banned-ips.json +banned-players.json +usercache.json +version_history.json +.console_history + +# Runtime configs +config/ +java/patchbukkit/config/ + +# Examples +examples/*/bin +examples/*/run + # IntelliJ IDEA .idea/ java/.idea/ +**/.idea/ +*.iml +*.iws +*.ipr # Eclipse .classpath @@ -54,11 +111,20 @@ java/.idea/ # VS Code .vscode/ +**/.vscode/ +*.code-workspace +# OS and Environment .DS_Store - -# Runtime configs -config/ -java/patchbukkit/config/ -logs/ -java/patchbukkit/logs/ +.AppleDouble +.LSOverride +._* +Thumbs.db +desktop.ini +*~ +*.swp +*.swo +.direnv/ +.env +.env.* +!*.example diff --git a/java/patchbukkit-test-plugin/build.gradle.kts b/java/patchbukkit-test-plugin/build.gradle.kts index 1ae74ea..33c0ea9 100644 --- a/java/patchbukkit-test-plugin/build.gradle.kts +++ b/java/patchbukkit-test-plugin/build.gradle.kts @@ -27,3 +27,5 @@ tasks.withType { "-nowarn" )) } + + diff --git a/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/EntityTests.java b/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/EntityTests.java index 83695f5..86b226c 100644 --- a/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/EntityTests.java +++ b/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/EntityTests.java @@ -23,10 +23,10 @@ public final class EntityTests { } } - @ConformanceTest(name = "Server.getEntity(UUID) stub", category = TestCategory.ENTITY, - expectation = TestExpectation.EXPECT_UNSUPPORTED) + @ConformanceTest(name = "Server.getEntity(UUID) returns null for random UUID", category = TestCategory.ENTITY) public void testGetEntity() { - Bukkit.getServer().getEntity(UUID.randomUUID()); + var entity = Bukkit.getServer().getEntity(UUID.randomUUID()); + assertTrue(entity == null, "Server.getEntity(randomUUID) should be null"); } @ConformanceTest(name = "Player flight state and speed getters run cleanly", category = TestCategory.ENTITY) diff --git a/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/RegistryTests.java b/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/RegistryTests.java index e8bd1a6..b67bd8f 100644 --- a/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/RegistryTests.java +++ b/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/RegistryTests.java @@ -33,14 +33,10 @@ public final class RegistryTests { assertNotNull(stream, "Registry.SOUNDS.stream()"); } - @ConformanceTest(name = "Server.getRegistry(Sound.class) returns registry", category = TestCategory.REGISTRY, - expectation = TestExpectation.EXPECT_UNSUPPORTED) + @ConformanceTest(name = "Server.getRegistry(Sound.class) returns registry", category = TestCategory.REGISTRY) public void testServerGetRegistry() { - // Server.getRegistry() returns null in PatchBukkit — might throw or return null Registry reg = Bukkit.getServer().getRegistry(Sound.class); - if (reg == null) { - throw new UnsupportedOperationException("Server.getRegistry() returned null (not implemented)"); - } + assertNotNull(reg, "Server.getRegistry(Sound.class)"); } } diff --git a/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/ServerTests.java b/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/ServerTests.java index 1940bf5..3b0c14c 100644 --- a/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/ServerTests.java +++ b/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/ServerTests.java @@ -102,4 +102,106 @@ public final class ServerTests { assertTrue(cmd == null, "Server.getPluginCommand(unknown) should be null"); } + @ConformanceTest(name = "Server.getWorlds() returns non-empty list", category = TestCategory.SERVER) + public void testGetWorlds() { + var worlds = Bukkit.getServer().getWorlds(); + assertNotNull(worlds, "Server.getWorlds()"); + assertTrue(!worlds.isEmpty(), "Server.getWorlds() should not be empty"); + } + + @ConformanceTest(name = "Server.getMaxPlayers() returns positive int", category = TestCategory.SERVER) + public void testGetMaxPlayers() { + int max = Bukkit.getServer().getMaxPlayers(); + assertTrue(max >= 0, "Server.getMaxPlayers() >= 0"); + } + + @ConformanceTest(name = "Server.getPort() returns port", category = TestCategory.SERVER) + public void testGetPort() { + int port = Bukkit.getServer().getPort(); + assertTrue(port >= 0, "Server.getPort() >= 0"); + } + + @ConformanceTest(name = "Server.getIp() returns string", category = TestCategory.SERVER) + public void testGetIp() { + String ip = Bukkit.getServer().getIp(); + assertNotNull(ip, "Server.getIp()"); + } + + @ConformanceTest(name = "Server.getViewDistance() returns positive int", category = TestCategory.SERVER) + public void testGetViewDistance() { + int vd = Bukkit.getServer().getViewDistance(); + assertTrue(vd > 0, "Server.getViewDistance() > 0"); + } + + @ConformanceTest(name = "Server.getSimulationDistance() returns positive int", category = TestCategory.SERVER) + public void testGetSimulationDistance() { + int sd = Bukkit.getServer().getSimulationDistance(); + assertTrue(sd > 0, "Server.getSimulationDistance() > 0"); + } + + @ConformanceTest(name = "Server.getUpdateFolder() returns string", category = TestCategory.SERVER) + public void testGetUpdateFolder() { + String uf = Bukkit.getServer().getUpdateFolder(); + assertNotNull(uf, "Server.getUpdateFolder()"); + } + + @ConformanceTest(name = "Server.getUpdateFolderFile() returns non-null", category = TestCategory.SERVER) + public void testGetUpdateFolderFile() { + java.io.File file = Bukkit.getServer().getUpdateFolderFile(); + assertNotNull(file, "Server.getUpdateFolderFile()"); + } + + @ConformanceTest(name = "Server.getConnectionThrottle() returns long", category = TestCategory.SERVER) + public void testGetConnectionThrottle() { + long throttle = Bukkit.getServer().getConnectionThrottle(); + assertTrue(throttle >= 0, "Server.getConnectionThrottle() >= 0"); + } + + @ConformanceTest(name = "Server.broadcastMessage() works without throwing", category = TestCategory.SERVER) + @SuppressWarnings("deprecation") + public void testBroadcastMessage() { + int reached = Bukkit.getServer().broadcastMessage("test conformance broadcast"); + assertTrue(reached >= 0, "broadcastMessage return >= 0"); + } + + @ConformanceTest(name = "Server.getOfflinePlayer(UUID) returns non-null", category = TestCategory.SERVER) + public void testGetOfflinePlayer() { + var op = Bukkit.getServer().getOfflinePlayer(java.util.UUID.randomUUID()); + assertNotNull(op, "Server.getOfflinePlayer(UUID)"); + } + + @ConformanceTest(name = "Server.getBanList() returns non-null", category = TestCategory.SERVER) + public void testGetBanList() { + var bans = Bukkit.getServer().getBanList(org.bukkit.BanList.Type.NAME); + assertNotNull(bans, "Server.getBanList(NAME)"); + } + + @ConformanceTest(name = "Server.getOperators() returns non-null set", category = TestCategory.SERVER) + public void testGetOperators() { + var ops = Bukkit.getServer().getOperators(); + assertNotNull(ops, "Server.getOperators()"); + } + + @ConformanceTest(name = "Server.getWhitelistedPlayers() returns non-null set", category = TestCategory.SERVER) + public void testGetWhitelistedPlayers() { + var wl = Bukkit.getServer().getWhitelistedPlayers(); + assertNotNull(wl, "Server.getWhitelistedPlayers()"); + } + + @ConformanceTest(name = "Server.reloadWhitelist() executes without throwing", category = TestCategory.SERVER) + public void testReloadWhitelist() { + Bukkit.getServer().reloadWhitelist(); + } + + @ConformanceTest(name = "Server.getMotd() returns non-null", category = TestCategory.SERVER) + public void testGetMotd() { + String motd = Bukkit.getServer().getMotd(); + assertNotNull(motd, "Server.getMotd()"); + } + + @ConformanceTest(name = "Server.getAllowNether() returns boolean", category = TestCategory.SERVER) + public void testGetAllowNether() { + boolean nether = Bukkit.getServer().getAllowNether(); + assertTrue(nether || !nether, "Server.getAllowNether()"); + } } diff --git a/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/StubTests.java b/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/StubTests.java index 00856bf..d315359 100644 --- a/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/StubTests.java +++ b/java/patchbukkit-test-plugin/src/main/java/org/patchbukkit/testplugin/tests/StubTests.java @@ -10,114 +10,7 @@ import java.util.UUID; public final class StubTests { - @ConformanceTest(name = "Server.getWorlds() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testGetWorlds() { - Bukkit.getServer().getWorlds(); - } - @ConformanceTest(name = "Server.getMaxPlayers() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testGetMaxPlayers() { - Bukkit.getServer().getMaxPlayers(); - } - - @ConformanceTest(name = "Server.getPort() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testGetPort() { - Bukkit.getServer().getPort(); - } - - @ConformanceTest(name = "Server.getIp() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testGetIp() { - Bukkit.getServer().getIp(); - } - - @ConformanceTest(name = "Server.getViewDistance() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testGetViewDistance() { - Bukkit.getServer().getViewDistance(); - } - - @ConformanceTest(name = "Server.getSimulationDistance() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testGetSimulationDistance() { - Bukkit.getServer().getSimulationDistance(); - } - - @ConformanceTest(name = "Server.getUpdateFolder() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testGetUpdateFolder() { - Bukkit.getServer().getUpdateFolder(); - } - - @ConformanceTest(name = "Server.getUpdateFolderFile() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testGetUpdateFolderFile() { - Bukkit.getServer().getUpdateFolderFile(); - } - - @ConformanceTest(name = "Server.getConnectionThrottle() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testGetConnectionThrottle() { - Bukkit.getServer().getConnectionThrottle(); - } - - @ConformanceTest(name = "Server.broadcastMessage() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - @SuppressWarnings("deprecation") - public void testBroadcastMessage() { - Bukkit.getServer().broadcastMessage("test"); - } - - @ConformanceTest(name = "Server.getOfflinePlayer(UUID) throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testGetOfflinePlayer() { - Bukkit.getServer().getOfflinePlayer(UUID.randomUUID()); - } - - @ConformanceTest(name = "Server.getBanList() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testGetBanList() { - Bukkit.getServer().getBanList(org.bukkit.BanList.Type.NAME); - } - - @ConformanceTest(name = "Server.getOperators() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testGetOperators() { - Bukkit.getServer().getOperators(); - } - - @ConformanceTest(name = "Server.getWhitelistedPlayers() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testGetWhitelistedPlayers() { - Bukkit.getServer().getWhitelistedPlayers(); - } - - @ConformanceTest(name = "Server.reloadWhitelist() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testReloadWhitelist() { - Bukkit.getServer().reloadWhitelist(); - } - - @ConformanceTest(name = "Server.shutdown() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testShutdown() { - Bukkit.getServer().shutdown(); - } - - @ConformanceTest(name = "Server.getMotd() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testGetMotd() { - Bukkit.getServer().getMotd(); - } - - @ConformanceTest(name = "Server.getAllowNether() throws UnsupportedOperationException", - category = TestCategory.STUBS, expectation = TestExpectation.EXPECT_UNSUPPORTED) - public void testGetAllowNether() { - Bukkit.getServer().getAllowNether(); - } @ConformanceTest(name = "PaperLib detection classes and PaperLib.isPaper() are available", category = TestCategory.STUBS) public void testPaperLibDetection() throws ClassNotFoundException { diff --git a/java/patchbukkit/logs/latest.log b/java/patchbukkit/logs/latest.log deleted file mode 100644 index d63ce61..0000000 --- a/java/patchbukkit/logs/latest.log +++ /dev/null @@ -1 +0,0 @@ -[19:04:38] [Test worker/INFO]: [MoonriseCommon] Paper is using 8 worker threads, 1 I/O threads diff --git a/java/patchbukkit/src/main/java/org/patchbukkit/LibraryResolver.java b/java/patchbukkit/src/main/java/org/patchbukkit/LibraryResolver.java index 62f8427..eebc670 100644 --- a/java/patchbukkit/src/main/java/org/patchbukkit/LibraryResolver.java +++ b/java/patchbukkit/src/main/java/org/patchbukkit/LibraryResolver.java @@ -22,6 +22,9 @@ import org.eclipse.aether.transport.http.HttpTransporterFactory; import org.eclipse.aether.connector.basic.BasicRepositoryConnectorFactory; public final class LibraryResolver { + private static final java.util.logging.Logger LOGGER = + java.util.logging.Logger.getLogger("LibraryResolver"); + private LibraryResolver() {} public static List resolveLibraries( @@ -32,6 +35,14 @@ public final class LibraryResolver { return List.of(); } + try { + var config = patchbukkit.bridge.NativeBridgeFfi.getPatchBukkitConfig(patchbukkit.common.EmptyRequest.newBuilder().build()); + if (config != null && !config.getDownloadDependencies()) { + LOGGER.info("[PatchBukkit] Library downloading is disabled in configuration, skipping resolution."); + return List.of(); + } + } catch (Throwable ignored) {} + Set unique = new LinkedHashSet<>(); for (String line : coordinates.split("\n")) { String trimmed = line.trim(); diff --git a/java/patchbukkit/src/main/java/org/patchbukkit/PatchBukkitServer.java b/java/patchbukkit/src/main/java/org/patchbukkit/PatchBukkitServer.java index eb51e36..9be1344 100644 --- a/java/patchbukkit/src/main/java/org/patchbukkit/PatchBukkitServer.java +++ b/java/patchbukkit/src/main/java/org/patchbukkit/PatchBukkitServer.java @@ -28,6 +28,7 @@ import java.util.Date; import java.util.HashSet; import java.util.Iterator; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -727,21 +728,70 @@ public class PatchBukkitServer implements Server { rootConfig.removeAppender("ServerGuiConsole"); rootConfig.removeAppender("Async"); - rootConfig.addAppender(appender, org.apache.logging.log4j.Level.ALL, null); - rootConfig.setLevel(org.apache.logging.log4j.Level.ALL); + org.apache.logging.log4j.Level targetLevel = getEffectiveLog4jLevel(); + rootConfig.addAppender(appender, targetLevel, null); + rootConfig.setLevel(targetLevel); context.updateLoggers(); } catch (Throwable t) { ORIGINAL_ERR.println("[PatchBukkit] Failed to initialize Log4j native bridge appender: " + t.getMessage()); } } + public static org.apache.logging.log4j.Level getEffectiveLog4jLevel() { + try { + var cfg = NativeBridgeFfi.getPatchBukkitConfig(patchbukkit.common.EmptyRequest.newBuilder().build()); + if (cfg != null) { + if (cfg.getDebugBridge()) { + return org.apache.logging.log4j.Level.ALL; + } + String level = cfg.getLogLevel(); + if (level != null && !level.isBlank()) { + return switch (level.toLowerCase(Locale.ENGLISH).trim()) { + case "trace" -> org.apache.logging.log4j.Level.TRACE; + case "debug" -> org.apache.logging.log4j.Level.DEBUG; + case "warn", "warning" -> org.apache.logging.log4j.Level.WARN; + case "error", "severe" -> org.apache.logging.log4j.Level.ERROR; + case "off" -> org.apache.logging.log4j.Level.OFF; + case "all" -> org.apache.logging.log4j.Level.ALL; + default -> org.apache.logging.log4j.Level.INFO; + }; + } + } + } catch (Throwable ignored) {} + return org.apache.logging.log4j.Level.INFO; + } + + public static Level getEffectiveJulLevel() { + try { + var cfg = NativeBridgeFfi.getPatchBukkitConfig(patchbukkit.common.EmptyRequest.newBuilder().build()); + if (cfg != null) { + if (cfg.getDebugBridge()) { + return Level.ALL; + } + String level = cfg.getLogLevel(); + if (level != null && !level.isBlank()) { + return switch (level.toLowerCase(Locale.ENGLISH).trim()) { + case "trace" -> Level.FINEST; + case "debug" -> Level.FINE; + case "warn", "warning" -> Level.WARNING; + case "error", "severe" -> Level.SEVERE; + case "off" -> Level.OFF; + case "all" -> Level.ALL; + default -> Level.INFO; + }; + } + } + } catch (Throwable ignored) {} + return Level.INFO; + } + private static void configureJulLogger() { Logger root = Logger.getLogger(""); root.setUseParentHandlers(false); for (java.util.logging.Handler h : root.getHandlers()) { root.removeHandler(h); } - root.setLevel(Level.ALL); + root.setLevel(getEffectiveJulLevel()); try { root.addHandler(new org.bukkit.craftbukkit.util.ForwardLogHandler()); } catch (Throwable t) { diff --git a/java/patchbukkit/src/main/java/org/patchbukkit/PatchBukkitUnsafeValues.java b/java/patchbukkit/src/main/java/org/patchbukkit/PatchBukkitUnsafeValues.java index b947118..70b302f 100644 --- a/java/patchbukkit/src/main/java/org/patchbukkit/PatchBukkitUnsafeValues.java +++ b/java/patchbukkit/src/main/java/org/patchbukkit/PatchBukkitUnsafeValues.java @@ -71,8 +71,13 @@ public class PatchBukkitUnsafeValues implements UnsafeValues { public boolean isSupportedApiVersion(String apiVersion) { if (apiVersion == null) return false; final ApiVersion toCheck = ApiVersion.getOrCreateVersion(apiVersion); - var minimumApi = NativeBridgeFfi.getPatchBukkitConfig(EmptyRequest.newBuilder().build()).getMinimumSupportedPluginApi(); - final ApiVersion minimumVersion = ApiVersion.getOrCreateVersion(minimumApi); + String minimumApi = null; + try { + minimumApi = NativeBridgeFfi.getPatchBukkitConfig(EmptyRequest.newBuilder().build()).getMinimumSupportedPluginApi(); + } catch (Throwable ignored) {} + final ApiVersion minimumVersion = (minimumApi == null || minimumApi.isEmpty() || "0.0.0".equals(minimumApi)) + ? ApiVersion.getOrCreateVersion("1.13") + : ApiVersion.getOrCreateVersion(minimumApi); return !toCheck.isNewerThan(ApiVersion.CURRENT) && !toCheck.isOlderThan(minimumVersion); } diff --git a/java/patchbukkit/src/main/java/org/patchbukkit/entity/PatchBukkitEntity.java b/java/patchbukkit/src/main/java/org/patchbukkit/entity/PatchBukkitEntity.java index 99f9e26..411abdf 100644 --- a/java/patchbukkit/src/main/java/org/patchbukkit/entity/PatchBukkitEntity.java +++ b/java/patchbukkit/src/main/java/org/patchbukkit/entity/PatchBukkitEntity.java @@ -297,13 +297,17 @@ public class PatchBukkitEntity implements Entity { public @NotNull Location getLocation() { try { var location = NativeBridgeFfi.getLocation(BridgeUtils.convertUuid(this.uuid)); - if (location != null && location.hasWorld() && location.hasPosition()) { + if (location != null && location.hasWorld() && location.hasPosition() && location.getWorld().hasUuid()) { var world = PatchBukkitWorld.getOrCreate(BridgeUtils.convertUuid(location.getWorld().getUuid())); var position = location.getPosition(); return new Location(world, position.getX(), position.getY(), position.getZ(), location.getYaw(), location.getPitch()); } } catch (Throwable ignored) {} - return this.cachedLocation != null ? this.cachedLocation.clone() : new Location(null, 0, 0, 0); + Location fallback = this.cachedLocation != null ? this.cachedLocation.clone() : new Location(null, 0, 0, 0); + if (fallback.getWorld() == null && !Bukkit.getWorlds().isEmpty()) { + fallback.setWorld(Bukkit.getWorlds().get(0)); + } + return fallback; } @Override @@ -387,8 +391,16 @@ public class PatchBukkitEntity implements Entity { @Override public @NotNull World getWorld() { - var location = NativeBridgeFfi.getLocation(BridgeUtils.convertUuid(this.uuid)); - return PatchBukkitWorld.getOrCreate(BridgeUtils.convertUuid(location.getWorld().getUuid())); + try { + var location = NativeBridgeFfi.getLocation(BridgeUtils.convertUuid(this.uuid)); + if (location != null && location.hasWorld() && location.getWorld().hasUuid()) { + return PatchBukkitWorld.getOrCreate(BridgeUtils.convertUuid(location.getWorld().getUuid())); + } + } catch (Throwable ignored) {} + if (this.cachedLocation != null && this.cachedLocation.getWorld() != null) { + return this.cachedLocation.getWorld(); + } + return Bukkit.getWorlds().isEmpty() ? null : Bukkit.getWorlds().get(0); } @Override diff --git a/java/patchbukkit/src/main/java/org/patchbukkit/loader/PatchBukkitBootstrap.java b/java/patchbukkit/src/main/java/org/patchbukkit/loader/PatchBukkitBootstrap.java index 1993013..b7971c9 100644 --- a/java/patchbukkit/src/main/java/org/patchbukkit/loader/PatchBukkitBootstrap.java +++ b/java/patchbukkit/src/main/java/org/patchbukkit/loader/PatchBukkitBootstrap.java @@ -38,12 +38,32 @@ public class PatchBukkitBootstrap { return true; } + Set disabledPlugins = new HashSet<>(); + boolean debugBridge = false; + try { + var config = patchbukkit.bridge.NativeBridgeFfi.getPatchBukkitConfig(patchbukkit.common.EmptyRequest.newBuilder().build()); + if (config != null) { + for (String d : config.getDisabledPluginsList()) { + disabledPlugins.add(d.toLowerCase(Locale.ENGLISH)); + } + debugBridge = config.getDebugBridge(); + } + } catch (Throwable ignored) {} + + if (debugBridge && !disabledPlugins.isEmpty()) { + LOGGER.info("[PatchBukkit] Disabled plugins from config: " + disabledPlugins); + } + Map holders = new LinkedHashMap<>(); for (File jarFile : jarFiles) { try { PluginHolder holder = parsePluginHolder(jarFile); if (holder != null) { + if (disabledPlugins.contains(holder.name.toLowerCase(Locale.ENGLISH))) { + LOGGER.info("[PatchBukkit] Plugin '" + holder.name + "' is disabled in configuration, skipping."); + continue; + } holders.put(holder.name.toLowerCase(Locale.ENGLISH), holder); } } catch (Throwable t) { diff --git a/java/patchbukkit/src/test/java/org/patchbukkit/loader/CloudReflectionTest.java b/java/patchbukkit/src/test/java/org/patchbukkit/loader/CloudReflectionTest.java index 5ccf417..0e07e1f 100644 --- a/java/patchbukkit/src/test/java/org/patchbukkit/loader/CloudReflectionTest.java +++ b/java/patchbukkit/src/test/java/org/patchbukkit/loader/CloudReflectionTest.java @@ -17,7 +17,10 @@ public class CloudReflectionTest { public void testGrimAcCloudReflection() throws Exception { PatchBukkitServer.initServer(); - File grimFile = new File("/home/alex/Documents/Development/Rust/Pumpkin/plugins/data/patchbukkit/patchbukkit-plugins/grimac-bukkit-2.3.74-61caa53.jar"); + File grimFile = new File("build/test-plugins/GrimAC.jar"); + if (!grimFile.exists()) { + grimFile = new File("java/patchbukkit/build/test-plugins/GrimAC.jar"); + } if (!grimFile.exists()) return; PatchBukkitPluginClassLoader loader = new PatchBukkitPluginClassLoader( diff --git a/java/patchbukkit/src/test/java/org/patchbukkit/loader/ProtocolPluginsLoadTest.java b/java/patchbukkit/src/test/java/org/patchbukkit/loader/ProtocolPluginsLoadTest.java index a3a0939..b63494b 100644 --- a/java/patchbukkit/src/test/java/org/patchbukkit/loader/ProtocolPluginsLoadTest.java +++ b/java/patchbukkit/src/test/java/org/patchbukkit/loader/ProtocolPluginsLoadTest.java @@ -75,7 +75,7 @@ public class ProtocolPluginsLoadTest { @Test public void testLoadGrimAC() throws Exception { - File grimFile = new File("/home/alex/Documents/Development/Rust/Pumpkin/plugins/data/patchbukkit/patchbukkit-plugins/grimac-bukkit-2.3.74-61caa53.jar"); + File grimFile = testPluginsDir.resolve("GrimAC.jar").toFile(); if (!grimFile.exists()) { return; } diff --git a/proto/patchbukkit/config.proto b/proto/patchbukkit/config.proto index 12c48c0..d127a73 100644 --- a/proto/patchbukkit/config.proto +++ b/proto/patchbukkit/config.proto @@ -9,4 +9,9 @@ option java_package = "patchbukkit.config"; message GetPatchBukkitConfigResponse { string minimum_supported_plugin_api = 1; + repeated string disabled_plugins = 2; + bool download_dependencies = 3; + bool warn_unimplemented_api = 4; + bool debug_bridge = 5; + string log_level = 6; } diff --git a/rust/src/config/patchbukkit.rs b/rust/src/config/patchbukkit.rs index 1434aee..c259c8c 100644 --- a/rust/src/config/patchbukkit.rs +++ b/rust/src/config/patchbukkit.rs @@ -2,22 +2,135 @@ use std::{fs, path::Path}; use serde::{Deserialize, Serialize}; -#[derive(Deserialize, Serialize, Debug)] +#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq, Eq)] pub struct PatchBukkitConfig { + #[serde(default)] pub settings: SettingsConfig, + #[serde(default)] + pub jvm: JvmConfig, + #[serde(default)] + pub plugins: PluginsConfig, + #[serde(default)] + pub libraries: LibrariesConfig, + #[serde(default)] + pub diagnostics: DiagnosticsConfig, } -#[derive(Deserialize, Serialize, Debug)] +#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub struct SettingsConfig { + #[serde(skip_serializing_if = "Option::is_none")] pub minimum_supported_plugin_api: Option, } +#[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct JvmConfig { + /// Maximum heap memory allocated to the JVM (e.g. "2G", "1024M") + #[serde(skip_serializing_if = "Option::is_none")] + pub max_heap: Option, + /// Initial heap memory allocated to the JVM (e.g. "512M") + #[serde(skip_serializing_if = "Option::is_none")] + pub initial_heap: Option, + /// Additional JVM command-line flags + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub extra_args: Vec, +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct PluginsConfig { + /// Directory where Bukkit/Paper plugins are located + #[serde(default = "default_plugins_dir")] + pub directory: String, + /// List of plugin names to skip loading (case-insensitive) + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub disabled: Vec, +} + +fn default_plugins_dir() -> String { + "patchbukkit-plugins".to_string() +} + +impl Default for PluginsConfig { + fn default() -> Self { + Self { + directory: default_plugins_dir(), + disabled: Vec::new(), + } + } +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct LibrariesConfig { + /// Whether to automatically download declared Maven libraries for plugins + #[serde(default = "default_true")] + pub download_dependencies: bool, +} + +const fn default_true() -> bool { + true +} + +impl Default for LibrariesConfig { + fn default() -> Self { + Self { + download_dependencies: true, + } + } +} + +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub struct DiagnosticsConfig { + /// Minimum log level ("info", "warn", "error", "debug", "trace") + #[serde(default = "default_log_level")] + pub log_level: String, + /// Log warnings when plugins call unimplemented Bukkit API methods + #[serde(default = "default_true")] + pub warn_unimplemented_api: bool, + /// Enable verbose bridge and event logging + #[serde(default)] + pub debug_bridge: bool, +} + +fn default_log_level() -> String { + "info".to_string() +} + +impl Default for DiagnosticsConfig { + fn default() -> Self { + Self { + log_level: default_log_level(), + warn_unimplemented_api: true, + debug_bridge: false, + } + } +} + impl PatchBukkitConfig { pub const DEFAULT: PatchBukkitConfig = PatchBukkitConfig { settings: SettingsConfig { minimum_supported_plugin_api: None, }, + jvm: JvmConfig { + max_heap: None, + initial_heap: None, + extra_args: Vec::new(), + }, + plugins: PluginsConfig { + directory: String::new(), + disabled: Vec::new(), + }, + libraries: LibrariesConfig { + download_dependencies: true, + }, + diagnostics: DiagnosticsConfig { + log_level: String::new(), + warn_unimplemented_api: true, + debug_bridge: false, + }, }; pub fn parse>(config: S) -> Result { @@ -27,11 +140,126 @@ impl PatchBukkitConfig { pub fn get_or_create>(config_path: P) -> anyhow::Result { let config_path = config_path.as_ref(); if !config_path.exists() { - let default = PatchBukkitConfig::DEFAULT; - std::fs::write(config_path, &toml::to_string_pretty(&default)?)?; + let default = PatchBukkitConfig::default(); + let template = r#"# PatchBukkit Configuration File + +[settings] +# Minimum Bukkit/Paper plugin API version required (e.g. "1.20"). Leave unset for all versions. +# minimum-supported-plugin-api = "1.20" + +[jvm] +# Maximum and initial heap memory allocated to the embedded JVM (e.g. "2G", "512M") +# max-heap = "2G" +# initial-heap = "512M" + +# Additional JVM arguments passed on startup +extra-args = [] + +[plugins] +# Directory where plugin JARs are located +directory = "patchbukkit-plugins" + +# List of plugin names to skip loading (case-insensitive) +disabled = [] + +[libraries] +# Automatically download declared Maven libraries for plugins +download-dependencies = true + +[diagnostics] +# Minimum log level: "info", "warn", "error", "debug", "trace" (default: "info") +log-level = "info" + +# Log warnings when plugins call unimplemented Bukkit API methods +warn-unimplemented-api = true + +# Enable verbose logging for bridge and FFI events +debug-bridge = false +"#; + std::fs::write(config_path, template)?; return Ok(default); } let config = PatchBukkitConfig::parse(fs::read_to_string(config_path)?)?; Ok(config) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_empty_toml() { + let config = PatchBukkitConfig::parse("").unwrap(); + assert_eq!(config.plugins.directory, "patchbukkit-plugins"); + assert!(config.plugins.disabled.is_empty()); + assert!(config.libraries.download_dependencies); + assert_eq!(config.diagnostics.log_level, "info"); + assert!(config.diagnostics.warn_unimplemented_api); + assert!(!config.diagnostics.debug_bridge); + assert_eq!(config.jvm.max_heap, None); + assert_eq!(config.jvm.initial_heap, None); + assert!(config.jvm.extra_args.is_empty()); + } + + #[test] + fn test_parse_legacy_toml() { + let toml_str = r#" +[settings] +minimum-supported-plugin-api = "1.20" +"#; + let config = PatchBukkitConfig::parse(toml_str).unwrap(); + assert_eq!( + config.settings.minimum_supported_plugin_api.as_deref(), + Some("1.20") + ); + assert_eq!(config.plugins.directory, "patchbukkit-plugins"); + assert_eq!(config.diagnostics.log_level, "info"); + assert!(config.libraries.download_dependencies); + } + + #[test] + fn test_parse_full_toml() { + let toml_str = r#" +[settings] +minimum-supported-plugin-api = "1.20.4" + +[jvm] +max-heap = "4G" +initial-heap = "1G" +extra-args = ["-XX:+UseG1GC", "-Dcustom.prop=true"] + +[plugins] +directory = "plugins" +disabled = ["ProblematicPlugin", "OldPlugin"] + +[libraries] +download-dependencies = false + +[diagnostics] +log-level = "debug" +warn-unimplemented-api = false +debug-bridge = true +"#; + let config = PatchBukkitConfig::parse(toml_str).unwrap(); + assert_eq!( + config.settings.minimum_supported_plugin_api.as_deref(), + Some("1.20.4") + ); + assert_eq!(config.jvm.max_heap.as_deref(), Some("4G")); + assert_eq!(config.jvm.initial_heap.as_deref(), Some("1G")); + assert_eq!( + config.jvm.extra_args, + vec!["-XX:+UseG1GC", "-Dcustom.prop=true"] + ); + assert_eq!(config.plugins.directory, "plugins"); + assert_eq!( + config.plugins.disabled, + vec!["ProblematicPlugin", "OldPlugin"] + ); + assert!(!config.libraries.download_dependencies); + assert_eq!(config.diagnostics.log_level, "debug"); + assert!(!config.diagnostics.warn_unimplemented_api); + assert!(config.diagnostics.debug_bridge); + } +} diff --git a/rust/src/directories.rs b/rust/src/directories.rs index 6254a87..765684f 100644 --- a/rust/src/directories.rs +++ b/rust/src/directories.rs @@ -1,4 +1,7 @@ -use std::{fs, path::PathBuf}; +use std::{ + fs, + path::{Path, PathBuf}, +}; use pumpkin::plugin::Context; @@ -9,7 +12,19 @@ pub struct PatchBukkitDirectories { pub jassets: PathBuf, } -pub fn setup_directories(server: &Context) -> Result { +pub fn get_base_directory(server: &Context) -> Result { + let data_folder = std::path::absolute(server.get_data_folder()) + .map_err(|_| "Failed to get absolute directory from relative")?; + let server_root = data_folder + .parent() + .ok_or("Failed to determine server root from PatchBukkit data folder")?; + Ok(server_root.join("patchbukkit")) +} + +pub fn setup_directories( + server: &Context, + plugins_dir_name: &str, +) -> Result { let data_folder = std::path::absolute(server.get_data_folder()) .map_err(|_| "Failed to get absolute directory from relative")?; let server_root = data_folder @@ -17,7 +32,14 @@ pub fn setup_directories(server: &Context) -> Result Result anyhow::Result<()> { + fn initialize_jvm( + &mut self, + jassets_path: &PathBuf, + config: &crate::config::patchbukkit::PatchBukkitConfig, + ) -> anyhow::Result<()> { tracing::info!("Initializing JVM with assets path: {jassets_path:?}"); let mut jar_paths = Vec::new(); @@ -284,7 +288,7 @@ impl JvmWorker { .collect::>() .join(separator); - let jvm_args = InitArgsBuilder::new() + let mut builder = InitArgsBuilder::new() .version(JNIVersion::V21) .option(format!("-Djava.class.path={classpath}")) .option("-XX:+IgnoreUnrecognizedVMOptions") @@ -292,7 +296,28 @@ impl JvmWorker { .option("--enable-final-field-mutation=ALL-UNNAMED") .option("--add-opens=java.base/java.lang=ALL-UNNAMED") .option("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED") - .option("-Dcom.google.protobuf.useUnsafe=false") + .option("-Dcom.google.protobuf.useUnsafe=false"); + + if let Some(ref max_heap) = config.jvm.max_heap { + builder = builder.option(format!("-Xmx{max_heap}")); + } + if let Some(ref initial_heap) = config.jvm.initial_heap { + builder = builder.option(format!("-Xms{initial_heap}")); + } + for arg in &config.jvm.extra_args { + builder = builder.option(arg.clone()); + } + + if config.diagnostics.debug_bridge { + tracing::info!( + "Configuring JVM: max_heap={:?}, initial_heap={:?}, extra_args={:?}", + config.jvm.max_heap, + config.jvm.initial_heap, + config.jvm.extra_args + ); + } + + let jvm_args = builder .build() .map_err(|e| anyhow::anyhow!("Failed to build JVM init args: {e:?}"))?; diff --git a/rust/src/java/native_callbacks/command.rs b/rust/src/java/native_callbacks/command.rs index 8975a7d..e13f29e 100644 --- a/rust/src/java/native_callbacks/command.rs +++ b/rust/src/java/native_callbacks/command.rs @@ -54,7 +54,7 @@ pub fn ffi_native_bridge_register_command_impl(request: RegisterCommandRequest) .cloned() .collect(); - let node = init_java_command(primary_name.clone(), command_tx, description); + let node = init_java_command(primary_name, command_tx, description); let clean_perm = cmd_name.trim_start_matches('/'); let permission = format!("patchbukkit:command.{clean_perm}"); diff --git a/rust/src/java/native_callbacks/config.rs b/rust/src/java/native_callbacks/config.rs index eed3dec..74b1da3 100644 --- a/rust/src/java/native_callbacks/config.rs +++ b/rust/src/java/native_callbacks/config.rs @@ -14,6 +14,11 @@ pub fn ffi_native_bridge_get_patch_bukkit_config_impl( .settings .minimum_supported_plugin_api .clone() - .unwrap_or("0.0.0".to_string()), + .unwrap_or_else(|| "1.13".to_string()), + disabled_plugins: context.config.plugins.disabled.clone(), + download_dependencies: context.config.libraries.download_dependencies, + warn_unimplemented_api: context.config.diagnostics.warn_unimplemented_api, + debug_bridge: context.config.diagnostics.debug_bridge, + log_level: context.config.diagnostics.log_level.clone(), }) } diff --git a/rust/src/java/native_callbacks/entity.rs b/rust/src/java/native_callbacks/entity.rs index 5312066..c1d195b 100644 --- a/rust/src/java/native_callbacks/entity.rs +++ b/rust/src/java/native_callbacks/entity.rs @@ -154,7 +154,6 @@ pub fn ffi_native_bridge_teleport_entity_impl(request: TeleportEntityRequest) -> with_player(request.uuid.as_ref(), |player| { let position = pumpkin_util::math::vector3::Vector3::new(pos.x, pos.y, pos.z); let world = player.living_entity.entity.world.load_full(); - let player = player.clone(); ctx.runtime.spawn(async move { player.teleport(position, Some(yaw), Some(pitch), world); }); @@ -367,7 +366,6 @@ pub fn ffi_native_bridge_set_player_list_header_footer_impl( with_player(request.uuid.as_ref(), |player| { let header = request.header; let footer = request.footer; - let player = player.clone(); ctx.runtime.spawn(async move { let h = pumpkin_util::text::TextComponent::from_legacy_string(&header); let f = pumpkin_util::text::TextComponent::from_legacy_string(&footer); @@ -430,7 +428,6 @@ pub fn ffi_native_bridge_set_player_time_impl(request: SetPlayerTimeRequest) -> with_player(request.uuid.as_ref(), |player| { let time = request.time; let relative = request.relative; - let player = player.clone(); ctx.runtime.spawn(async move { let packet = pumpkin_protocol::java::client::play::CUpdateTime::new(0, time, relative); player.send_client_packet(&packet).await; @@ -441,7 +438,6 @@ pub fn ffi_native_bridge_set_player_time_impl(request: SetPlayerTimeRequest) -> pub fn ffi_native_bridge_reset_player_time_impl(request: Uuid) -> Option<()> { let ctx = CALLBACK_CONTEXT.get()?; with_player(Some(&request), |player| { - let player = player.clone(); ctx.runtime.spawn(async move { let world_time = player .world() @@ -461,7 +457,6 @@ pub fn ffi_native_bridge_set_player_weather_impl(request: SetPlayerWeatherReques let ctx = CALLBACK_CONTEXT.get()?; with_player(request.uuid.as_ref(), |player| { let weather = request.weather; - let player = player.clone(); ctx.runtime.spawn(async move { let event = if weather == 1 { pumpkin_protocol::java::client::play::GameEvent::BeginRaining @@ -477,7 +472,6 @@ pub fn ffi_native_bridge_set_player_weather_impl(request: SetPlayerWeatherReques pub fn ffi_native_bridge_reset_player_weather_impl(request: Uuid) -> Option<()> { let ctx = CALLBACK_CONTEXT.get()?; with_player(Some(&request), |player| { - let player = player.clone(); ctx.runtime.spawn(async move { let is_raining = player.world().is_raining(); let event = if is_raining { @@ -495,7 +489,6 @@ pub fn ffi_native_bridge_set_compass_target_impl(request: SetCompassTargetReques let ctx = CALLBACK_CONTEXT.get()?; let pos = request.position?; with_player(request.uuid.as_ref(), |player| { - let player = player.clone(); ctx.runtime.spawn(async move { let block_pos = pumpkin_util::math::position::BlockPos::new( pos.x as i32, @@ -609,7 +602,6 @@ pub fn ffi_native_bridge_stop_sound_impl(request: StopSoundRequest) -> Option<() pub fn ffi_native_bridge_send_block_change_impl(request: SendBlockChangeRequest) -> Option<()> { let ctx = CALLBACK_CONTEXT.get()?; with_player(request.uuid.as_ref(), |player| { - let player = player.clone(); let pos = pumpkin_util::math::position::BlockPos::new(request.x, request.y, request.z); let state_str = request.block_state; let clean_key = state_str @@ -635,7 +627,6 @@ pub fn ffi_native_bridge_send_block_change_impl(request: SendBlockChangeRequest) pub fn ffi_native_bridge_send_resource_pack_impl(request: SendResourcePackRequest) -> Option<()> { let ctx = CALLBACK_CONTEXT.get()?; with_player(request.uuid.as_ref(), |player| { - let player = player.clone(); let url = request.url; let prompt = request.prompt; let required = request.required; @@ -678,7 +669,6 @@ pub fn ffi_native_bridge_get_player_connection_info_impl( pub fn ffi_native_bridge_send_game_event_impl(request: SendGameEventRequest) -> Option<()> { let ctx = CALLBACK_CONTEXT.get()?; with_player(request.uuid.as_ref(), |player| { - let player = player.clone(); let event_type = request.event_type as u8; let value = request.value; ctx.runtime.spawn(async move { diff --git a/rust/src/java/native_callbacks/log.rs b/rust/src/java/native_callbacks/log.rs index 442441c..0c693c0 100644 --- a/rust/src/java/native_callbacks/log.rs +++ b/rust/src/java/native_callbacks/log.rs @@ -1,4 +1,7 @@ -use crate::proto::patchbukkit::log::{LogLevel, SendLogRequest}; +use crate::{ + java::native_callbacks::CALLBACK_CONTEXT, + proto::patchbukkit::log::{LogLevel, SendLogRequest}, +}; pub fn ffi_native_bridge_send_log_impl(request: SendLogRequest) -> Option<()> { let logger = &request.logger_name; @@ -9,6 +12,39 @@ pub fn ffi_native_bridge_send_log_impl(request: SendLogRequest) -> Option<()> { return Some(()); } + let min_level = CALLBACK_CONTEXT + .get() + .map(|ctx| ctx.config.diagnostics.log_level.as_str()) + .unwrap_or("info"); + + let debug_bridge = CALLBACK_CONTEXT + .get() + .map(|ctx| ctx.config.diagnostics.debug_bridge) + .unwrap_or(false); + + let allowed = debug_bridge + || match min_level { + "error" => matches!(request.level(), LogLevel::Severe), + "warn" | "warning" => matches!(request.level(), LogLevel::Severe | LogLevel::Warning), + "info" => matches!( + request.level(), + LogLevel::Severe | LogLevel::Warning | LogLevel::Info + ), + "debug" => matches!( + request.level(), + LogLevel::Severe | LogLevel::Warning | LogLevel::Info | LogLevel::Config + ), + "trace" | "all" => true, + _ => matches!( + request.level(), + LogLevel::Severe | LogLevel::Warning | LogLevel::Info + ), + }; + + if !allowed { + return Some(()); + } + match request.level() { LogLevel::Severe => tracing::error!(logger = %logger, "{}", msg), LogLevel::Warning => tracing::warn!(logger = %logger, "{}", msg), diff --git a/rust/src/java/native_callbacks/mod.rs b/rust/src/java/native_callbacks/mod.rs index 5f961b8..11425fc 100644 --- a/rust/src/java/native_callbacks/mod.rs +++ b/rust/src/java/native_callbacks/mod.rs @@ -50,9 +50,9 @@ pub use command::*; pub mod server; pub use server::*; -static CALLBACK_CONTEXT: OnceLock = OnceLock::new(); +pub(crate) static CALLBACK_CONTEXT: OnceLock = OnceLock::new(); -struct CallbackContext { +pub(crate) struct CallbackContext { pub plugin_context: Arc, pub runtime: tokio::runtime::Handle, pub command_tx: mpsc::Sender, diff --git a/rust/src/java/native_callbacks/server.rs b/rust/src/java/native_callbacks/server.rs index 7ff4fb8..a54fd1b 100644 --- a/rust/src/java/native_callbacks/server.rs +++ b/rust/src/java/native_callbacks/server.rs @@ -312,7 +312,9 @@ pub fn ffi_native_bridge_get_ban_list_impl( if let Ok(list) = ctx.plugin_context.server.data.banned_ip_list.try_read() { for entry in &list.banned_ips { let created = entry.created.unix_timestamp(); - let expires = entry.expires.map(|e| e.unix_timestamp()).unwrap_or(0); + let expires = entry + .expires + .map_or(0, time::OffsetDateTime::unix_timestamp); entries.push(BanEntryProto { target: entry.ip.to_string(), source: entry.source.clone(), @@ -322,19 +324,19 @@ pub fn ffi_native_bridge_get_ban_list_impl( }); } } - } else { - if let Ok(list) = ctx.plugin_context.server.data.banned_player_list.try_read() { - for entry in &list.banned_players { - let created = entry.created.unix_timestamp(); - let expires = entry.expires.map(|e| e.unix_timestamp()).unwrap_or(0); - entries.push(BanEntryProto { - target: entry.name.clone(), - source: entry.source.clone(), - created, - expires, - reason: entry.reason.clone(), - }); - } + } else if let Ok(list) = ctx.plugin_context.server.data.banned_player_list.try_read() { + for entry in &list.banned_players { + let created = entry.created.unix_timestamp(); + let expires = entry + .expires + .map_or(0, time::OffsetDateTime::unix_timestamp); + entries.push(BanEntryProto { + target: entry.name.clone(), + source: entry.source.clone(), + created, + expires, + reason: entry.reason.clone(), + }); } } diff --git a/rust/src/java/native_callbacks/world.rs b/rust/src/java/native_callbacks/world.rs index 46a8a3f..e9f3c67 100644 --- a/rust/src/java/native_callbacks/world.rs +++ b/rust/src/java/native_callbacks/world.rs @@ -614,9 +614,8 @@ pub fn ffi_native_bridge_save_world_impl(request: SaveWorldRequest) -> Option<() .cloned() .or_else(|| worlds.first().cloned())?; - let w = world.clone(); ctx.runtime.spawn(async move { - let _ = w.save().await; + let _ = world.save().await; }); Some(()) diff --git a/rust/src/java/plugin/command_manager.rs b/rust/src/java/plugin/command_manager.rs index a057ff9..1a03097 100644 --- a/rust/src/java/plugin/command_manager.rs +++ b/rust/src/java/plugin/command_manager.rs @@ -19,7 +19,7 @@ impl CommandManager { } pub fn get_tab_complete( - &mut self, + &self, env: &mut Env, sender: SimpleCommandSender, full_command: String, @@ -35,7 +35,7 @@ impl CommandManager { } fn try_tab_complete( - &mut self, + &self, env: &mut Env, sender: SimpleCommandSender, full_command: String, diff --git a/rust/src/java/resources.rs b/rust/src/java/resources.rs index fa081ce..ee3a397 100644 --- a/rust/src/java/resources.rs +++ b/rust/src/java/resources.rs @@ -25,7 +25,7 @@ fn cleanup_stale_files(resources_folder: &Path) { for entry in walkdir::WalkDir::new(resources_folder) .into_iter() - .filter_map(|e| e.ok()) + .filter_map(std::result::Result::ok) .filter(|e| e.file_type().is_file()) { let path = entry.path(); @@ -41,7 +41,7 @@ fn cleanup_stale_files(resources_folder: &Path) { for entry in walkdir::WalkDir::new(resources_folder) .contents_first(true) .into_iter() - .filter_map(|e| e.ok()) + .filter_map(std::result::Result::ok) .filter(|e| e.file_type().is_dir() && e.path() != resources_folder) { let _ = fs::remove_dir(entry.path()); diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 3303d9c..b2abc44 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -12,7 +12,7 @@ pub mod events; pub mod java; pub mod proto; -use directories::setup_directories; +use directories::{get_base_directory, setup_directories}; use tokio::sync::{mpsc, oneshot}; use crate::{ @@ -27,20 +27,24 @@ pub async fn on_load_inner(plugin: &PatchBukkitPlugin, server: Arc) -> server.init_log(); tracing::info!("Starting PatchBukkit"); - // Setup directories - let dirs = setup_directories(&server)?; + let base = get_base_directory(&server)?; + std::fs::create_dir_all(&base) + .map_err(|e| format!("Failed to create PatchBukkit base directory: {e}"))?; - let mut config_path = dirs.base.clone(); + let mut config_path = base; config_path.push("patchbukkit.config.toml"); let config = PatchBukkitConfig::get_or_create(config_path) .map_err(|e| format!("Failed to setup PatchBukkit config: {e}"))?; + // Setup directories + let dirs = setup_directories(&server, &config.plugins.directory)?; + // Manage embedded resources setup_resources(&dirs.jassets).map_err(|e| format!("Failed to setup resources: {e}"))?; let runtime_handle = plugin.runtime.handle().clone(); let command_tx = plugin.command_tx.clone(); - let server_clone = server.clone(); + let server_clone = server; // Run JVM initialization and Java plugin bootstrap in a background task // on PatchBukkit's dedicated runtime so Pumpkin's main startup is never blocked. diff --git a/rust/tests/bot_integration_test.rs b/rust/tests/bot_integration_test.rs new file mode 100644 index 0000000..3cc8ff7 --- /dev/null +++ b/rust/tests/bot_integration_test.rs @@ -0,0 +1,219 @@ +mod common; + +use std::fs; +use std::sync::Arc; +use std::time::Duration; + +use common::bot::BotClient; +use patchbukkit::{ + PatchBukkitPlugin, commands::SimpleCommandSender, directories::setup_directories, + java::jvm::commands::JvmCommand, +}; +use pumpkin::{PumpkinServer, data::VanillaData, plugin::Context}; +use pumpkin_config::{AdvancedConfiguration, BasicConfiguration, TelemetryConfig}; +use tokio::sync::oneshot; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_fake_bot_join_and_interaction() { + let _ = pumpkin::LOGGER_IMPL.set(None); + let _ = tracing_subscriber::fmt().try_init(); + let temp_dir = tempfile::tempdir().expect("Failed to create tempdir"); + let prev_dir = std::env::current_dir().expect("Failed to get current dir"); + std::env::set_current_dir(temp_dir.path()).expect("Failed to set current dir"); + + let basic_config = BasicConfiguration { + default_level_name: "test_bot_world".to_string(), + ..Default::default() + }; + + let mut advanced_config = AdvancedConfiguration::default(); + advanced_config.networking.java.enabled = true; + advanced_config.networking.java.address = "127.0.0.1:0".parse().unwrap(); + advanced_config.networking.java.online_mode = false; + advanced_config.networking.java.encryption = false; + advanced_config.networking.bedrock.enabled = false; + advanced_config.networking.rcon.enabled = false; + advanced_config.commands.use_console = false; + + let vanilla_data = VanillaData::load(); + let telemetry_config = TelemetryConfig::default(); + let pumpkin_server = Arc::new( + PumpkinServer::new( + basic_config, + advanced_config, + telemetry_config, + vanilla_data, + ) + .await, + ); + + let server_addr = pumpkin_server + .tcp_listener + .as_ref() + .expect("TCP listener should be bound") + .local_addr() + .expect("Should have local address"); + + println!("Pumpkin TCP server bound to ephemeral port: {server_addr}"); + + let metadata = pumpkin::plugin::PluginMetadata { + name: "patchbukkit".to_string(), + version: "0.1.0".to_string(), + authors: vec!["PumpkinMC".to_string()], + description: "PatchBukkit".to_string(), + dependencies: vec![], + permissions: vec![], + }; + + let handlers = Arc::new(arc_swap::ArcSwap::from_pointee( + std::collections::HashMap::new(), + )); + let context = Arc::new(Context::new( + metadata, + pumpkin_server.server.clone(), + handlers, + Arc::clone(&pumpkin_server.server.plugin_manager), + Arc::clone(&pumpkin::LOGGER_IMPL), + )); + + // Setup directories + let dirs = setup_directories(&context, "patchbukkit-plugins") + .expect("Failed to setup PatchBukkit directories"); + + // Copy test plugins if present + let test_plugins_dir = if prev_dir.join("java").exists() { + prev_dir + .join("java") + .join("patchbukkit") + .join("build") + .join("test-plugins") + } else if let Some(parent) = prev_dir.parent() { + parent + .join("java") + .join("patchbukkit") + .join("build") + .join("test-plugins") + } else { + std::path::PathBuf::from("java/patchbukkit/build/test-plugins") + }; + + let protocollib_source = test_plugins_dir.join("ProtocolLib.jar"); + if protocollib_source.exists() { + let _ = fs::copy(&protocollib_source, dirs.plugins.join("ProtocolLib.jar")); + } + + let plugin = PatchBukkitPlugin::new(); + patchbukkit::on_load_inner(&plugin, context.clone()) + .await + .expect("Failed to on_load PatchBukkit"); + + // Wait for JVM initialization + for _ in 0..100 { + tokio::time::sleep(Duration::from_millis(200)).await; + let (tx, rx) = oneshot::channel(); + if plugin + .command_tx + .send(JvmCommand::TriggerCommand { + full_command: "version".to_string(), + command_sender: SimpleCommandSender::Console, + respond_to: tx, + }) + .await + .is_ok() + && let Ok(res) = rx.await + && res.is_ok() + { + break; + } + } + + // Wait for ProtocolLib to finish initializing if present + if protocollib_source.exists() { + for _ in 0..150 { + tokio::time::sleep(Duration::from_millis(200)).await; + let (tx, rx) = oneshot::channel(); + if plugin + .command_tx + .send(JvmCommand::TriggerCommand { + full_command: "protocol".to_string(), + command_sender: SimpleCommandSender::Console, + respond_to: tx, + }) + .await + .is_ok() + && let Ok(res) = rx.await + && res.is_ok() + { + println!("ProtocolLib command responded successfully!"); + break; + } + } + } + + // Start Pumpkin server network accept loop in background + let server_run = pumpkin_server.clone(); + let server_task = tokio::spawn(async move { + server_run.start().await; + }); + tokio::time::sleep(Duration::from_millis(200)).await; + + // Connect fake bot + println!("Connecting fake bot 'TestBot_1' to {server_addr}..."); + let bot = BotClient::connect(server_addr, "TestBot_1") + .await + .expect("Failed to connect bot"); + + // Await transition into Play state (completes Handshake -> Login -> Config -> Play) + println!("Waiting for bot to enter Play state..."); + bot.wait_for_play(Duration::from_secs(30)) + .await + .expect("Bot should enter Play state successfully"); + println!("Bot successfully in Play state!"); + + // Verify player is registered on server + tokio::time::sleep(Duration::from_millis(200)).await; + let players = pumpkin_server.server.get_all_players(); + assert!( + !players.is_empty(), + "Server should have at least 1 player connected" + ); + let player = players + .iter() + .find(|p| p.gameprofile.name == "TestBot_1") + .expect("TestBot_1 should be registered on the server"); + assert_eq!(player.gameprofile.name, "TestBot_1"); + + // Send arm swing packet from bot + bot.swing_arm() + .await + .expect("Bot should be able to send SSwingArm"); + + // Send movement packet from bot + bot.move_to(0.0, 64.0, 0.0) + .await + .expect("Bot should be able to send SPlayerPosition"); + + // Send command packet from bot + bot.send_command("version") + .await + .expect("Bot should be able to send SChatCommand"); + + tokio::time::sleep(Duration::from_millis(300)).await; + + // Disconnect bot + bot.disconnect().await; + tokio::time::sleep(Duration::from_millis(300)).await; + + // Gracefully stop server + pumpkin::stop_server(); + let _ = server_task.await; + + // Gracefully unload PatchBukkit + let _ = patchbukkit::on_unload_inner(&plugin, context.clone()).await; + + // Restore working directory + let _ = std::env::set_current_dir(prev_dir); + tokio::task::spawn_blocking(move || drop(plugin)) + .await + .unwrap(); +} diff --git a/rust/tests/common/bot.rs b/rust/tests/common/bot.rs new file mode 100644 index 0000000..d15289a --- /dev/null +++ b/rust/tests/common/bot.rs @@ -0,0 +1,268 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use pumpkin_data::packet::CURRENT_MC_VERSION; +use pumpkin_protocol::codec::var_int::VarInt; +use pumpkin_protocol::java::client::config::{ + CConfigDisconnect, CConfigPing, CFinishConfig, CKnownPacks, +}; +use pumpkin_protocol::java::client::login::{CLoginDisconnect, CLoginSuccess, CSetCompression}; +use pumpkin_protocol::java::client::play::{CKeepAlive, CPlayerPosition}; +use pumpkin_protocol::java::packet_decoder::TCPNetworkDecoder; +use pumpkin_protocol::java::packet_encoder::TCPNetworkEncoder; +use pumpkin_protocol::java::server::config::{SAcknowledgeFinishConfig, SConfigPong, SKnownPacks}; +use pumpkin_protocol::java::server::handshake::SHandShake; +use pumpkin_protocol::java::server::login::{SLoginAcknowledged, SLoginStart}; +use pumpkin_protocol::java::server::play::{ + SChatCommand, SConfirmTeleport, SKeepAlive, SPlayerLoaded, SPlayerPosition, SSwingArm, +}; +use pumpkin_protocol::ser::NetworkWriteExt; +use pumpkin_protocol::{ClientPacket, ConnectionState, MultiVersionJavaPacket, ServerPacket}; +use pumpkin_util::math::vector3::Vector3; +use tokio::io::{BufReader, BufWriter}; +use tokio::net::TcpStream; +use tokio::net::tcp::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::sync::Mutex; +use tracing::{debug, trace}; +use uuid::Uuid; + +#[allow(dead_code)] +pub struct BotClient { + pub name: String, + pub uuid: Uuid, + network_writer: Arc>>>, + network_reader: Arc>>>, + pub in_play: Arc, + pub closed: Arc, +} + +impl BotClient { + fn write_packet(packet: &P) -> Result, anyhow::Error> { + let mut buf = Vec::new(); + buf.write_var_int(&VarInt(P::to_id(CURRENT_MC_VERSION)))?; + packet.write_packet_data(&mut buf, &CURRENT_MC_VERSION)?; + Ok(buf) + } + + pub async fn send_packet(&self, packet: &P) -> anyhow::Result<()> { + let buf = Self::write_packet(packet)?; + let mut encoder = self.network_writer.lock().await; + encoder.write_packet(buf.into()).await?; + encoder.flush().await?; + Ok(()) + } + + pub async fn connect(address: SocketAddr, name: &str) -> anyhow::Result> { + let stream = TcpStream::connect(address).await?; + let (read_half, write_half) = stream.into_split(); + let writer = Arc::new(Mutex::new(TCPNetworkEncoder::new(BufWriter::new( + write_half, + )))); + let reader = Arc::new(Mutex::new(TCPNetworkDecoder::new(BufReader::new( + read_half, + )))); + + let bot = Arc::new(Self { + name: name.to_string(), + uuid: Uuid::new_v4(), + network_writer: writer, + network_reader: reader, + in_play: Arc::new(AtomicBool::new(false)), + closed: Arc::new(AtomicBool::new(false)), + }); + + debug!("[BOT] Connected TCP stream to {address}, sending SHandShake..."); + // 1. Send Handshake + bot.send_packet(&SHandShake { + protocol_version: VarInt(CURRENT_MC_VERSION.protocol_version()), + server_address: address.ip().to_string().into_boxed_str(), + server_port: address.port(), + next_state: ConnectionState::Login, + }) + .await?; + + debug!("[BOT] Sent SHandShake, sending SLoginStart (name={name})..."); + // 2. Send Login Start + bot.send_packet(&SLoginStart { + name: name.to_string().into_boxed_str(), + uuid: bot.uuid, + }) + .await?; + + // 3. Start packet processing loop in background + let bot_clone = bot.clone(); + tokio::spawn(async move { + let mut connection_state = ConnectionState::Login; + trace!("[BOT] Background reader loop started in state: {connection_state:?}"); + loop { + if bot_clone.closed.load(Ordering::Relaxed) { + trace!("[BOT] Reader loop exiting: closed flag set"); + break; + } + let packet_opt = { + let mut reader_guard = bot_clone.network_reader.lock().await; + reader_guard.get_raw_packet().await + }; + + let raw = match packet_opt { + Ok(raw) => raw, + Err(e) => { + debug!("[BOT] Reader loop error reading packet: {e:?}"); + break; + } + }; + + trace!( + "[BOT] Received raw packet id=0x{:02X} ({}) len={} in state {:?}", + raw.id, + raw.id, + raw.payload.len(), + connection_state + ); + + match connection_state { + ConnectionState::Login => { + let mut bytebuf = &raw.payload[..]; + if raw.id == CSetCompression::to_id(CURRENT_MC_VERSION) { + trace!("[BOT] Login -> CSetCompression"); + if let Ok(pkt) = + CSetCompression::read(&mut bytebuf, &CURRENT_MC_VERSION) + { + debug!("[BOT] Enabling compression threshold {}", pkt.threshold.0); + bot_clone + .network_reader + .lock() + .await + .set_compression(pkt.threshold.0 as usize); + bot_clone + .network_writer + .lock() + .await + .set_compression((pkt.threshold.0 as usize, 6)); + } + } else if raw.id == CLoginSuccess::to_id(CURRENT_MC_VERSION) { + debug!( + "[BOT] Login -> CLoginSuccess! Sending SLoginAcknowledged and SKnownPacks" + ); + let _ = bot_clone.send_packet(&SLoginAcknowledged).await; + connection_state = ConnectionState::Config; + let _ = bot_clone + .send_packet(&SKnownPacks { + known_packs: Vec::new(), + }) + .await; + } else if raw.id == CLoginDisconnect::to_id(CURRENT_MC_VERSION) { + debug!("[BOT] Login -> CLoginDisconnect! Server kicked bot in login"); + break; + } else { + trace!("[BOT] Unhandled packet in Login state: id=0x{:02X}", raw.id); + } + } + ConnectionState::Config => { + let mut bytebuf = &raw.payload[..]; + if raw.id == CKnownPacks::to_id(CURRENT_MC_VERSION) { + trace!("[BOT] Config -> CKnownPacks, responding SKnownPacks"); + let _ = bot_clone + .send_packet(&SKnownPacks { + known_packs: Vec::new(), + }) + .await; + } else if raw.id == CConfigPing::to_id(CURRENT_MC_VERSION) { + trace!("[BOT] Config -> CConfigPing, responding SConfigPong"); + if let Ok(pkt) = CConfigPing::read(&mut bytebuf, &CURRENT_MC_VERSION) { + let _ = bot_clone.send_packet(&SConfigPong { id: pkt.id }).await; + } + } else if raw.id == CFinishConfig::to_id(CURRENT_MC_VERSION) { + debug!("[BOT] Config -> CFinishConfig! Transitioning to Play"); + let _ = bot_clone.send_packet(&SAcknowledgeFinishConfig).await; + connection_state = ConnectionState::Play; + } else if raw.id == CConfigDisconnect::to_id(CURRENT_MC_VERSION) { + debug!( + "[BOT] Config -> CConfigDisconnect! Server kicked bot in config" + ); + break; + } else { + trace!( + "[BOT] Unhandled packet in Config state: id=0x{:02X}", + raw.id + ); + } + } + ConnectionState::Play => { + let mut bytebuf = &raw.payload[..]; + if raw.id == CKeepAlive::to_id(CURRENT_MC_VERSION) { + if let Ok(pkt) = CKeepAlive::read(&mut bytebuf, &CURRENT_MC_VERSION) { + let _ = bot_clone + .send_packet(&SKeepAlive { + keep_alive_id: pkt.keep_alive_id, + }) + .await; + } + } else if raw.id == CPlayerPosition::to_id(CURRENT_MC_VERSION) { + debug!( + "[BOT] Play -> CPlayerPosition! Confirming teleport and setting in_play=true" + ); + if let Ok(pkt) = + CPlayerPosition::read(&mut bytebuf, &CURRENT_MC_VERSION) + { + let _ = bot_clone + .send_packet(&SConfirmTeleport { + teleport_id: pkt.teleport_id, + }) + .await; + let _ = bot_clone.send_packet(&SPlayerLoaded).await; + bot_clone.in_play.store(true, Ordering::Release); + } + } + } + _ => {} + } + } + trace!("[BOT] Reader loop ended, marking closed=true"); + bot_clone.closed.store(true, Ordering::Release); + }); + + Ok(bot) + } + + pub async fn wait_for_play(&self, timeout: Duration) -> anyhow::Result<()> { + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + if self.in_play.load(Ordering::Acquire) { + return Ok(()); + } + if self.closed.load(Ordering::Acquire) { + anyhow::bail!("Bot connection closed before entering Play state"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + anyhow::bail!("Timed out waiting for bot to enter Play state"); + } + + pub async fn send_command(&self, command: &str) -> anyhow::Result<()> { + self.send_packet(&SChatCommand { + command: command.trim_start_matches('/'), + }) + .await + } + + pub async fn swing_arm(&self) -> anyhow::Result<()> { + self.send_packet(&SSwingArm { hand: VarInt(0) }).await + } + + pub async fn move_to(&self, x: f64, y: f64, z: f64) -> anyhow::Result<()> { + self.send_packet(&SPlayerPosition { + position: Vector3::new(x, y, z), + collision: 1, // on ground + }) + .await + } + + pub async fn disconnect(&self) { + self.closed.store(true, Ordering::Release); + let mut writer = self.network_writer.lock().await; + let _ = writer.flush().await; + } +} diff --git a/rust/tests/common/mod.rs b/rust/tests/common/mod.rs new file mode 100644 index 0000000..1cbfd96 --- /dev/null +++ b/rust/tests/common/mod.rs @@ -0,0 +1 @@ +pub mod bot; diff --git a/rust/tests/pumpkin_server_test.rs b/rust/tests/pumpkin_server_test.rs index 5d560de..37d871d 100644 --- a/rust/tests/pumpkin_server_test.rs +++ b/rust/tests/pumpkin_server_test.rs @@ -55,7 +55,8 @@ async fn test_pumpkin_server_with_patchbukkit_and_plugins() { )); // Setup directories - let dirs = setup_directories(&context).expect("Failed to setup PatchBukkit directories"); + let dirs = setup_directories(&context, "patchbukkit-plugins") + .expect("Failed to setup PatchBukkit directories"); // Resolve test plugins built in java/patchbukkit/build/test-plugins let test_plugins_dir = if prev_dir.join("java").exists() { @@ -94,14 +95,40 @@ async fn test_pumpkin_server_with_patchbukkit_and_plugins() { ); } - let grimac_system_source = std::path::PathBuf::from( - "/home/alex/Documents/Development/Rust/Pumpkin/plugins/data/patchbukkit/patchbukkit-plugins/grimac-bukkit-2.3.74-61caa53.jar", - ); - if grimac_system_source.exists() { - let _ = fs::copy(&grimac_system_source, dirs.plugins.join("GrimAC.jar")); + let grimac_source = test_plugins_dir.join("GrimAC.jar"); + if grimac_source.exists() { + let _ = fs::copy(&grimac_source, dirs.plugins.join("GrimAC.jar")); println!("Copied GrimAC.jar to plugins directory: {:?}", dirs.plugins); } + let test_plugin_source = { + let direct = if prev_dir.join("java").exists() { + prev_dir.join("java/patchbukkit-test-plugin/build/libs/patchbukkit-test-plugin.jar") + } else if let Some(parent) = prev_dir.parent() { + parent.join("java/patchbukkit-test-plugin/build/libs/patchbukkit-test-plugin.jar") + } else { + std::path::PathBuf::from( + "java/patchbukkit-test-plugin/build/libs/patchbukkit-test-plugin.jar", + ) + }; + if direct.exists() { + direct + } else { + test_plugins_dir.join("patchbukkit-test-plugin.jar") + } + }; + if test_plugin_source.exists() { + fs::copy( + &test_plugin_source, + dirs.plugins.join("patchbukkit-test-plugin.jar"), + ) + .expect("Failed to copy patchbukkit-test-plugin.jar to plugins dir"); + println!( + "Copied patchbukkit-test-plugin.jar to plugins directory: {:?}", + dirs.plugins + ); + } + let plugin = PatchBukkitPlugin::new(); // Call on_load_inner @@ -150,12 +177,11 @@ async fn test_pumpkin_server_with_patchbukkit_and_plugins() { let pe_res = rx.await.expect("Failed to receive packetevents response"); assert!( pe_res.is_ok(), - "packetevents command should execute successfully: {:?}", - pe_res + "packetevents command should execute successfully: {pe_res:?}" ); } - if grimac_system_source.exists() { + if grimac_source.exists() { let (tx, rx) = oneshot::channel(); plugin .command_tx @@ -170,8 +196,7 @@ async fn test_pumpkin_server_with_patchbukkit_and_plugins() { let grim_res = rx.await.expect("Failed to receive grim response"); assert!( grim_res.is_ok(), - "grim command should execute successfully: {:?}", - grim_res + "grim command should execute successfully: {grim_res:?}" ); // Test PlayerJoinEvent with GrimAC and PacketEvents @@ -212,8 +237,27 @@ async fn test_pumpkin_server_with_patchbukkit_and_plugins() { .expect("Failed to receive PlayerJoin response"); assert!( !join_res.cancelled, - "PlayerJoin should not be cancelled: {:?}", - join_res + "PlayerJoin should not be cancelled: {join_res:?}" + ); + } + + // Run Bukkit API Conformance tests via patchbukkit-test-plugin + if test_plugin_source.exists() { + let (tx, rx) = oneshot::channel(); + plugin + .command_tx + .send(JvmCommand::TriggerCommand { + full_command: "pbtest all".to_string(), + command_sender: SimpleCommandSender::Console, + respond_to: tx, + }) + .await + .expect("Failed to send pbtest all TriggerCommand"); + + let pbtest_res = rx.await.expect("Failed to receive pbtest all response"); + assert!( + pbtest_res.is_ok(), + "pbtest all command should execute successfully: {pbtest_res:?}" ); }