From 03e4d5018d162ea051f6004b0c215d2463946898 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:26:33 +0000 Subject: [PATCH] feat: log nonlethal knockouts in CoreProtect CoreProtect now logs every hit between players, but it never sees a nonlethal knockout: RPCharacters cancels the knockout blow, and CoreProtect skips cancelled hits. Report each knockout to CoreProtect's new logPvpKnockout API so staff see it next to the hits that led to it. CoreProtect is reached by reflection, so RPCharacters gains no load order against it and keeps working without it or with an older version, which gets one warning. Co-Authored-By: Claude Opus 5.5 --- .../pvp/CoreProtectKnockouts.java | 51 ++++++++ .../rpcharacters/pvp/PvpKnockoutManager.java | 4 +- .../pvp/CoreProtectKnockoutsTest.java | 110 ++++++++++++++++++ .../pvp/PvpKnockoutManagerTest.java | 5 +- 4 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 src/main/java/net/tfminecraft/rpcharacters/pvp/CoreProtectKnockouts.java create mode 100644 src/test/java/net/tfminecraft/rpcharacters/pvp/CoreProtectKnockoutsTest.java 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 00000000..ce24b012 --- /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 70c0cf4a..00862e4e 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 00000000..298e31d1 --- /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 3e72695c..e0b30da4 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); }