) ignored -> task.run(), delayTicks);
return true;
- } catch (ReflectiveOperationException ignored) {
+ } catch (ReflectiveOperationException | RuntimeException ex) {
+ logReflectionFallback("getRegionScheduler#runDelayed", ex);
return false;
}
}
@@ -198,11 +209,27 @@ private PlatformTask scheduleGlobalRepeating(Runnable task, long delayTicks, lon
return () -> {
try {
cancel.invoke(scheduledTask);
- } catch (ReflectiveOperationException ignored) {
+ } catch (ReflectiveOperationException | RuntimeException ignored) {
}
};
- } catch (ReflectiveOperationException ignored) {
+ } catch (ReflectiveOperationException | RuntimeException ex) {
+ logReflectionFallback("getGlobalRegionScheduler#runAtFixedRate", ex);
return null;
}
}
+
+ /**
+ * Logs a fine-level diagnostic when a Folia/region-scheduler reflection call fails.
+ *
+ * Reflective calls into region-scheduler APIs can fail with more than just
+ * {@link ReflectiveOperationException} — a signature change on a newer or forked
+ * server (e.g. Purpur) can surface as {@link IllegalArgumentException} ("argument
+ * type mismatch") from {@link Method#invoke}. These are caught broadly so a single
+ * unexpected API drift falls back to the standard scheduler instead of breaking
+ * the caller (notably the async RTP location search).
+ */
+ private void logReflectionFallback(String operation, Throwable ex) {
+ plugin.getLogger().log(Level.FINE, "EzRTP: region-scheduler reflection call " + operation
+ + " failed, falling back to standard scheduler", ex);
+ }
}
diff --git a/ezrtp-bukkit/src/test/java/com/skyblockexp/ezrtp/platform/BukkitPlatformSchedulerFoliaTest.java b/ezrtp-bukkit/src/test/java/com/skyblockexp/ezrtp/platform/BukkitPlatformSchedulerFoliaTest.java
index 2c05660..f6ce310 100644
--- a/ezrtp-bukkit/src/test/java/com/skyblockexp/ezrtp/platform/BukkitPlatformSchedulerFoliaTest.java
+++ b/ezrtp-bukkit/src/test/java/com/skyblockexp/ezrtp/platform/BukkitPlatformSchedulerFoliaTest.java
@@ -48,6 +48,7 @@ class BukkitPlatformSchedulerFoliaTest {
void setUp() {
when(plugin.getServer()).thenReturn(server);
when(server.getScheduler()).thenReturn(bukkitScheduler);
+ when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("test"));
}
// --- scheduleRepeating ---
@@ -79,7 +80,9 @@ void scheduleRepeating_withoutFoliaAwareness_propagatesUnsupportedOperationExcep
*/
@Test
void scheduleRepeating_withFoliaCapabilities_doesNotThrow() {
- // No stub needed: the fixed implementation never reaches the Bukkit scheduler on Folia.
+ // scheduleRepeating intentionally stays a no-op on reflection failure (rather than
+ // falling back to the standard scheduler) since Folia forbids synchronous scheduling
+ // outright; see the comment in BukkitPlatformScheduler#scheduleRepeating.
BukkitPlatformScheduler scheduler =
new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PAPER_FOLIA);
@@ -110,11 +113,16 @@ void scheduleRepeating_withBukkitCapabilities_delegatesToBukkitScheduler() {
@Test
void executeGlobal_withFoliaCapabilities_doesNotThrow() {
- // No stub: the fix never reaches the Bukkit scheduler when regionizedRuntime=true.
+ // Reflection naturally fails in the test JVM (no Folia classes present), so this
+ // now falls through to the standard Bukkit scheduler, matching PaperPlatformScheduler.
+ BukkitTask mockTask = mock(BukkitTask.class);
+ when(bukkitScheduler.runTask(any(Plugin.class), any(Runnable.class))).thenReturn(mockTask);
+
BukkitPlatformScheduler scheduler =
new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PAPER_FOLIA);
assertDoesNotThrow(() -> scheduler.executeGlobal(() -> {}));
+ verify(bukkitScheduler).runTask(eq(plugin), any(Runnable.class));
}
@Test
@@ -134,7 +142,12 @@ void executeGlobal_withBukkitCapabilities_delegatesToBukkitScheduler() {
@Test
void executeGlobalDelayed_withFoliaCapabilities_doesNotThrow() {
- // No stub: the fix never reaches the Bukkit scheduler when regionizedRuntime=true.
+ // Reflection naturally fails in the test JVM (no Folia classes present), so this
+ // now falls through to the standard Bukkit scheduler, matching PaperPlatformScheduler.
+ BukkitTask mockTask = mock(BukkitTask.class);
+ when(bukkitScheduler.runTaskLater(any(Plugin.class), any(Runnable.class), anyLong()))
+ .thenReturn(mockTask);
+
BukkitPlatformScheduler scheduler =
new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PAPER_FOLIA);
@@ -161,22 +174,33 @@ void executeGlobalDelayed_withBukkitCapabilities_delegatesToBukkitScheduler() {
@Test
void executeRegion_withFoliaCapabilities_doesNotThrow() {
- // No stub: the fix never reaches the Bukkit scheduler when regionizedRuntime=true.
+ // Reflection naturally fails in the test JVM (no Folia classes present), so this
+ // now falls through to the standard Bukkit scheduler, matching PaperPlatformScheduler.
+ BukkitTask mockTask = mock(BukkitTask.class);
+ when(bukkitScheduler.runTask(any(Plugin.class), any(Runnable.class))).thenReturn(mockTask);
+
BukkitPlatformScheduler scheduler =
new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PAPER_FOLIA);
assertDoesNotThrow(() -> scheduler.executeRegion(null, 0, 0, () -> {}));
+ verify(bukkitScheduler).runTask(eq(plugin), any(Runnable.class));
}
// --- executeRegionDelayed ---
@Test
void executeRegionDelayed_withFoliaCapabilities_doesNotThrow() {
- // No stub: the fix never reaches the Bukkit scheduler when regionizedRuntime=true.
+ // Reflection naturally fails in the test JVM (no Folia classes present), so this
+ // now falls through to the standard Bukkit scheduler, matching PaperPlatformScheduler.
+ BukkitTask mockTask = mock(BukkitTask.class);
+ when(bukkitScheduler.runTaskLater(any(Plugin.class), any(Runnable.class), anyLong()))
+ .thenReturn(mockTask);
+
BukkitPlatformScheduler scheduler =
new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PAPER_FOLIA);
assertDoesNotThrow(() -> scheduler.executeRegionDelayed(null, 0, 0, () -> {}, 20L));
+ verify(bukkitScheduler).runTaskLater(eq(plugin), any(Runnable.class), eq(20L));
}
// --- teleportAsync ---
diff --git a/ezrtp-bukkit/src/test/java/com/skyblockexp/ezrtp/platform/BukkitPlatformSchedulerReflectionFallbackTest.java b/ezrtp-bukkit/src/test/java/com/skyblockexp/ezrtp/platform/BukkitPlatformSchedulerReflectionFallbackTest.java
new file mode 100644
index 0000000..5f15e80
--- /dev/null
+++ b/ezrtp-bukkit/src/test/java/com/skyblockexp/ezrtp/platform/BukkitPlatformSchedulerReflectionFallbackTest.java
@@ -0,0 +1,159 @@
+package com.skyblockexp.ezrtp.platform;
+
+import io.papermc.paper.threadedregions.scheduler.GlobalRegionScheduler;
+import io.papermc.paper.threadedregions.scheduler.RegionScheduler;
+import org.bukkit.Bukkit;
+import org.bukkit.Server;
+import org.bukkit.World;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.scheduler.BukkitScheduler;
+import org.bukkit.scheduler.BukkitTask;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Regression coverage mirroring {@code PaperPlatformSchedulerReflectionFallbackTest}, for the
+ * {@link BukkitPlatformScheduler} fallback that is used when the Paper runtime module is absent.
+ *
+ *
Confirms that when {@code regionizedRuntime()} is {@code true} (the region-scheduler API is
+ * present on the classpath) but the reflective {@code Method.invoke} call fails unexpectedly —
+ * e.g. with {@link IllegalArgumentException} ("argument type mismatch") rather than
+ * {@link ReflectiveOperationException} — the scheduler falls back to the standard Bukkit
+ * scheduler and still runs the submitted task, instead of throwing or silently dropping it.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class BukkitPlatformSchedulerReflectionFallbackTest {
+
+ @Mock
+ private Plugin plugin;
+
+ @Mock
+ private Server server;
+
+ @Mock
+ private BukkitScheduler bukkitScheduler;
+
+ @BeforeEach
+ void setUp() {
+ when(plugin.getServer()).thenReturn(server);
+ when(server.getScheduler()).thenReturn(bukkitScheduler);
+ when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("test"));
+ }
+
+ @Test
+ void executeRegion_fallsBackAndRunsTaskWhenRegionSchedulerReflectionThrows() {
+ World world = mock(World.class);
+ RegionScheduler brokenRegionScheduler = mock(RegionScheduler.class);
+ when(brokenRegionScheduler.run(any(Plugin.class), any(World.class), anyInt(), anyInt(), any()))
+ .thenThrow(new IllegalArgumentException("argument type mismatch"));
+
+ AtomicBoolean taskRan = new AtomicBoolean(false);
+ stubRunTaskToExecuteImmediately();
+
+ try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) {
+ mockedBukkit.when(Bukkit::getRegionScheduler).thenReturn(brokenRegionScheduler);
+
+ BukkitPlatformScheduler scheduler =
+ new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED);
+
+ assertDoesNotThrow(() -> scheduler.executeRegion(world, 0, 0, () -> taskRan.set(true)));
+ }
+
+ assertTrue(taskRan.get(), "Task must still run via the Bukkit-scheduler fallback");
+ verify(bukkitScheduler).runTask(eq(plugin), any(Runnable.class));
+ }
+
+ @Test
+ void executeGlobal_fallsBackAndRunsTaskWhenGlobalRegionSchedulerReflectionThrows() {
+ GlobalRegionScheduler brokenGlobalScheduler = mock(GlobalRegionScheduler.class);
+ when(brokenGlobalScheduler.run(any(Plugin.class), any()))
+ .thenThrow(new IllegalArgumentException("argument type mismatch"));
+
+ AtomicBoolean taskRan = new AtomicBoolean(false);
+ stubRunTaskToExecuteImmediately();
+
+ try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) {
+ mockedBukkit.when(Bukkit::getGlobalRegionScheduler).thenReturn(brokenGlobalScheduler);
+
+ BukkitPlatformScheduler scheduler =
+ new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED);
+
+ assertDoesNotThrow(() -> scheduler.executeGlobal(() -> taskRan.set(true)));
+ }
+
+ assertTrue(taskRan.get(), "Task must still run via the Bukkit-scheduler fallback");
+ verify(bukkitScheduler).runTask(eq(plugin), any(Runnable.class));
+ }
+
+ @Test
+ void executeGlobalDelayed_fallsBackWhenGlobalRegionSchedulerReflectionThrows() {
+ GlobalRegionScheduler brokenGlobalScheduler = mock(GlobalRegionScheduler.class);
+ when(brokenGlobalScheduler.runDelayed(any(Plugin.class), any(), anyLong()))
+ .thenThrow(new IllegalArgumentException("argument type mismatch"));
+
+ BukkitTask bukkitTask = mock(BukkitTask.class);
+ when(bukkitScheduler.runTaskLater(any(Plugin.class), any(Runnable.class), anyLong()))
+ .thenReturn(bukkitTask);
+
+ try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) {
+ mockedBukkit.when(Bukkit::getGlobalRegionScheduler).thenReturn(brokenGlobalScheduler);
+
+ BukkitPlatformScheduler scheduler =
+ new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED);
+
+ PlatformTask task = assertDoesNotThrow(() -> scheduler.executeGlobalDelayed(() -> {}, 20L));
+ assertNotNull(task);
+ }
+
+ verify(bukkitScheduler).runTaskLater(eq(plugin), any(Runnable.class), eq(20L));
+ }
+
+ @Test
+ void executeRegionDelayed_fallsBackWhenRegionSchedulerReflectionThrows() {
+ World world = mock(World.class);
+ RegionScheduler brokenRegionScheduler = mock(RegionScheduler.class);
+ when(brokenRegionScheduler.runDelayed(any(Plugin.class), any(World.class), anyInt(), anyInt(), any(), anyLong()))
+ .thenThrow(new IllegalArgumentException("argument type mismatch"));
+
+ try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) {
+ mockedBukkit.when(Bukkit::getRegionScheduler).thenReturn(brokenRegionScheduler);
+
+ BukkitPlatformScheduler scheduler =
+ new BukkitPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED);
+
+ assertDoesNotThrow(() -> scheduler.executeRegionDelayed(world, 0, 0, () -> {}, 20L));
+ }
+
+ verify(bukkitScheduler).runTaskLater(eq(plugin), any(Runnable.class), eq(20L));
+ }
+
+ private void stubRunTaskToExecuteImmediately() {
+ doAnswer(invocation -> {
+ Runnable task = invocation.getArgument(1);
+ task.run();
+ return mock(BukkitTask.class);
+ }).when(bukkitScheduler).runTask(any(Plugin.class), any(Runnable.class));
+ }
+}
diff --git a/ezrtp-common/pom.xml b/ezrtp-common/pom.xml
index 45c8778..236192d 100644
--- a/ezrtp-common/pom.xml
+++ b/ezrtp-common/pom.xml
@@ -5,13 +5,13 @@
com.skyblockexp
ezrtp-parent
- 3.4.2
+ 3.4.3
../pom.xml
com.skyblockexp
ezrtp-common
- 3.4.2
+ 3.4.3
jar
EzRTP Common
Shared utilities for EzRTP (platform-independent)
diff --git a/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/platform/PlatformRuntimeCapabilities.java b/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/platform/PlatformRuntimeCapabilities.java
index cd2840b..e66bf70 100644
--- a/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/platform/PlatformRuntimeCapabilities.java
+++ b/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/platform/PlatformRuntimeCapabilities.java
@@ -13,6 +13,15 @@ public record PlatformRuntimeCapabilities(boolean paperApi, boolean purpurApi, b
public static final PlatformRuntimeCapabilities PAPER_FOLIA = new PlatformRuntimeCapabilities(true, false, true);
public static final PlatformRuntimeCapabilities PURPUR = new PlatformRuntimeCapabilities(true, true, false);
+ /**
+ * A non-Folia Purpur (or Paper) build whose server classpath nonetheless carries the
+ * Folia regionized-runtime marker class, so {@code regionizedRuntime()} is detected as
+ * {@code true} even though the server is not actually running region-threaded. This is
+ * the exact combination reported against Purpur builds where the region-scheduler
+ * reflection path is exercised but not necessarily functional.
+ */
+ public static final PlatformRuntimeCapabilities PURPUR_REGIONIZED = new PlatformRuntimeCapabilities(true, true, true);
+
public boolean isStrictPaper() {
return paperApi && !purpurApi;
}
diff --git a/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/util/compat/ParticleCompat.java b/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/util/compat/ParticleCompat.java
index 3901d0c..9347cff 100644
--- a/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/util/compat/ParticleCompat.java
+++ b/ezrtp-common/src/main/java/com/skyblockexp/ezrtp/util/compat/ParticleCompat.java
@@ -23,7 +23,7 @@ public static void spawnParticle(World world, Particle particle, Location loc, i
double.class, double.class, double.class, double.class, Object.class, boolean.class);
m.invoke(world, particle, loc, count, offsetX, offsetY, offsetZ, extra, data, force);
return;
- } catch (ReflectiveOperationException | LinkageError ignored) {}
+ } catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) {}
try {
// Try alternate signature: spawnParticle(Particle, Location, int, double, double, double, double)
@@ -31,7 +31,7 @@ public static void spawnParticle(World world, Particle particle, Location loc, i
double.class, double.class, double.class, double.class);
m2.invoke(world, particle, loc, count, offsetX, offsetY, offsetZ, extra);
return;
- } catch (ReflectiveOperationException | LinkageError ignored) {}
+ } catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) {}
try {
// Legacy: spawnParticle(Location, Particle, int, double, double, double)
@@ -39,7 +39,7 @@ public static void spawnParticle(World world, Particle particle, Location loc, i
double.class, double.class, double.class);
m3.invoke(world, loc, particle, count, offsetX, offsetY, offsetZ);
return;
- } catch (ReflectiveOperationException | LinkageError ignored) {}
+ } catch (ReflectiveOperationException | LinkageError | RuntimeException ignored) {}
// Give up silently if no compatible method is found.
}
diff --git a/ezrtp-paper/pom.xml b/ezrtp-paper/pom.xml
index fdf43c3..619bf5b 100644
--- a/ezrtp-paper/pom.xml
+++ b/ezrtp-paper/pom.xml
@@ -5,13 +5,13 @@
com.skyblockexp
ezrtp-parent
- 3.4.2
+ 3.4.3
../pom.xml
com.skyblockexp
ezrtp-paper
- 3.4.2
+ 3.4.3
jar
EzRTP (Paper)
Paper-optimized module for EzRTP
@@ -32,7 +32,7 @@
com.skyblockexp
ezrtp-common
- 3.4.2
+ 3.4.3
provided
diff --git a/ezrtp-paper/src/main/java/com/skyblockexp/ezrtp/platform/PaperPlatformScheduler.java b/ezrtp-paper/src/main/java/com/skyblockexp/ezrtp/platform/PaperPlatformScheduler.java
index 7ee4642..4e2fa6f 100644
--- a/ezrtp-paper/src/main/java/com/skyblockexp/ezrtp/platform/PaperPlatformScheduler.java
+++ b/ezrtp-paper/src/main/java/com/skyblockexp/ezrtp/platform/PaperPlatformScheduler.java
@@ -8,6 +8,7 @@
import java.lang.reflect.Method;
import java.util.concurrent.CompletableFuture;
+import java.util.logging.Level;
public final class PaperPlatformScheduler implements PlatformScheduler {
@@ -55,7 +56,8 @@ private boolean invokeFoliaAsync(Runnable task) {
Method runNow = asyncScheduler.getClass().getMethod("runNow", Plugin.class, java.util.function.Consumer.class);
runNow.invoke(asyncScheduler, plugin, (java.util.function.Consumer) scheduledTask -> task.run());
return true;
- } catch (ReflectiveOperationException ignored) {
+ } catch (ReflectiveOperationException | RuntimeException ex) {
+ logReflectionFallback("getAsyncScheduler#runNow", ex);
return false;
}
}
@@ -69,7 +71,8 @@ private boolean invokeRegionTask(World world, int chunkX, int chunkZ, Runnable t
run.invoke(regionScheduler, plugin, world, chunkX, chunkZ,
(java.util.function.Consumer) scheduledTask -> task.run());
return true;
- } catch (ReflectiveOperationException ignored) {
+ } catch (ReflectiveOperationException | RuntimeException ex) {
+ logReflectionFallback("getRegionScheduler#run", ex);
return false;
}
}
@@ -120,7 +123,8 @@ private boolean invokeGlobalRun(Runnable task) {
run.invoke(globalScheduler, plugin,
(java.util.function.Consumer) ignored -> task.run());
return true;
- } catch (ReflectiveOperationException ignored) {
+ } catch (ReflectiveOperationException | RuntimeException ex) {
+ logReflectionFallback("getGlobalRegionScheduler#run", ex);
return false;
}
}
@@ -136,10 +140,11 @@ private PlatformTask invokeGlobalRunDelayed(Runnable task, long delayTicks) {
return () -> {
try {
cancel.invoke(scheduledTask);
- } catch (ReflectiveOperationException ignored) {
+ } catch (ReflectiveOperationException | RuntimeException ignored) {
}
};
- } catch (ReflectiveOperationException ignored) {
+ } catch (ReflectiveOperationException | RuntimeException ex) {
+ logReflectionFallback("getGlobalRegionScheduler#runDelayed", ex);
return null;
}
}
@@ -154,7 +159,8 @@ private boolean invokeRegionDelayed(
runDelayed.invoke(regionScheduler, plugin, world, chunkX, chunkZ,
(java.util.function.Consumer) ignored -> task.run(), delayTicks);
return true;
- } catch (ReflectiveOperationException ignored) {
+ } catch (ReflectiveOperationException | RuntimeException ex) {
+ logReflectionFallback("getRegionScheduler#runDelayed", ex);
return false;
}
}
@@ -177,11 +183,27 @@ private PlatformTask scheduleGlobalRepeating(Runnable task, long delayTicks, lon
return () -> {
try {
cancel.invoke(scheduledTask);
- } catch (ReflectiveOperationException ignored) {
+ } catch (ReflectiveOperationException | RuntimeException ignored) {
}
};
- } catch (ReflectiveOperationException ignored) {
+ } catch (ReflectiveOperationException | RuntimeException ex) {
+ logReflectionFallback("getGlobalRegionScheduler#runAtFixedRate", ex);
return null;
}
}
+
+ /**
+ * Logs a fine-level diagnostic when a Folia/region-scheduler reflection call fails.
+ *
+ * Reflective calls into region-scheduler APIs can fail with more than just
+ * {@link ReflectiveOperationException} — a signature change on a newer or forked
+ * server (e.g. Purpur) can surface as {@link IllegalArgumentException} ("argument
+ * type mismatch") from {@link Method#invoke}. These are caught broadly so a single
+ * unexpected API drift falls back to the standard scheduler instead of breaking
+ * the caller (notably the async RTP location search).
+ */
+ private void logReflectionFallback(String operation, Throwable ex) {
+ plugin.getLogger().log(Level.FINE, "EzRTP: region-scheduler reflection call " + operation
+ + " failed, falling back to standard scheduler", ex);
+ }
}
diff --git a/ezrtp-paper/src/test/java/com/skyblockexp/ezrtp/platform/PaperPlatformSchedulerReflectionFallbackTest.java b/ezrtp-paper/src/test/java/com/skyblockexp/ezrtp/platform/PaperPlatformSchedulerReflectionFallbackTest.java
new file mode 100644
index 0000000..d82710e
--- /dev/null
+++ b/ezrtp-paper/src/test/java/com/skyblockexp/ezrtp/platform/PaperPlatformSchedulerReflectionFallbackTest.java
@@ -0,0 +1,247 @@
+package com.skyblockexp.ezrtp.platform;
+
+import io.papermc.paper.threadedregions.scheduler.GlobalRegionScheduler;
+import io.papermc.paper.threadedregions.scheduler.RegionScheduler;
+import org.bukkit.Bukkit;
+import org.bukkit.Server;
+import org.bukkit.World;
+import org.bukkit.entity.Player;
+import org.bukkit.plugin.Plugin;
+import org.bukkit.scheduler.BukkitScheduler;
+import org.bukkit.scheduler.BukkitTask;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.MockedStatic;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Regression coverage for a real-world "/rtp" failure reported on Purpur.
+ *
+ *
{@link PlatformRuntimeCapabilitiesDetector} marks a server as {@code regionizedRuntime}
+ * purely by the presence of a Folia marker class on the classpath. As Paper continues merging
+ * Folia's regionized-threading model upstream, that marker class can be present on a Purpur (or
+ * Paper) server jar even when the server is not actually running region-threaded. When that
+ * happens, {@link PaperPlatformScheduler} still routes {@code executeRegion}/{@code executeGlobal}
+ * /etc. through reflective calls into {@code Bukkit.getRegionScheduler()} /
+ * {@code Bukkit.getGlobalRegionScheduler()}.
+ *
+ *
Previously, if those reflective {@code Method.invoke} calls failed with anything other than
+ * {@link ReflectiveOperationException} — for example {@link IllegalArgumentException} ("argument
+ * type mismatch") from an API signature drift — the exception propagated uncaught out of the
+ * scheduler and into the async RTP location-search {@link java.util.concurrent.CompletableFuture}
+ * chain, surfacing to players as "/rtp" always failing (logged as
+ * {@code CompletionException - java.lang.IllegalArgumentException: argument type mismatch}).
+ *
+ *
These tests simulate that exact failure (the reflective method lookup succeeds, but invoking
+ * it throws) and confirm the scheduler falls back to the standard Bukkit scheduler — without
+ * throwing, and without silently dropping the submitted task.
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.LENIENT)
+class PaperPlatformSchedulerReflectionFallbackTest {
+
+ @Mock
+ private Plugin plugin;
+
+ @Mock
+ private Server server;
+
+ @Mock
+ private BukkitScheduler bukkitScheduler;
+
+ @BeforeEach
+ void setUp() {
+ when(plugin.getServer()).thenReturn(server);
+ when(server.getScheduler()).thenReturn(bukkitScheduler);
+ when(plugin.getLogger()).thenReturn(java.util.logging.Logger.getLogger("test"));
+ }
+
+ @Test
+ void executeRegion_fallsBackAndRunsTaskWhenRegionSchedulerReflectionThrows() {
+ World world = mock(World.class);
+ RegionScheduler brokenRegionScheduler = mock(RegionScheduler.class);
+ when(brokenRegionScheduler.run(any(Plugin.class), any(World.class), anyInt(), anyInt(), any()))
+ .thenThrow(new IllegalArgumentException("argument type mismatch"));
+
+ AtomicBoolean taskRan = new AtomicBoolean(false);
+ stubRunTaskToExecuteImmediately();
+
+ try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) {
+ mockedBukkit.when(Bukkit::getRegionScheduler).thenReturn(brokenRegionScheduler);
+
+ PaperPlatformScheduler scheduler =
+ new PaperPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED);
+
+ assertDoesNotThrow(() -> scheduler.executeRegion(world, 0, 0, () -> taskRan.set(true)));
+ }
+
+ assertTrue(taskRan.get(), "Task must still run via the Bukkit-scheduler fallback");
+ verify(bukkitScheduler).runTask(eq(plugin), any(Runnable.class));
+ }
+
+ @Test
+ void executeGlobal_fallsBackAndRunsTaskWhenGlobalRegionSchedulerReflectionThrows() {
+ GlobalRegionScheduler brokenGlobalScheduler = mock(GlobalRegionScheduler.class);
+ when(brokenGlobalScheduler.run(any(Plugin.class), any()))
+ .thenThrow(new IllegalArgumentException("argument type mismatch"));
+
+ AtomicBoolean taskRan = new AtomicBoolean(false);
+ stubRunTaskToExecuteImmediately();
+
+ try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) {
+ mockedBukkit.when(Bukkit::getGlobalRegionScheduler).thenReturn(brokenGlobalScheduler);
+
+ PaperPlatformScheduler scheduler =
+ new PaperPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED);
+
+ assertDoesNotThrow(() -> scheduler.executeGlobal(() -> taskRan.set(true)));
+ }
+
+ assertTrue(taskRan.get(), "Task must still run via the Bukkit-scheduler fallback");
+ verify(bukkitScheduler).runTask(eq(plugin), any(Runnable.class));
+ }
+
+ @Test
+ void executeGlobalDelayed_fallsBackWhenGlobalRegionSchedulerReflectionThrows() {
+ GlobalRegionScheduler brokenGlobalScheduler = mock(GlobalRegionScheduler.class);
+ when(brokenGlobalScheduler.runDelayed(any(Plugin.class), any(), anyLong()))
+ .thenThrow(new IllegalArgumentException("argument type mismatch"));
+
+ BukkitTask bukkitTask = mock(BukkitTask.class);
+ when(bukkitScheduler.runTaskLater(any(Plugin.class), any(Runnable.class), anyLong()))
+ .thenReturn(bukkitTask);
+
+ try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) {
+ mockedBukkit.when(Bukkit::getGlobalRegionScheduler).thenReturn(brokenGlobalScheduler);
+
+ PaperPlatformScheduler scheduler =
+ new PaperPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED);
+
+ PlatformTask task = assertDoesNotThrow(() -> scheduler.executeGlobalDelayed(() -> {}, 20L));
+ assertNotNull(task);
+ }
+
+ verify(bukkitScheduler).runTaskLater(eq(plugin), any(Runnable.class), eq(20L));
+ }
+
+ @Test
+ void executeRegionDelayed_fallsBackWhenRegionSchedulerReflectionThrows() {
+ World world = mock(World.class);
+ RegionScheduler brokenRegionScheduler = mock(RegionScheduler.class);
+ when(brokenRegionScheduler.runDelayed(any(Plugin.class), any(World.class), anyInt(), anyInt(), any(), anyLong()))
+ .thenThrow(new IllegalArgumentException("argument type mismatch"));
+
+ try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) {
+ mockedBukkit.when(Bukkit::getRegionScheduler).thenReturn(brokenRegionScheduler);
+
+ PaperPlatformScheduler scheduler =
+ new PaperPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED);
+
+ assertDoesNotThrow(() -> scheduler.executeRegionDelayed(world, 0, 0, () -> {}, 20L));
+ }
+
+ verify(bukkitScheduler).runTaskLater(eq(plugin), any(Runnable.class), eq(20L));
+ }
+
+ @Test
+ void scheduleRepeating_fallsBackWhenGlobalRegionSchedulerReflectionThrows() {
+ GlobalRegionScheduler brokenGlobalScheduler = mock(GlobalRegionScheduler.class);
+ when(brokenGlobalScheduler.runAtFixedRate(any(Plugin.class), any(), anyLong(), anyLong()))
+ .thenThrow(new IllegalArgumentException("argument type mismatch"));
+
+ BukkitTask bukkitTask = mock(BukkitTask.class);
+ when(bukkitScheduler.runTaskTimer(any(Plugin.class), any(Runnable.class), anyLong(), anyLong()))
+ .thenReturn(bukkitTask);
+
+ try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) {
+ mockedBukkit.when(Bukkit::getGlobalRegionScheduler).thenReturn(brokenGlobalScheduler);
+
+ PaperPlatformScheduler scheduler =
+ new PaperPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED);
+
+ PlatformTask task = assertDoesNotThrow(() -> scheduler.scheduleRepeating(() -> {}, 1L, 20L));
+ assertNotNull(task);
+ }
+
+ verify(bukkitScheduler).runTaskTimer(eq(plugin), any(Runnable.class), eq(1L), eq(20L));
+ }
+
+ /**
+ * Feature-level check: a full teleport-style hop (region-thread candidate resolution
+ * followed by a global-thread completion callback) must still complete end-to-end when
+ * the region-scheduler reflection is broken, mirroring the "/rtp" search-and-teleport flow.
+ */
+ @Test
+ void regionThenGlobalSequence_completesEndToEndWhenReflectionIsBroken() {
+ World world = mock(World.class);
+ RegionScheduler brokenRegionScheduler = mock(RegionScheduler.class);
+ when(brokenRegionScheduler.run(any(Plugin.class), any(World.class), anyInt(), anyInt(), any()))
+ .thenThrow(new IllegalArgumentException("argument type mismatch"));
+ GlobalRegionScheduler brokenGlobalScheduler = mock(GlobalRegionScheduler.class);
+ when(brokenGlobalScheduler.run(any(Plugin.class), any()))
+ .thenThrow(new IllegalArgumentException("argument type mismatch"));
+
+ stubRunTaskToExecuteImmediately();
+
+ AtomicBoolean candidateResolved = new AtomicBoolean(false);
+ AtomicBoolean teleportCompleted = new AtomicBoolean(false);
+
+ try (MockedStatic mockedBukkit = mockStatic(Bukkit.class)) {
+ mockedBukkit.when(Bukkit::getRegionScheduler).thenReturn(brokenRegionScheduler);
+ mockedBukkit.when(Bukkit::getGlobalRegionScheduler).thenReturn(brokenGlobalScheduler);
+
+ PaperPlatformScheduler scheduler =
+ new PaperPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED);
+
+ assertDoesNotThrow(() -> {
+ scheduler.executeRegion(world, 0, 0, () -> {
+ candidateResolved.set(true);
+ scheduler.executeGlobal(() -> teleportCompleted.set(true));
+ });
+ });
+ }
+
+ assertTrue(candidateResolved.get(), "Candidate resolution must still run on the region fallback");
+ assertTrue(teleportCompleted.get(), "Teleport completion must still run on the global fallback");
+ }
+
+ @Test
+ void teleportAsync_doesNotDependOnRegionSchedulerReflection() {
+ Player player = mock(Player.class);
+ org.bukkit.Location destination = mock(org.bukkit.Location.class);
+ when(player.teleportAsync(destination))
+ .thenReturn(java.util.concurrent.CompletableFuture.completedFuture(true));
+
+ PaperPlatformScheduler scheduler =
+ new PaperPlatformScheduler(plugin, PlatformRuntimeCapabilities.PURPUR_REGIONIZED);
+
+ assertDoesNotThrow(() -> scheduler.teleportAsync(player, destination).join());
+ }
+
+ private void stubRunTaskToExecuteImmediately() {
+ doAnswer(invocation -> {
+ Runnable task = invocation.getArgument(1);
+ task.run();
+ return mock(BukkitTask.class);
+ }).when(bukkitScheduler).runTask(any(Plugin.class), any(Runnable.class));
+ }
+}
diff --git a/ezrtp-purpur/pom.xml b/ezrtp-purpur/pom.xml
index b8af9a2..e8069a4 100644
--- a/ezrtp-purpur/pom.xml
+++ b/ezrtp-purpur/pom.xml
@@ -5,7 +5,7 @@
com.skyblockexp
ezrtp-parent
- 3.4.2
+ 3.4.3
../pom.xml
@@ -18,12 +18,12 @@
com.skyblockexp
ezrtp-paper
- 3.4.2
+ 3.4.3
com.skyblockexp
ezrtp-common
- 3.4.2
+ 3.4.3
provided
diff --git a/ezrtp-spigot/pom.xml b/ezrtp-spigot/pom.xml
index 7afd140..d45c95d 100644
--- a/ezrtp-spigot/pom.xml
+++ b/ezrtp-spigot/pom.xml
@@ -5,13 +5,13 @@
com.skyblockexp
ezrtp-parent
- 3.4.2
+ 3.4.3
../pom.xml
com.skyblockexp
ezrtp-spigot
- 3.4.2
+ 3.4.3
jar
EzRTP (Spigot)
Spigot-optimized module for EzRTP
@@ -26,7 +26,7 @@
com.skyblockexp
ezrtp-common
- 3.4.2
+ 3.4.3
diff --git a/pom.xml b/pom.xml
index 1b2d690..8ab3878 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
com.skyblockexp
ezrtp-parent
- 3.4.2
+ 3.4.3
pom
EzRTP Parent
Aggregator POM for EzRTP multi-module build