feat: more config & testing stuff

This commit is contained in:
Alexander Medvedev 2026-09-18 20:19:58 +02:00
parent 1e8e203281
commit 0ec9711408
33 changed files with 1226 additions and 203 deletions

84
.gitignore vendored
View File

@ -1,13 +1,20 @@
# Rust # Rust
/rust/target/ /rust/target/
/target/ /target/
**/target/
**/*.rs.bk
*.pdb
# Rust resources # Rust resources
rust/resources/jassets/* rust/resources/jassets/*
!rust/resources/jassets/.gitkeep
# Gradle cache # Gradle cache
.gradle/ .gradle/
java/.gradle/ java/.gradle/
**/.gradle/
.gradletasknamecache
gradle-app.setting
# Build outputs # Build outputs
build/ build/
@ -19,22 +26,72 @@ java/protoc-gen-ffi/bin/
java/patchbukkit-test-plugin/build/ java/patchbukkit-test-plugin/build/
java/patchbukkit-test-plugin/bin/ java/patchbukkit-test-plugin/bin/
!rust/build/ !rust/build/
!rust/build/**
# Examples # Java compiled classes and JAR packages
examples/*/bin *.class
examples/*/run *.jar
*.war
*.ear
# Keep Gradle wrapper # Keep Gradle wrapper
!gradle/wrapper/gradle-wrapper.jar !gradle/wrapper/gradle-wrapper.jar
!java/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 # Never ignore source build dirs if any exist
!**/src/main/**/build/ !**/src/main/**/build/
!**/src/test/**/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 # IntelliJ IDEA
.idea/ .idea/
java/.idea/ java/.idea/
**/.idea/
*.iml
*.iws
*.ipr
# Eclipse # Eclipse
.classpath .classpath
@ -54,11 +111,20 @@ java/.idea/
# VS Code # VS Code
.vscode/ .vscode/
**/.vscode/
*.code-workspace
# OS and Environment
.DS_Store .DS_Store
.AppleDouble
# Runtime configs .LSOverride
config/ ._*
java/patchbukkit/config/ Thumbs.db
logs/ desktop.ini
java/patchbukkit/logs/ *~
*.swp
*.swo
.direnv/
.env
.env.*
!*.example

View File

@ -27,3 +27,5 @@ tasks.withType<JavaCompile> {
"-nowarn" "-nowarn"
)) ))
} }

View File

@ -23,10 +23,10 @@ public final class EntityTests {
} }
} }
@ConformanceTest(name = "Server.getEntity(UUID) stub", category = TestCategory.ENTITY, @ConformanceTest(name = "Server.getEntity(UUID) returns null for random UUID", category = TestCategory.ENTITY)
expectation = TestExpectation.EXPECT_UNSUPPORTED)
public void testGetEntity() { 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) @ConformanceTest(name = "Player flight state and speed getters run cleanly", category = TestCategory.ENTITY)

View File

@ -33,14 +33,10 @@ public final class RegistryTests {
assertNotNull(stream, "Registry.SOUNDS.stream()"); assertNotNull(stream, "Registry.SOUNDS.stream()");
} }
@ConformanceTest(name = "Server.getRegistry(Sound.class) returns registry", category = TestCategory.REGISTRY, @ConformanceTest(name = "Server.getRegistry(Sound.class) returns registry", category = TestCategory.REGISTRY)
expectation = TestExpectation.EXPECT_UNSUPPORTED)
public void testServerGetRegistry() { public void testServerGetRegistry() {
// Server.getRegistry() returns null in PatchBukkit — might throw or return null
Registry<Sound> reg = Bukkit.getServer().getRegistry(Sound.class); Registry<Sound> reg = Bukkit.getServer().getRegistry(Sound.class);
if (reg == null) { assertNotNull(reg, "Server.getRegistry(Sound.class)");
throw new UnsupportedOperationException("Server.getRegistry() returned null (not implemented)");
}
} }
} }

View File

@ -102,4 +102,106 @@ public final class ServerTests {
assertTrue(cmd == null, "Server.getPluginCommand(unknown) should be null"); 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()");
}
} }

View File

