diff --git a/src/main/java/net/tfminecraft/rpcharacters/pvp/CoreProtectKnockouts.java b/src/main/java/net/tfminecraft/rpcharacters/pvp/CoreProtectKnockouts.java new file mode 100644 index 0000000..ce24b01 --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/CoreProtectKnockouts.java @@ -0,0 +1,51 @@ +package net.tfminecraft.rpcharacters.pvp; + +import java.lang.reflect.Method; +import java.util.logging.Level; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; + +/** + * Reports nonlethal knockouts to CoreProtect, which logs every hit between players but can't see + * a knockout itself because the knockout blow is cancelled. CoreProtect is reached by reflection + * so RPCharacters needs no load order against it, and works without it or with older versions. + */ +public final class CoreProtectKnockouts { + private static final Logger LOG = Logger.getLogger("RPCharacters"); + private static boolean warned; + + private CoreProtectKnockouts() { + } + + /** Logs the knockout of {@code victim} by {@code attacker}; true if CoreProtect queued it. */ + public static boolean log(Player attacker, Player victim) { + if (attacker == null || victim == null) { + return false; + } + try { + Plugin plugin = Bukkit.getPluginManager().getPlugin("CoreProtect"); + if (plugin == null || !plugin.isEnabled()) { + return false; + } + Object api = plugin.getClass().getMethod("getAPI").invoke(plugin); + if (api == null) { + return false; + } + Method logKnockout = api.getClass().getMethod("logPvpKnockout", Player.class, Player.class); + return Boolean.TRUE.equals(logKnockout.invoke(api, attacker, victim)); + } catch (ReflectiveOperationException | RuntimeException e) { + if (!warned) { + warned = true; + LOG.log(Level.WARNING, "[RPCharacters] CoreProtect could not log a knockout; it needs a version with logPvpKnockout", e); + } + return false; + } + } + + static void resetWarning() { + warned = false; + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java index 70c0cf4..00862e4 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java @@ -103,7 +103,9 @@ public void onLethalDamage(EntityDamageEvent event) { event.setCancelled(true); applyKnockout(player); - PvpStrikeService.handleKnockout(player, attackingPlayer(event)); + Player attacker = attackingPlayer(event); + CoreProtectKnockouts.log(attacker, player); + PvpStrikeService.handleKnockout(player, attacker); } /** diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/CoreProtectKnockoutsTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/CoreProtectKnockoutsTest.java new file mode 100644 index 0000000..298e31d --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/CoreProtectKnockoutsTest.java @@ -0,0 +1,110 @@ +package net.tfminecraft.rpcharacters.pvp; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Handler; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.plugin.Plugin; +import org.bukkit.plugin.PluginManager; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; + +class CoreProtectKnockoutsTest { + /** Stands in for CoreProtect's main class, which exposes its API through getAPI(). */ + public abstract static class CoreProtectPlugin implements Plugin { + public abstract Object getAPI(); + } + + /** Stands in for a CoreProtect API with knockout logging. */ + public static class KnockoutApi { + final List logged = new ArrayList<>(); + boolean result = true; + + public boolean logPvpKnockout(Player attacker, Player victim) { + logged.add(new Player[] { attacker, victim }); + return result; + } + } + + /** Stands in for a CoreProtect API from before knockout logging. */ + public static class OldApi { + } + + private MockedStatic bukkit; + private PluginManager plugins; + private CoreProtectPlugin coreProtect; + private final Player attacker = mock(Player.class); + private final Player victim = mock(Player.class); + private final List warnings = new ArrayList<>(); + private final Handler capture = new Handler() { + @Override public void publish(LogRecord record) { warnings.add(record); } + @Override public void flush() { } + @Override public void close() { } + }; + + @BeforeEach + void setUp() { + CoreProtectKnockouts.resetWarning(); + plugins = mock(PluginManager.class); + coreProtect = mock(CoreProtectPlugin.class); + when(coreProtect.isEnabled()).thenReturn(true); + when(plugins.getPlugin("CoreProtect")).thenReturn(coreProtect); + bukkit = mockStatic(Bukkit.class); + bukkit.when(Bukkit::getPluginManager).thenReturn(plugins); + Logger.getLogger("RPCharacters").addHandler(capture); + } + + @AfterEach + void tearDown() { + Logger.getLogger("RPCharacters").removeHandler(capture); + bukkit.close(); + } + + @Test + void knockoutIsPassedToCoreProtectsApi() { + KnockoutApi api = new KnockoutApi(); + when(coreProtect.getAPI()).thenReturn(api); + assertTrue(CoreProtectKnockouts.log(attacker, victim)); + assertEquals(1, api.logged.size()); + assertSame(attacker, api.logged.getFirst()[0]); + assertSame(victim, api.logged.getFirst()[1]); + api.result = false; + assertFalse(CoreProtectKnockouts.log(attacker, victim)); + assertTrue(warnings.isEmpty()); + } + + @Test + void missingPlayersOrAMissingDisabledOrUnreadyCoreProtectLogNothing() { + assertFalse(CoreProtectKnockouts.log(null, victim)); + assertFalse(CoreProtectKnockouts.log(attacker, null)); + when(coreProtect.getAPI()).thenReturn(null); + assertFalse(CoreProtectKnockouts.log(attacker, victim)); + when(coreProtect.isEnabled()).thenReturn(false); + assertFalse(CoreProtectKnockouts.log(attacker, victim)); + when(plugins.getPlugin("CoreProtect")).thenReturn(null); + assertFalse(CoreProtectKnockouts.log(attacker, victim)); + verify(coreProtect, times(1)).getAPI(); + assertTrue(warnings.isEmpty()); + } + + @Test + void olderCoreProtectWarnsOnceAndNeverBreaksTheKnockout() { + when(coreProtect.getAPI()).thenReturn(new OldApi()); + assertFalse(CoreProtectKnockouts.log(attacker, victim)); + assertFalse(CoreProtectKnockouts.log(attacker, victim)); + bukkit.when(Bukkit::getPluginManager).thenThrow(new IllegalStateException("server stopping")); + assertFalse(CoreProtectKnockouts.log(attacker, victim)); + assertEquals(1, warnings.size()); + assertTrue(warnings.getFirst().getMessage().contains("logPvpKnockout")); + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java index 3e72695..e0b30da 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java @@ -109,7 +109,8 @@ void downedPlayerSurvivesDamageWithoutAttacker(String cause) throws Exception { when(event.getFinalDamage()).thenReturn(4.0); var manager = new PvpKnockoutManager(); PlayerData nonlethal = data(false); - try (var players = mockStatic(PlayerManager.class); var strikes = mockStatic(PvpStrikeService.class)) { + try (var players = mockStatic(PlayerManager.class); var strikes = mockStatic(PvpStrikeService.class); + var coreProtect = mockStatic(CoreProtectKnockouts.class)) { players.when(() -> PlayerManager.get(attacker)).thenReturn(nonlethal); // SimpleFactions only counts a battle life when the victim really dies. PermadeathBattleExemption.set(p -> p == victim); @@ -121,6 +122,8 @@ void downedPlayerSurvivesDamageWithoutAttacker(String cause) throws Exception { manager.onLethalDamage(event); verify(event).setCancelled(true); strikes.verify(() -> PvpStrikeService.handleKnockout(victim, attacker)); + // The cancelled blow never reaches CoreProtect, so the knockout is reported to it directly. + coreProtect.verify(() -> CoreProtectKnockouts.log(attacker, victim)); } finally { PermadeathBattleExemption.set(null); }