diff --git a/README.md b/README.md index c8cae39..e0dc4df 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Beyond a name and appearance, characters have traits, professions, injuries, and - **Progression and rolls** — bring professions, attributes, and dice rolls into character gameplay. - **Injuries and recovery** — represent injuries and prosthetics, with related treatment and progression systems. - **Consequences and investigation** — support lethal or nonlethal PvP, graves, and discoverable clues left in the world. +- **Nonlethal knockouts** — GSit holds downed players in a crawl pose for the knockout duration, alongside freeze and blindness. The pose uses GSit's API, bypasses command restrictions, respects other plugins' crawl vetoes, and releases only knockout-created crawls on recovery. GSit is optional for the rest of RPCharacters; without it, knockouts retain freeze and blindness only. - **PvP strikes** — after `/pvp start`, the fight lasts 15 minutes unless the player who started it runs `/pvp end`. When it ends, players who have not died see a title that a new RP interaction is needed. Whoever kills or knocks someone out chooses to spare them or give a strike; the third strike kills the character, though a killer can wound or maim instead of killing. Lockpicking, robbing, pickpocketing and looting locked graves start a timed evil RP session, during which any strike kills and a death leaves an unlocked grave. ## Beyond the game diff --git a/pom.xml b/pom.xml index 5404722..157cbe3 100644 --- a/pom.xml +++ b/pom.xml @@ -17,6 +17,12 @@ + + maven.modrinth + gsit + 3.3.1 + provided + org.mockito mockito-core @@ -136,6 +142,10 @@ + + modrinth + https://api.modrinth.com/maven + papermc https://repo.papermc.io/repository/maven-public/ diff --git a/src/main/java/net/tfminecraft/rpcharacters/pvp/KnockoutCrawl.java b/src/main/java/net/tfminecraft/rpcharacters/pvp/KnockoutCrawl.java new file mode 100644 index 0000000..b139a6a --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/KnockoutCrawl.java @@ -0,0 +1,52 @@ +package net.tfminecraft.rpcharacters.pvp; + +import java.util.function.Predicate; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; + +import dev.geco.gsit.api.GSitAPI; +import dev.geco.gsit.api.event.PrePlayerStopCrawlEvent; +import dev.geco.gsit.model.Crawl; +import dev.geco.gsit.model.StopReason; + +/** Loaded only when GSit is enabled. Uses the crawl API shared by GSit 3.2 and 3.3. */ +final class KnockoutCrawl implements Listener { + + private final Predicate knockedOut; + private final Map ownedCrawls = new HashMap<>(); + + KnockoutCrawl(Predicate knockedOut) { + this.knockedOut = knockedOut; + } + + void enforce(Player player) { + // The command checks permissions/ground state and toggles an existing crawl off. + // The API starts the pose directly and also handles the player's client-side crawl. + if (!GSitAPI.isPlayerCrawling(player)) { + Crawl crawl = GSitAPI.startCrawl(player); + if (crawl != null) { + ownedCrawls.put(player.getUniqueId(), crawl); + } + } + } + + void release(Player player) { + Crawl crawl = ownedCrawls.remove(player.getUniqueId()); + if (crawl != null && GSitAPI.getCrawlByPlayer(player) == crawl) { + GSitAPI.stopCrawl(crawl, StopReason.PLUGIN); + } + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onStopCrawl(PrePlayerStopCrawlEvent event) { + if (event.getReason().isCancellable() && knockedOut.test(event.getPlayer())) { + event.setCancelled(true); + } + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java index 2be571c..493beda 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java @@ -11,12 +11,13 @@ import org.bukkit.attribute.Attribute; import org.bukkit.entity.Player; import org.bukkit.entity.Projectile; -import org.bukkit.entity.Pose; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; +import org.bukkit.event.HandlerList; import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.PlayerDeathEvent; import org.bukkit.event.player.PlayerQuitEvent; import org.bukkit.potion.PotionEffect; import org.bukkit.potion.PotionEffectType; @@ -34,8 +35,15 @@ public final class PvpKnockoutManager implements Listener { private final Map knockouts = new ConcurrentHashMap<>(); private BukkitTask tickTask; + private KnockoutCrawl crawl; public void start() { + if (crawl == null && Bukkit.getPluginManager().isPluginEnabled("GSit")) { + crawl = new KnockoutCrawl(this::isKnockedOut); + Bukkit.getPluginManager().registerEvents(crawl, RPCharacters.plugin); + } else if (crawl == null) { + RPCharacters.plugin.getLogger().warning("GSit is not enabled; knockout freeze and blindness will work, but the downed crawl pose is unavailable."); + } if (tickTask != null) { tickTask.cancel(); } @@ -52,7 +60,17 @@ public void shutdown() { tickTask.cancel(); tickTask = null; } + for (UUID id : knockouts.keySet()) { + Player player = Bukkit.getPlayer(id); + if (player != null) { + releaseKnockout(player); + } + } knockouts.clear(); + if (crawl != null) { + HandlerList.unregisterAll(crawl); + crawl = null; + } } /** @@ -115,7 +133,23 @@ static Player attackingPlayer(EntityDamageEvent event) { @EventHandler public void onQuit(PlayerQuitEvent event) { - knockouts.remove(event.getPlayer().getUniqueId()); + releaseKnockout(event.getPlayer()); + } + + @EventHandler + public void onDeath(PlayerDeathEvent event) { + releaseKnockout(event.getEntity()); + } + + private void releaseKnockout(Player player) { + if (knockouts.remove(player.getUniqueId()) != null && crawl != null) { + crawl.release(player); + } + } + + private boolean isKnockedOut(Player player) { + Knockout knockout = knockouts.get(player.getUniqueId()); + return knockout != null && System.currentTimeMillis() < knockout.untilMs && !shouldSkipTick(player); } private void applyKnockout(Player player) { @@ -126,7 +160,6 @@ private void applyKnockout(Player player) { } player.setHealth(Math.max(0.1, Math.min(1.0, maxHealth))); - player.performCommand("crawl"); int durationTicks = PvpLoader.getKnockoutSeconds() * 20; player.addPotionEffect(new PotionEffect( PotionEffectType.BLINDNESS, @@ -139,6 +172,9 @@ private void applyKnockout(Player player) { knockouts.put(player.getUniqueId(), new Knockout( player.getLocation().clone(), System.currentTimeMillis() + PvpLoader.getKnockoutSeconds() * 1000L)); + if (crawl != null) { + crawl.enforce(player); + } } private void tick() { @@ -147,17 +183,17 @@ private void tick() { while (it.hasNext()) { Map.Entry entry = it.next(); Player player = Bukkit.getPlayer(entry.getKey()); - if (player == null || !player.isOnline()) { + if (player == null) { it.remove(); continue; } - if (now >= entry.getValue().untilMs || shouldSkipTick(player)) { - it.remove(); + if (!player.isOnline() || now >= entry.getValue().untilMs || shouldSkipTick(player)) { + releaseKnockout(player); continue; } enforceFreeze(player, entry.getValue().location); - if (!isCrawling(player)) { - player.performCommand("crawl"); + if (crawl != null) { + crawl.enforce(player); } } } @@ -198,14 +234,6 @@ private void enforceFreeze(Player player, Location freezeAt) { player.teleport(dest); } - private boolean isCrawling(Player player) { - Pose pose = player.getPose(); - if ("CRAWLING".equals(pose.name())) { - return true; - } - return pose == Pose.SWIMMING && !player.isInWater(); - } - private static final class Knockout { private final Location location; private final long untilMs; diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 2f133b4..0ca373e 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -5,7 +5,7 @@ version: ${project.version} author: Drefvelin depend: [TLibs, MMOCore, TFMCWeb, MMOItems, MythicLib] -softdepend: [ProtocolLib, PlaceholderAPI, LuckPerms, WorldGuard, SimpleFactions] +softdepend: [ProtocolLib, PlaceholderAPI, LuckPerms, WorldGuard, SimpleFactions, GSit] commands: focus: @@ -56,4 +56,4 @@ permissions: rpchar.grave.admin: default: op professions.admin: - default: op \ No newline at end of file + default: op diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java index add9f5f..e03a101 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java @@ -1,13 +1,31 @@ package net.tfminecraft.rpcharacters.pvp; import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Registry; +import org.bukkit.attribute.Attribute; +import org.bukkit.potion.PotionEffectType; +import io.papermc.paper.registry.RegistryAccess; +import io.papermc.paper.registry.RegistryKey; import org.bukkit.damage.DamageSource; import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.entity.Projectile; import org.bukkit.event.entity.EntityDamageEvent; import org.bukkit.event.entity.EntityDamageByEntityEvent; +import dev.geco.gsit.api.GSitAPI; +import dev.geco.gsit.api.event.PrePlayerStopCrawlEvent; +import dev.geco.gsit.model.Crawl; +import dev.geco.gsit.model.StopReason; +import org.bukkit.event.HandlerList; +import org.bukkit.event.entity.PlayerDeathEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeAll; import net.tfminecraft.rpcharacters.managers.PlayerManager; import net.tfminecraft.rpcharacters.objects.PlayerData; import net.tfminecraft.rpcharacters.objects.RPCharacter; @@ -15,6 +33,210 @@ import static org.mockito.Mockito.*; class PvpKnockoutManagerTest { + @BeforeAll static void initializeServerRegistries() { + // Paper resolves attribute/effect constants through server registries on first use. + var access = mock(RegistryAccess.class, call -> { + Object key = call.getArgument(0); + return mock(Registry.class, lookup -> { + if (!lookup.getMethod().getName().equals("getOrThrow")) { + return RETURNS_DEFAULTS.answer(lookup); + } + if (RegistryKey.ATTRIBUTE.equals(key)) return mock(Attribute.class); + if (RegistryKey.MOB_EFFECT.equals(key)) return mock(PotionEffectType.class); + return null; + }); + }); + try (var registries = mockStatic(RegistryAccess.class)) { + registries.when(RegistryAccess::registryAccess).thenReturn(access); + assertNotNull(Attribute.MAX_HEALTH); + assertNotNull(PotionEffectType.BLINDNESS); + } + } + + @Test void forcesCrawlWithoutCommandAndMaintainsFreeze() throws Exception { + Player victim = knockoutPlayer(); + // The command would reject an airborne player with no crawl permission. + when(victim.isOnGround()).thenReturn(false); + when(victim.hasPermission("gsit.crawl")).thenReturn(false); + var manager = new PvpKnockoutManager(); + attachCrawl(manager); + try (var bukkit = mockStatic(Bukkit.class); var gsit = mockStatic(GSitAPI.class)) { + bukkit.when(() -> Bukkit.getPlayer(victim.getUniqueId())).thenReturn(victim); + gsit.when(() -> GSitAPI.isPlayerCrawling(victim)).thenReturn(false, true); + invoke(manager, "applyKnockout", victim); + when(victim.getLocation()).thenReturn(new Location(null, 11, 64, 10, 90, 20)); + for (int i = 0; i < 600; i++) invoke(manager, "tick"); + gsit.verify(() -> GSitAPI.startCrawl(victim), times(1)); + } + verify(victim, never()).performCommand(anyString()); + verify(victim).setHealth(1.0); + verify(victim).addPotionEffect(argThat(effect -> + effect.getType().equals(PotionEffectType.BLINDNESS) && effect.getDuration() == 600)); + verify(victim, times(600)).teleport(new Location(null, 10, 64, 10, 90, 20)); + } + + @Test void existingCrawlIsKeptAndInterruptedCrawlIsRestored() throws Exception { + Player victim = knockoutPlayer(); + var manager = new PvpKnockoutManager(); + attachCrawl(manager); + try (var bukkit = mockStatic(Bukkit.class); var gsit = mockStatic(GSitAPI.class)) { + bukkit.when(() -> Bukkit.getPlayer(victim.getUniqueId())).thenReturn(victim); + gsit.when(() -> GSitAPI.isPlayerCrawling(victim)).thenReturn(true); + invoke(manager, "applyKnockout", victim); + invoke(manager, "tick"); + gsit.verify(() -> GSitAPI.startCrawl(victim), never()); + // GSit ends crawling on teleports; the knockout restores it afterwards. + gsit.when(() -> GSitAPI.isPlayerCrawling(victim)).thenReturn(false); + invoke(manager, "tick"); + gsit.verify(() -> GSitAPI.startCrawl(victim)); + } + verify(victim, never()).performCommand(anyString()); + } + + @Test void playerCannotGetUpUntilKnockoutEnds() throws Exception { + Player victim = knockoutPlayer(); + var manager = new PvpKnockoutManager(); + var integration = attachCrawl(manager); + Crawl crawl = mock(Crawl.class); + when(crawl.getPlayer()).thenReturn(victim); + try (var gsit = mockStatic(GSitAPI.class)) { + invoke(manager, "applyKnockout", victim); + var getUp = new PrePlayerStopCrawlEvent(crawl, StopReason.GET_UP); + integration.onStopCrawl(getUp); + assertTrue(getUp.isCancelled()); + var teleport = new PrePlayerStopCrawlEvent(crawl, StopReason.TELEPORT); + integration.onStopCrawl(teleport); + assertFalse(teleport.isCancelled()); + expire(manager, victim); + var recovered = new PrePlayerStopCrawlEvent(crawl, StopReason.GET_UP); + integration.onStopCrawl(recovered); + assertFalse(recovered.isCancelled()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"expiry", "quit", "death", "gamemode", "offline", "shutdown"}) + void releasesCrawlWhenKnockoutEnds(String reason) throws Exception { + Player victim = knockoutPlayer(); + var manager = new PvpKnockoutManager(); + attachCrawl(manager); + Crawl crawl = mock(Crawl.class); + try (var bukkit = mockStatic(Bukkit.class); var gsit = mockStatic(GSitAPI.class); + var handlers = mockStatic(HandlerList.class)) { + bukkit.when(() -> Bukkit.getPlayer(victim.getUniqueId())).thenReturn(victim); + gsit.when(() -> GSitAPI.getCrawlByPlayer(victim)).thenReturn(crawl); + gsit.when(() -> GSitAPI.startCrawl(victim)).thenReturn(crawl); + invoke(manager, "applyKnockout", victim); + switch (reason) { + case "expiry" -> expire(manager, victim); + case "quit" -> { + var event = mock(PlayerQuitEvent.class); + when(event.getPlayer()).thenReturn(victim); + manager.onQuit(event); + } + case "death" -> { + var event = mock(PlayerDeathEvent.class); + when(event.getEntity()).thenReturn(victim); + manager.onDeath(event); + } + case "gamemode" -> when(victim.getGameMode()).thenReturn(GameMode.CREATIVE); + case "offline" -> when(victim.isOnline()).thenReturn(false); + case "shutdown" -> manager.shutdown(); + } + invoke(manager, "tick"); + invoke(manager, "tick"); + gsit.verify(() -> GSitAPI.stopCrawl(crawl, StopReason.PLUGIN), times(1)); + gsit.verify(() -> GSitAPI.startCrawl(victim), times(1)); + } + } + + @Test void ordinaryPlayerCrawlIsNotStoppedOnQuit() throws Exception { + Player victim = knockoutPlayer(); + var manager = new PvpKnockoutManager(); + attachCrawl(manager); + var event = mock(PlayerQuitEvent.class); + when(event.getPlayer()).thenReturn(victim); + try (var gsit = mockStatic(GSitAPI.class)) { + manager.onQuit(event); + gsit.verifyNoInteractions(); + } + } + + @Test void recoveryPreservesCrawlThatPredatesKnockout() throws Exception { + Player victim = knockoutPlayer(); + var manager = new PvpKnockoutManager(); + attachCrawl(manager); + Crawl existing = mock(Crawl.class); + try (var bukkit = mockStatic(Bukkit.class); var gsit = mockStatic(GSitAPI.class)) { + bukkit.when(() -> Bukkit.getPlayer(victim.getUniqueId())).thenReturn(victim); + gsit.when(() -> GSitAPI.isPlayerCrawling(victim)).thenReturn(true); + gsit.when(() -> GSitAPI.getCrawlByPlayer(victim)).thenReturn(existing); + invoke(manager, "applyKnockout", victim); + expire(manager, victim); + invoke(manager, "tick"); + gsit.verify(() -> GSitAPI.startCrawl(victim), never()); + gsit.verify(() -> GSitAPI.stopCrawl(existing, StopReason.PLUGIN), never()); + } + } + + @Test void recoveryDoesNotStopReplacementOwnedByAnotherPlugin() throws Exception { + Player victim = knockoutPlayer(); + var manager = new PvpKnockoutManager(); + attachCrawl(manager); + Crawl owned = mock(Crawl.class), replacement = mock(Crawl.class); + try (var bukkit = mockStatic(Bukkit.class); var gsit = mockStatic(GSitAPI.class)) { + bukkit.when(() -> Bukkit.getPlayer(victim.getUniqueId())).thenReturn(victim); + gsit.when(() -> GSitAPI.startCrawl(victim)).thenReturn(owned); + invoke(manager, "applyKnockout", victim); + gsit.when(() -> GSitAPI.getCrawlByPlayer(victim)).thenReturn(replacement); + expire(manager, victim); + invoke(manager, "tick"); + gsit.verify(() -> GSitAPI.stopCrawl(owned, StopReason.PLUGIN), never()); + gsit.verify(() -> GSitAPI.stopCrawl(replacement, StopReason.PLUGIN), never()); + } + } + + private Player knockoutPlayer() { + Player victim = player(); + when(victim.getLocation()).thenReturn(new Location(null, 10, 64, 10)); + when(victim.isOnline()).thenReturn(true); + when(victim.getGameMode()).thenReturn(GameMode.SURVIVAL); + return victim; + } + + private KnockoutCrawl attachCrawl(PvpKnockoutManager manager) throws Exception { + var active = PvpKnockoutManager.class.getDeclaredMethod("isKnockedOut", Player.class); + active.setAccessible(true); + var integration = new KnockoutCrawl(player -> { + try { + return (boolean) active.invoke(manager, player); + } catch (ReflectiveOperationException error) { + throw new AssertionError(error); + } + }); + var field = PvpKnockoutManager.class.getDeclaredField("crawl"); + field.setAccessible(true); + field.set(manager, integration); + return integration; + } + + private void expire(PvpKnockoutManager manager, Player victim) throws Exception { + var field = PvpKnockoutManager.class.getDeclaredField("knockouts"); + field.setAccessible(true); + Object knockout = ((java.util.Map) field.get(manager)).get(victim.getUniqueId()); + var until = knockout.getClass().getDeclaredField("untilMs"); + until.setAccessible(true); + until.setLong(knockout, 0L); + } + + private void invoke(PvpKnockoutManager manager, String name, Player... players) throws Exception { + var method = players.length == 0 + ? PvpKnockoutManager.class.getDeclaredMethod(name) + : PvpKnockoutManager.class.getDeclaredMethod(name, Player.class); + method.setAccessible(true); + method.invoke(manager, (Object[]) players); + } + private Player player() { Player p = mock(Player.class); when(p.getUniqueId()).thenReturn(UUID.randomUUID());