diff --git a/pom.xml b/pom.xml index f95865b..0905319 100644 --- a/pom.xml +++ b/pom.xml @@ -41,7 +41,19 @@ - + + + org.junit.jupiter + junit-jupiter + 5.11.4 + test + + + org.mockito + mockito-core + 5.14.2 + test + @@ -74,6 +86,11 @@ maven-compiler-plugin 3.14.1 + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + diff --git a/src/main/java/net/tfminecraft/TFMCWeb/TFMCWeb.java b/src/main/java/net/tfminecraft/TFMCWeb/TFMCWeb.java index 311f00d..bca688e 100644 --- a/src/main/java/net/tfminecraft/TFMCWeb/TFMCWeb.java +++ b/src/main/java/net/tfminecraft/TFMCWeb/TFMCWeb.java @@ -39,8 +39,8 @@ public void onEnable() { gateService = new DiscordGateService(this, linkCache); if (!gateService.isRpcAvailable()) { - getLogger().warning( - "RPCharacters not found — Discord Survival gate disabled; link/HTTP still active." + getLogger().info( + "Discord Survival gate waiting for RPCharacters; availability is checked when used." ); } diff --git a/src/main/java/net/tfminecraft/TFMCWeb/gate/DiscordGateService.java b/src/main/java/net/tfminecraft/TFMCWeb/gate/DiscordGateService.java index 46d66eb..d0146d2 100644 --- a/src/main/java/net/tfminecraft/TFMCWeb/gate/DiscordGateService.java +++ b/src/main/java/net/tfminecraft/TFMCWeb/gate/DiscordGateService.java @@ -15,29 +15,39 @@ import net.tfminecraft.TFMCWeb.cache.LinkCache; /** - * Applies Survival Discord gate via RPCharacters (soft-depend, reflection). + * Applies Survival Discord gate via RPCharacters (optional, reflection). */ public final class DiscordGateService { private final JavaPlugin plugin; private final LinkCache linkCache; - private final boolean rpcAvailable; + private Plugin resolvedRpc; private Method setGateUuid; private Method setGatePlayer; public DiscordGateService(JavaPlugin plugin, LinkCache linkCache) { this.plugin = plugin; this.linkCache = linkCache; - this.rpcAvailable = resolveRpc(); } private boolean resolveRpc() { Plugin rpc = Bukkit.getPluginManager().getPlugin("RPCharacters"); if (rpc == null || !rpc.isEnabled()) { + resolvedRpc = null; + setGateUuid = null; + setGatePlayer = null; return false; } + if (rpc == resolvedRpc) { + return setGateUuid != null && setGatePlayer != null; + } + resolvedRpc = rpc; + setGateUuid = null; + setGatePlayer = null; try { - Class cls = Class.forName("net.tfminecraft.RPCharacters.RPCharacters"); + // RPC depends on TFMCWeb, so resolve only once it has enabled, using + // its own class rather than TFMCWeb's plugin class loader. + Class cls = rpc.getClass(); setGateUuid = cls.getMethod("setDiscordGate", UUID.class, boolean.class); setGatePlayer = cls.getMethod("setDiscordGate", Player.class, boolean.class); return true; @@ -52,7 +62,7 @@ private boolean resolveRpc() { } public boolean isRpcAvailable() { - return rpcAvailable; + return resolveRpc(); } /** @@ -77,7 +87,7 @@ public void applyGate(Player player) { } public void applyGate(Player player, boolean eligible) { - if (player == null || !rpcAvailable) { + if (player == null || !isRpcAvailable()) { return; } boolean required = player.getGameMode() == GameMode.SURVIVAL && !eligible; @@ -86,7 +96,7 @@ public void applyGate(Player player, boolean eligible) { /** Offline-safe: set UUID gate flag; RPC reevaluates if online. */ public void applyGate(UUID uuid, boolean eligible) { - if (uuid == null || !rpcAvailable) { + if (uuid == null || !isRpcAvailable()) { return; } Player online = Bukkit.getPlayer(uuid); @@ -99,7 +109,7 @@ public void applyGate(UUID uuid, boolean eligible) { } public void clearGate(UUID uuid) { - if (uuid == null || !rpcAvailable) { + if (uuid == null || !isRpcAvailable()) { return; } Player online = Bukkit.getPlayer(uuid); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 79dba5d..7587643 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -5,7 +5,8 @@ version: ${project.version} author: TFMC depend: [TLibs] -softdepend: [RPCharacters, Essentials] +# RPCharacters depends on TFMCWeb; its optional gate API is resolved at runtime. +softdepend: [Essentials] commands: linkdiscord: diff --git a/src/test/java/net/tfminecraft/TFMCWeb/gate/DiscordGateServiceTest.java b/src/test/java/net/tfminecraft/TFMCWeb/gate/DiscordGateServiceTest.java new file mode 100644 index 0000000..884b7ee --- /dev/null +++ b/src/test/java/net/tfminecraft/TFMCWeb/gate/DiscordGateServiceTest.java @@ -0,0 +1,146 @@ +package net.tfminecraft.TFMCWeb.gate; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; + +import java.util.UUID; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.entity.Player; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.java.JavaPlugin; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +import net.tfminecraft.TFMCWeb.cache.LinkCache; + +class DiscordGateServiceTest { + + private MockedStatic bukkit; + private PluginManager plugins; + private RpcPlugin rpc; + private Player player; + private UUID uuid; + private LinkCache cache; + private DiscordGateService gate; + + @BeforeEach + void setUp() { + bukkit = mockStatic(Bukkit.class); + plugins = mock(PluginManager.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + rpc = mock(RpcPlugin.class); + when(plugins.getPlugin("RPCharacters")).thenReturn(rpc); + JavaPlugin web = mock(JavaPlugin.class); + when(web.getLogger()).thenReturn(Logger.getAnonymousLogger()); + player = mock(Player.class); + uuid = UUID.randomUUID(); + when(player.getUniqueId()).thenReturn(uuid); + when(player.isOnline()).thenReturn(true); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + cache = new LinkCache(); + RpcPlugin.lastUuid = null; + RpcPlugin.lastRequired = null; + // TFMCWeb starts while its dependent RPCharacters is still disabled. + gate = new DiscordGateService(web, cache); + } + + @AfterEach + void tearDown() { + bukkit.close(); + } + + @Test + void freezesUnlinkedSurvivalPlayerAfterRpcEnablesLater() { + assertFalse(gate.isRpcAvailable()); + gate.applyGate(player); + assertNull(RpcPlugin.lastRequired); + + when(rpc.isEnabled()).thenReturn(true); + gate.applyGate(player); + + assertEquals(uuid, RpcPlugin.lastUuid); + assertEquals(Boolean.TRUE, RpcPlugin.lastRequired); + assertTrue(gate.isRpcAvailable()); + } + + @Test + void releasesEligiblePlayersAndDoesNotFreezeOtherGameModes() { + when(rpc.isEnabled()).thenReturn(true); + cache.putLinked(uuid, "discord-id", "player"); + gate.applyGate(player); + assertEquals(Boolean.FALSE, RpcPlugin.lastRequired); + + cache.putUnlinked(uuid); + for (GameMode mode : GameMode.values()) { + when(player.getGameMode()).thenReturn(mode); + gate.applyGate(player); + assertEquals(mode == GameMode.SURVIVAL, RpcPlugin.lastRequired); + } + } + + @Test + void appliesAndClearsOfflineGateAfterRpcEnablesLater() { + assertFalse(gate.isRpcAvailable()); + when(rpc.isEnabled()).thenReturn(true); + gate.applyGate(uuid, false); + assertEquals(uuid, RpcPlugin.lastUuid); + assertEquals(Boolean.TRUE, RpcPlugin.lastRequired); + + gate.clearGate(uuid); + assertEquals(Boolean.FALSE, RpcPlugin.lastRequired); + } + + @Test + void clearGateAlsoResolvesRpcAfterLateEnable() { + when(rpc.isEnabled()).thenReturn(true); + gate.clearGate(uuid); + assertEquals(uuid, RpcPlugin.lastUuid); + assertEquals(Boolean.FALSE, RpcPlugin.lastRequired); + } + + @Test + void toleratesMissingRpcAndStopsCallingDisabledRpc() { + when(plugins.getPlugin("RPCharacters")).thenReturn(null); + gate.applyGate(player); + assertFalse(gate.isRpcAvailable()); + assertNull(RpcPlugin.lastRequired); + + when(plugins.getPlugin("RPCharacters")).thenReturn(rpc); + when(rpc.isEnabled()).thenReturn(true); + assertTrue(gate.isRpcAvailable()); + when(rpc.isEnabled()).thenReturn(false); + gate.applyGate(player); + gate.applyGate(uuid, false); + gate.clearGate(uuid); + assertFalse(gate.isRpcAvailable()); + assertNull(RpcPlugin.lastRequired); + + when(rpc.isEnabled()).thenReturn(true); + gate.applyGate(player); + assertEquals(Boolean.TRUE, RpcPlugin.lastRequired); + } + + public static class RpcPlugin extends JavaPlugin { + static UUID lastUuid; + static Boolean lastRequired; + + public static void setDiscordGate(UUID uuid, boolean required) { + lastUuid = uuid; + lastRequired = required; + } + + public static void setDiscordGate(Player player, boolean required) { + setDiscordGate(player.getUniqueId(), required); + } + } +}