@ -10,114 +10,7 @@ import java.util.UUID;
public final class StubTests { 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) @ConformanceTest(name = "PaperLib detection classes and PaperLib.isPaper() are available", category = TestCategory.STUBS)
public void testPaperLibDetection() throws ClassNotFoundException { public void testPaperLibDetection() throws ClassNotFoundException {

View File

@ -1 +0,0 @@
[19:04:38] [Test worker/INFO]: [MoonriseCommon] Paper is using 8 worker threads, 1 I/O threads

View File

@ -22,6 +22,9 @@ import org.eclipse.aether.transport.http.HttpTransporterFactory;
import org.eclipse.aether.connector.basic.BasicRepositoryConnectorFactory; import org.eclipse.aether.connector.basic.BasicRepositoryConnectorFactory;
public final class LibraryResolver { public final class LibraryResolver {
private static final java.util.logging.Logger LOGGER =
java.util.logging.Logger.getLogger("LibraryResolver");
private LibraryResolver() {} private LibraryResolver() {}
public static List<File> resolveLibraries( public static List<File> resolveLibraries(
@ -32,6 +35,14 @@ public final class LibraryResolver {
return List.of(); 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<String> unique = new LinkedHashSet<>(); Set<String> unique = new LinkedHashSet<>();
for (String line : coordinates.split("\n")) { for (String line : coordinates.split("\n")) {
String trimmed = line.trim(); String trimmed = line.trim();

View File

@ -28,6 +28,7 @@ import java.util.Date;
import java.util.HashSet; import java.util.HashSet;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
@ -727,21 +728,70 @@ public class PatchBukkitServer implements Server {
rootConfig.removeAppender("ServerGuiConsole"); rootConfig.removeAppender("ServerGuiConsole");
rootConfig.removeAppender("Async"); rootConfig.removeAppender("Async");
rootConfig.addAppender(appender, org.apache.logging.log4j.Level.ALL, null); org.apache.logging.log4j.Level targetLevel = getEffectiveLog4jLevel();
rootConfig.setLevel(org.apache.logging.log4j.Level.ALL); rootConfig.addAppender(appender, targetLevel, null);
rootConfig.setLevel(targetLevel);
context.updateLoggers(); context.updateLoggers();
} catch (Throwable t) { } catch (Throwable t) {
ORIGINAL_ERR.println("[PatchBukkit] Failed to initialize Log4j native bridge appender: " + t.getMessage()); 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() { private static void configureJulLogger() {
Logger root = Logger.getLogger(""); Logger root = Logger.getLogger("");
root.setUseParentHandlers(false); root.setUseParentHandlers(false);
for (java.util.logging.Handler h : root.getHandlers()) { for (java.util.logging.Handler h : root.getHandlers()) {
root.removeHandler(h); root.removeHandler(h);
} }
root.setLevel(Level.ALL); root.setLevel(getEffectiveJulLevel());
try { try {
root.addHandler(new org.bukkit.craftbukkit.util.ForwardLogHandler()); root.addHandler(new org.bukkit.craftbukkit.util.ForwardLogHandler());
} catch (Throwable t) { } catch (Throwable t) {

View File

@ -71,8 +71,13 @@ public class PatchBukkitUnsafeValues implements UnsafeValues {
public boolean isSupportedApiVersion(String apiVersion) { public boolean isSupportedApiVersion(String apiVersion) {
if (apiVersion == null) return false; if (apiVersion == null) return false;
final ApiVersion toCheck = ApiVersion.getOrCreateVersion(apiVersion); final ApiVersion toCheck = ApiVersion.getOrCreateVersion(apiVersion);
var minimumApi = NativeBridgeFfi.getPatchBukkitConfig(EmptyRequest.newBuilder().build()).getMinimumSupportedPluginApi(); String minimumApi = null;
final ApiVersion minimumVersion = ApiVersion.getOrCreateVersion(minimumApi); 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); return !toCheck.isNewerThan(ApiVersion.CURRENT) && !toCheck.isOlderThan(minimumVersion);
} }

View File

@ -297,13 +297,17 @@ public class PatchBukkitEntity implements Entity {
public @NotNull Location getLocation() { public @NotNull Location getLocation() {
try { try {
var location = NativeBridgeFfi.getLocation(BridgeUtils.convertUuid(this.uuid)); 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 world = PatchBukkitWorld.getOrCreate(BridgeUtils.convertUuid(location.getWorld().getUuid()));
var position = location.getPosition(); var position = location.getPosition();
return new Location(world, position.getX(), position.getY(), position.getZ(), location.getYaw(), location.getPitch()); return new Location(world, position.getX(), position.getY(), position.getZ(), location.getYaw(), location.getPitch());
} }
} catch (Throwable ignored) {} } 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 @Override
@ -387,9 +391,17 @@ public class PatchBukkitEntity implements Entity {
@Override @Override
public @NotNull World getWorld() { public @NotNull World getWorld() {
try {
var location = NativeBridgeFfi.getLocation(BridgeUtils.convertUuid(this.uuid)); var location = NativeBridgeFfi.getLocation(BridgeUtils.convertUuid(this.uuid));
if (location != null && location.hasWorld() && location.getWorld().hasUuid()) {
return PatchBukkitWorld.getOrCreate(BridgeUtils.convertUuid(location.getWorld().getUuid())); 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 @Override
public void setRotation(float yaw, float pitch) { public void setRotation(float yaw, float pitch) {

View File

@ -38,12 +38,32 @@ public class PatchBukkitBootstrap {
return true; return true;
} }
Set<String> 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<String, PluginHolder> holders = new LinkedHashMap<>(); Map<String, PluginHolder> holders = new LinkedHashMap<>();
for (File jarFile : jarFiles) { for (File jarFile : jarFiles) {
try { try {
PluginHolder holder = parsePluginHolder(jarFile); PluginHolder holder = parsePluginHolder(jarFile);
if (holder != null) { 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); holders.put(holder.name.toLowerCase(Locale.ENGLISH), holder);
} }
} catch (Throwable t) { } catch (Throwable t) {

View File

@ -17,7 +17,10 @@ public class CloudReflectionTest {
public void testGrimAcCloudReflection() throws Exception { public void testGrimAcCloudReflection() throws Exception {
PatchBukkitServer.initServer(); 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; if (!grimFile.exists()) return;
PatchBukkitPluginClassLoader loader = new PatchBukkitPluginClassLoader( PatchBukkitPluginClassLoader loader = new PatchBukkitPluginClassLoader(

View File

@ -75,7 +75,7 @@ public class ProtocolPluginsLoadTest {
@Test @Test
public void testLoadGrimAC() throws Exception { 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()) { if (!grimFile.exists()) {
return; return;
} }

View File

@ -9,4 +9,9 @@ option java_package = "patchbukkit.config";
message GetPatchBukkitConfigResponse { message GetPatchBukkitConfigResponse {
string minimum_supported_plugin_api = 1; 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;
} }

View File

@ -2,22 +2,135 @@ use std::{fs, path::Path};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Debug)] #[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct PatchBukkitConfig { pub struct PatchBukkitConfig {
#[serde(default)]
pub settings: SettingsConfig, 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")] #[serde(rename_all = "kebab-case")]
pub struct SettingsConfig { pub struct SettingsConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub minimum_supported_plugin_api: Option<String>, pub minimum_supported_plugin_api: Option<String>,
} }
#[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<String>,
/// Initial heap memory allocated to the JVM (e.g. "512M")
#[serde(skip_serializing_if = "Option::is_none")]
pub initial_heap: Option<String>,
/// Additional JVM command-line flags
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extra_args: Vec<String>,
}
#[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<String>,
}
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 { impl PatchBukkitConfig {
pub const DEFAULT: PatchBukkitConfig = PatchBukkitConfig { pub const DEFAULT: PatchBukkitConfig = PatchBukkitConfig {
settings: SettingsConfig { settings: SettingsConfig {
minimum_supported_plugin_api: None, 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<S: AsRef<str>>(config: S) -> Result<Self, toml::de::Error> { pub fn parse<S: AsRef<str>>(config: S) -> Result<Self, toml::de::Error> {
@ -27,11 +140,126 @@ impl PatchBukkitConfig {
pub fn get_or_create<P: AsRef<Path>>(config_path: P) -> anyhow::Result<Self> { pub fn get_or_create<P: AsRef<Path>>(config_path: P) -> anyhow::Result<Self> {
let config_path = config_path.as_ref(); let config_path = config_path.as_ref();
if !config_path.exists() { if !config_path.exists() {
let default = PatchBukkitConfig::DEFAULT; let default = PatchBukkitConfig::default();
std::fs::write(config_path, &toml::to_string_pretty(&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); return Ok(default);
} }
let config = PatchBukkitConfig::parse(fs::read_to_string(config_path)?)?; let config = PatchBukkitConfig::parse(fs::read_to_string(config_path)?)?;
Ok(config) 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);
}
}

View File

@ -1,4 +1,7 @@
use std::{fs, path::PathBuf}; use std::{
fs,
path::{Path, PathBuf},
};
use pumpkin::plugin::Context; use pumpkin::plugin::Context;
@ -9,7 +12,19 @@ pub struct PatchBukkitDirectories {
pub jassets: PathBuf, pub jassets: PathBuf,
} }
pub fn setup_directories(server: &Context) -> Result<PatchBukkitDirectories, String> { pub fn get_base_directory(server: &Context) -> Result<PathBuf, String> {
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<PatchBukkitDirectories, String> {
let data_folder = std::path::absolute(server.get_data_folder()) let data_folder = std::path::absolute(server.get_data_folder())
.map_err(|_| "Failed to get absolute directory from relative")?; .map_err(|_| "Failed to get absolute directory from relative")?;
let server_root = data_folder let server_root = data_folder
@ -17,7 +32,14 @@ pub fn setup_directories(server: &Context) -> Result<PatchBukkitDirectories, Str
.ok_or("Failed to determine server root from PatchBukkit data folder")?; .ok_or("Failed to determine server root from PatchBukkit data folder")?;
let base = server_root.join("patchbukkit"); let base = server_root.join("patchbukkit");
let plugins = base.join("patchbukkit-plugins"); let plugins = if Path::new(plugins_dir_name).is_absolute() {
PathBuf::from(plugins_dir_name)
} else if plugins_dir_name == "patchbukkit-plugins" {
base.join("patchbukkit-plugins")
} else {
server_root.join(plugins_dir_name)
};
let plugin_updates = plugins.join("update"); let plugin_updates = plugins.join("update");
let jassets = base.join("jassets"); let jassets = base.join("jassets");
@ -25,7 +47,7 @@ pub fn setup_directories(server: &Context) -> Result<PatchBukkitDirectories, Str
.map_err(|err| format!("Failed to create jassets folder: {err:?}"))?; .map_err(|err| format!("Failed to create jassets folder: {err:?}"))?;
fs::create_dir_all(&plugins) fs::create_dir_all(&plugins)
.map_err(|err| format!("Failed to create patchbukkit-plugins folder: {err:?}"))?; .map_err(|err| format!("Failed to create plugins folder: {err:?}"))?;
let patchbukkit_jar_dest = jassets.join("patchbukkit.jar"); let patchbukkit_jar_dest = jassets.join("patchbukkit.jar");

View File

@ -6605,6 +6605,14 @@ where
} }
Box::pin(async move { Box::pin(async move {
if let Some(ctx) = crate::java::native_callbacks::CALLBACK_CONTEXT.get() {
if ctx.config.diagnostics.debug_bridge {
tracing::debug!(
"Dispatching async event to JVM worker for plugin {}",
self.plugin_name
);
}
}
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
if let Err(e) = command_tx if let Err(e) = command_tx
.send(JvmCommand::FireEvent { .send(JvmCommand::FireEvent {
@ -6634,6 +6642,14 @@ where
} }
Box::pin(async move { Box::pin(async move {
if let Some(ctx) = crate::java::native_callbacks::CALLBACK_CONTEXT.get() {
if ctx.config.diagnostics.debug_bridge {
tracing::debug!(
"Dispatching blocking event to JVM worker for plugin {}",
self.plugin_name
);
}
}
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
if let Err(e) = command_tx if let Err(e) = command_tx
.send(JvmCommand::FireEvent { .send(JvmCommand::FireEvent {

View File

@ -55,11 +55,11 @@ impl JvmWorker {
context.clone(), context.clone(),
runtime_handle, runtime_handle,
command_tx.clone(), command_tx.clone(),
config, config.clone(),
) )
.unwrap(); .unwrap();
self.context = Some(context); self.context = Some(context);
let result = self.initialize_jvm(&jassets_path); let result = self.initialize_jvm(&jassets_path, &config);
let _ = respond_to.send(result); let _ = respond_to.send(result);
} }
JvmCommand::LoadPlugin { JvmCommand::LoadPlugin {
@ -234,7 +234,11 @@ impl JvmWorker {
tracing::info!("JVM worker thread exited"); tracing::info!("JVM worker thread exited");
} }
fn initialize_jvm(&mut self, jassets_path: &PathBuf) -> 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:?}"); tracing::info!("Initializing JVM with assets path: {jassets_path:?}");
let mut jar_paths = Vec::new(); let mut jar_paths = Vec::new();
@ -284,7 +288,7 @@ impl JvmWorker {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(separator); .join(separator);
let jvm_args = InitArgsBuilder::new() let mut builder = InitArgsBuilder::new()
.version(JNIVersion::V21) .version(JNIVersion::V21)
.option(format!("-Djava.class.path={classpath}")) .option(format!("-Djava.class.path={classpath}"))
.option("-XX:+IgnoreUnrecognizedVMOptions") .option("-XX:+IgnoreUnrecognizedVMOptions")
@ -292,7 +296,28 @@ impl JvmWorker {
.option("--enable-final-field-mutation=ALL-UNNAMED") .option("--enable-final-field-mutation=ALL-UNNAMED")
.option("--add-opens=java.base/java.lang=ALL-UNNAMED") .option("--add-opens=java.base/java.lang=ALL-UNNAMED")
.option("--add-opens=java.base/java.lang.reflect=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() .build()
.map_err(|e| anyhow::anyhow!("Failed to build JVM init args: {e:?}"))?; .map_err(|e| anyhow::anyhow!("Failed to build JVM init args: {e:?}"))?;

View File

@ -54,7 +54,7 @@ pub fn ffi_native_bridge_register_command_impl(request: RegisterCommandRequest)
.cloned() .cloned()
.collect(); .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 clean_perm = cmd_name.trim_start_matches('/');
let permission = format!("patchbukkit:command.{clean_perm}"); let permission = format!("patchbukkit:command.{clean_perm}");

View File

@ -14,6 +14,11 @@ pub fn ffi_native_bridge_get_patch_bukkit_config_impl(
.settings .settings
.minimum_supported_plugin_api .minimum_supported_plugin_api
.clone() .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(),
}) })
} }

View File

@ -154,7 +154,6 @@ pub fn ffi_native_bridge_teleport_entity_impl(request: TeleportEntityRequest) ->
with_player(request.uuid.as_ref(), |player| { with_player(request.uuid.as_ref(), |player| {
let position = pumpkin_util::math::vector3::Vector3::new(pos.x, pos.y, pos.z); let position = pumpkin_util::math::vector3::Vector3::new(pos.x, pos.y, pos.z);
let world = player.living_entity.entity.world.load_full(); let world = player.living_entity.entity.world.load_full();
let player = player.clone();
ctx.runtime.spawn(async move { ctx.runtime.spawn(async move {
player.teleport(position, Some(yaw), Some(pitch), world); 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| { with_player(request.uuid.as_ref(), |player| {
let header = request.header; let header = request.header;
let footer = request.footer; let footer = request.footer;
let player = player.clone();
ctx.runtime.spawn(async move { ctx.runtime.spawn(async move {
let h = pumpkin_util::text::TextComponent::from_legacy_string(&header); let h = pumpkin_util::text::TextComponent::from_legacy_string(&header);
let f = pumpkin_util::text::TextComponent::from_legacy_string(&footer); 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| { with_player(request.uuid.as_ref(), |player| {
let time = request.time; let time = request.time;
let relative = request.relative; let relative = request.relative;
let player = player.clone();
ctx.runtime.spawn(async move { ctx.runtime.spawn(async move {
let packet = pumpkin_protocol::java::client::play::CUpdateTime::new(0, time, relative); let packet = pumpkin_protocol::java::client::play::CUpdateTime::new(0, time, relative);
player.send_client_packet(&packet).await; 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<()> { pub fn ffi_native_bridge_reset_player_time_impl(request: Uuid) -> Option<()> {
let ctx = CALLBACK_CONTEXT.get()?; let ctx = CALLBACK_CONTEXT.get()?;
with_player(Some(&request), |player| { with_player(Some(&request), |player| {
let player = player.clone();
ctx.runtime.spawn(async move { ctx.runtime.spawn(async move {
let world_time = player let world_time = player
.world() .world()
@ -461,7 +457,6 @@ pub fn ffi_native_bridge_set_player_weather_impl(request: SetPlayerWeatherReques
let ctx = CALLBACK_CONTEXT.get()?; let ctx = CALLBACK_CONTEXT.get()?;
with_player(request.uuid.as_ref(), |player| { with_player(request.uuid.as_ref(), |player| {
let weather = request.weather; let weather = request.weather;
let player = player.clone();
ctx.runtime.spawn(async move { ctx.runtime.spawn(async move {
let event = if weather == 1 { let event = if weather == 1 {
pumpkin_protocol::java::client::play::GameEvent::BeginRaining 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<()> { pub fn ffi_native_bridge_reset_player_weather_impl(request: Uuid) -> Option<()> {
let ctx = CALLBACK_CONTEXT.get()?; let ctx = CALLBACK_CONTEXT.get()?;
with_player(Some(&request), |player| { with_player(Some(&request), |player| {
let player = player.clone();
ctx.runtime.spawn(async move { ctx.runtime.spawn(async move {
let is_raining = player.world().is_raining(); let is_raining = player.world().is_raining();
let event = if 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 ctx = CALLBACK_CONTEXT.get()?;
let pos = request.position?; let pos = request.position?;
with_player(request.uuid.as_ref(), |player| { with_player(request.uuid.as_ref(), |player| {
let player = player.clone();
ctx.runtime.spawn(async move { ctx.runtime.spawn(async move {
let block_pos = pumpkin_util::math::position::BlockPos::new( let block_pos = pumpkin_util::math::position::BlockPos::new(
pos.x as i32, 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<()> { pub fn ffi_native_bridge_send_block_change_impl(request: SendBlockChangeRequest) -> Option<()> {
let ctx = CALLBACK_CONTEXT.get()?; let ctx = CALLBACK_CONTEXT.get()?;
with_player(request.uuid.as_ref(), |player| { 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 pos = pumpkin_util::math::position::BlockPos::new(request.x, request.y, request.z);
let state_str = request.block_state; let state_str = request.block_state;
let clean_key = state_str 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<()> { pub fn ffi_native_bridge_send_resource_pack_impl(request: SendResourcePackRequest) -> Option<()> {
let ctx = CALLBACK_CONTEXT.get()?; let ctx = CALLBACK_CONTEXT.get()?;
with_player(request.uuid.as_ref(), |player| { with_player(request.uuid.as_ref(), |player| {
let player = player.clone();
let url = request.url; let url = request.url;
let prompt = request.prompt; let prompt = request.prompt;
let required = request.required; 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<()> { pub fn ffi_native_bridge_send_game_event_impl(request: SendGameEventRequest) -> Option<()> {
let ctx = CALLBACK_CONTEXT.get()?; let ctx = CALLBACK_CONTEXT.get()?;
with_player(request.uuid.as_ref(), |player| { with_player(request.uuid.as_ref(), |player| {
let player = player.clone();
let event_type = request.event_type as u8; let event_type = request.event_type as u8;
let value = request.value; let value = request.value;
ctx.runtime.spawn(async move { ctx.runtime.spawn(async move {

View File

@ -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<()> { pub fn ffi_native_bridge_send_log_impl(request: SendLogRequest) -> Option<()> {
let logger = &request.logger_name; let logger = &request.logger_name;
@ -9,6 +12,39 @@ pub fn ffi_native_bridge_send_log_impl(request: SendLogRequest) -> Option<()> {
return Some(()); 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() { match request.level() {
LogLevel::Severe => tracing::error!(logger = %logger, "{}", msg), LogLevel::Severe => tracing::error!(logger = %logger, "{}", msg),
LogLevel::Warning => tracing::warn!(logger = %logger, "{}", msg), LogLevel::Warning => tracing::warn!(logger = %logger, "{}", msg),

View File

@ -50,9 +50,9 @@ pub use command::*;
pub mod server; pub mod server;
pub use server::*; pub use server::*;
static CALLBACK_CONTEXT: OnceLock<CallbackContext> = OnceLock::new(); pub(crate) static CALLBACK_CONTEXT: OnceLock<CallbackContext> = OnceLock::new();
struct CallbackContext { pub(crate) struct CallbackContext {
pub plugin_context: Arc<Context>, pub plugin_context: Arc<Context>,
pub runtime: tokio::runtime::Handle, pub runtime: tokio::runtime::Handle,
pub command_tx: mpsc::Sender<JvmCommand>, pub command_tx: mpsc::Sender<JvmCommand>,

View File

@ -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() { if let Ok(list) = ctx.plugin_context.server.data.banned_ip_list.try_read() {
for entry in &list.banned_ips { for entry in &list.banned_ips {
let created = entry.created.unix_timestamp(); 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 { entries.push(BanEntryProto {
target: entry.ip.to_string(), target: entry.ip.to_string(),
source: entry.source.clone(), source: entry.source.clone(),
@ -322,11 +324,12 @@ pub fn ffi_native_bridge_get_ban_list_impl(
}); });
} }
} }
} else { } else if let Ok(list) = ctx.plugin_context.server.data.banned_player_list.try_read() {
if let Ok(list) = ctx.plugin_context.server.data.banned_player_list.try_read() {
for entry in &list.banned_players { for entry in &list.banned_players {
let created = entry.created.unix_timestamp(); 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 { entries.push(BanEntryProto {
target: entry.name.clone(), target: entry.name.clone(),
source: entry.source.clone(), source: entry.source.clone(),
@ -336,7 +339,6 @@ pub fn ffi_native_bridge_get_ban_list_impl(
}); });
} }
} }
}
Some(GetBanListResponse { entries }) Some(GetBanListResponse { entries })
} }

View File

@ -614,9 +614,8 @@ pub fn ffi_native_bridge_save_world_impl(request: SaveWorldRequest) -> Option<()
.cloned() .cloned()
.or_else(|| worlds.first().cloned())?; .or_else(|| worlds.first().cloned())?;
let w = world.clone();
ctx.runtime.spawn(async move { ctx.runtime.spawn(async move {
let _ = w.save().await; let _ = world.save().await;
}); });
Some(()) Some(())

View File

@ -19,7 +19,7 @@ impl CommandManager {
} }
pub fn get_tab_complete( pub fn get_tab_complete(
&mut self, &self,
env: &mut Env, env: &mut Env,
sender: SimpleCommandSender, sender: SimpleCommandSender,
full_command: String, full_command: String,
@ -35,7 +35,7 @@ impl CommandManager {
} }
fn try_tab_complete( fn try_tab_complete(
&mut self, &self,
env: &mut Env, env: &mut Env,
sender: SimpleCommandSender, sender: SimpleCommandSender,
full_command: String, full_command: String,

View File

@ -25,7 +25,7 @@ fn cleanup_stale_files(resources_folder: &Path) {
for entry in walkdir::WalkDir::new(resources_folder) for entry in walkdir::WalkDir::new(resources_folder)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .filter_map(std::result::Result::ok)
.filter(|e| e.file_type().is_file()) .filter(|e| e.file_type().is_file())
{ {
let path = entry.path(); let path = entry.path();
@ -41,7 +41,7 @@ fn cleanup_stale_files(resources_folder: &Path) {
for entry in walkdir::WalkDir::new(resources_folder) for entry in walkdir::WalkDir::new(resources_folder)
.contents_first(true) .contents_first(true)
.into_iter() .into_iter()
.filter_map(|e| e.ok()) .filter_map(std::result::Result::ok)
.filter(|e| e.file_type().is_dir() && e.path() != resources_folder) .filter(|e| e.file_type().is_dir() && e.path() != resources_folder)
{ {
let _ = fs::remove_dir(entry.path()); let _ = fs::remove_dir(entry.path());

View File

@ -12,7 +12,7 @@ pub mod events;
pub mod java; pub mod java;
pub mod proto; pub mod proto;
use directories::setup_directories; use directories::{get_base_directory, setup_directories};
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot};
use crate::{ use crate::{
@ -27,20 +27,24 @@ pub async fn on_load_inner(plugin: &PatchBukkitPlugin, server: Arc<Context>) ->
server.init_log(); server.init_log();
tracing::info!("Starting PatchBukkit"); tracing::info!("Starting PatchBukkit");
// Setup directories let base = get_base_directory(&server)?;
let dirs = setup_directories(&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"); config_path.push("patchbukkit.config.toml");
let config = PatchBukkitConfig::get_or_create(config_path) let config = PatchBukkitConfig::get_or_create(config_path)
.map_err(|e| format!("Failed to setup PatchBukkit config: {e}"))?; .map_err(|e| format!("Failed to setup PatchBukkit config: {e}"))?;
// Setup directories
let dirs = setup_directories(&server, &config.plugins.directory)?;
// Manage embedded resources // Manage embedded resources
setup_resources(&dirs.jassets).map_err(|e| format!("Failed to setup resources: {e}"))?; setup_resources(&dirs.jassets).map_err(|e| format!("Failed to setup resources: {e}"))?;
let runtime_handle = plugin.runtime.handle().clone(); let runtime_handle = plugin.runtime.handle().clone();
let command_tx = plugin.command_tx.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 // Run JVM initialization and Java plugin bootstrap in a background task
// on PatchBukkit's dedicated runtime so Pumpkin's main startup is never blocked. // on PatchBukkit's dedicated runtime so Pumpkin's main startup is never blocked.

View File

@ -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();
}

268
rust/tests/common/bot.rs Normal file
View File

@ -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<Mutex<TCPNetworkEncoder<BufWriter<OwnedWriteHalf>>>>,
network_reader: Arc<Mutex<TCPNetworkDecoder<BufReader<OwnedReadHalf>>>>,
pub in_play: Arc<AtomicBool>,
pub closed: Arc<AtomicBool>,
}
impl BotClient {
fn write_packet<P: ClientPacket>(packet: &P) -> Result<Vec<u8>, 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<P: ClientPacket>(&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<Arc<Self>> {
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;
}
}

1
rust/tests/common/mod.rs Normal file
View File

@ -0,0 +1 @@
pub mod bot;

View File

@ -55,7 +55,8 @@ async fn test_pumpkin_server_with_patchbukkit_and_plugins() {
)); ));
// Setup directories // 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 // Resolve test plugins built in java/patchbukkit/build/test-plugins
let test_plugins_dir = if prev_dir.join("java").exists() { 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( let grimac_source = test_plugins_dir.join("GrimAC.jar");
"/home/alex/Documents/Development/Rust/Pumpkin/plugins/data/patchbukkit/patchbukkit-plugins/grimac-bukkit-2.3.74-61caa53.jar", if grimac_source.exists() {
); let _ = fs::copy(&grimac_source, dirs.plugins.join("GrimAC.jar"));
if grimac_system_source.exists() {
let _ = fs::copy(&grimac_system_source, dirs.plugins.join("GrimAC.jar"));
println!("Copied GrimAC.jar to plugins directory: {:?}", dirs.plugins); 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(); let plugin = PatchBukkitPlugin::new();
// Call on_load_inner // 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"); let pe_res = rx.await.expect("Failed to receive packetevents response");
assert!( assert!(
pe_res.is_ok(), pe_res.is_ok(),
"packetevents command should execute successfully: {:?}", "packetevents command should execute successfully: {pe_res:?}"
pe_res
); );
} }
if grimac_system_source.exists() { if grimac_source.exists() {
let (tx, rx) = oneshot::channel(); let (tx, rx) = oneshot::channel();
plugin plugin
.command_tx .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"); let grim_res = rx.await.expect("Failed to receive grim response");
assert!( assert!(
grim_res.is_ok(), grim_res.is_ok(),
"grim command should execute successfully: {:?}", "grim command should execute successfully: {grim_res:?}"
grim_res
); );
// Test PlayerJoinEvent with GrimAC and PacketEvents // 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"); .expect("Failed to receive PlayerJoin response");
assert!( assert!(
!join_res.cancelled, !join_res.cancelled,
"PlayerJoin should not be cancelled: {:?}", "PlayerJoin should not be cancelled: {join_res:?}"
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:?}"
); );
} }