From 2e0f5820a7b4cca0d6888d69e276c54da8e912c4 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Sat, 26 Sep 2026 23:24:15 +0000
Subject: [PATCH 1/2] fix: force crawl during nonlethal knockouts without
command spam
---
README.md | 1 +
pom.xml | 10 +
.../rpcharacters/pvp/KnockoutCrawl.java | 54 +++++
.../rpcharacters/pvp/PvpKnockoutManager.java | 60 ++++--
src/main/resources/plugin.yml | 4 +-
.../pvp/PvpKnockoutManagerTest.java | 203 ++++++++++++++++++
6 files changed, 314 insertions(+), 18 deletions(-)
create mode 100644 src/main/java/net/tfminecraft/rpcharacters/pvp/KnockoutCrawl.java
diff --git a/README.md b/README.md
index c8cae39..3d62670 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 voluntary crawl restrictions, and releases 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..abf04bc
--- /dev/null
+++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/KnockoutCrawl.java
@@ -0,0 +1,54 @@
+package net.tfminecraft.rpcharacters.pvp;
+
+import java.util.function.Predicate;
+
+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.PrePlayerCrawlEvent;
+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;
+
+ 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)) {
+ GSitAPI.startCrawl(player);
+ }
+ }
+
+ void release(Player player) {
+ Crawl crawl = GSitAPI.getCrawlByPlayer(player);
+ if (crawl != null) {
+ GSitAPI.stopCrawl(crawl, StopReason.PLUGIN);
+ }
+ }
+
+ @EventHandler(priority = EventPriority.HIGHEST)
+ public void onStartCrawl(PrePlayerCrawlEvent event) {
+ // Being knocked down is compulsory, even where voluntary crawling is blocked.
+ if (knockedOut.test(event.getPlayer())) {
+ event.setCancelled(false);
+ }
+ }
+
+ @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..e20a79b 100644
--- a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java
+++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java
@@ -1,13 +1,32 @@
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.PrePlayerCrawlEvent;
+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 +34,190 @@
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);
+ 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 onlyKnockoutsOverrideVoluntaryCrawlRestrictions() throws Exception {
+ Player victim = knockoutPlayer();
+ var manager = new PvpKnockoutManager();
+ var integration = attachCrawl(manager);
+ var blocked = new PrePlayerCrawlEvent(victim);
+ blocked.setCancelled(true);
+ integration.onStartCrawl(blocked);
+ assertTrue(blocked.isCancelled());
+ try (var gsit = mockStatic(GSitAPI.class)) {
+ invoke(manager, "applyKnockout", victim);
+ integration.onStartCrawl(blocked);
+ assertFalse(blocked.isCancelled());
+ }
+ }
+
+ 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());
From b62b8dad35ae567c2fedf02d8ed6447303478bb0 Mon Sep 17 00:00:00 2001
From: Ryan Barlow <7389646+ryanbarlow97@users.noreply.github.com>
Date: Sat, 26 Sep 2026 23:34:05 +0000
Subject: [PATCH 2/2] fix: preserve existing crawls and respect integration
vetoes
---
README.md | 2 +-
.../rpcharacters/pvp/KnockoutCrawl.java | 22 +++++------
.../pvp/PvpKnockoutManagerTest.java | 39 ++++++++++++++-----
3 files changed, 40 insertions(+), 23 deletions(-)
diff --git a/README.md b/README.md
index 3d62670..e0dc4df 100644
--- a/README.md
+++ b/README.md
@@ -16,7 +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 voluntary crawl restrictions, and releases on recovery. GSit is optional for the rest of RPCharacters; without it, knockouts retain freeze and blindness only.
+- **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/src/main/java/net/tfminecraft/rpcharacters/pvp/KnockoutCrawl.java b/src/main/java/net/tfminecraft/rpcharacters/pvp/KnockoutCrawl.java
index abf04bc..b139a6a 100644
--- a/src/main/java/net/tfminecraft/rpcharacters/pvp/KnockoutCrawl.java
+++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/KnockoutCrawl.java
@@ -1,6 +1,9 @@
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;
@@ -8,7 +11,6 @@
import org.bukkit.event.Listener;
import dev.geco.gsit.api.GSitAPI;
-import dev.geco.gsit.api.event.PrePlayerCrawlEvent;
import dev.geco.gsit.api.event.PrePlayerStopCrawlEvent;
import dev.geco.gsit.model.Crawl;
import dev.geco.gsit.model.StopReason;
@@ -17,6 +19,7 @@
final class KnockoutCrawl implements Listener {
private final Predicate knockedOut;
+ private final Map ownedCrawls = new HashMap<>();
KnockoutCrawl(Predicate knockedOut) {
this.knockedOut = knockedOut;
@@ -26,25 +29,20 @@ 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)) {
- GSitAPI.startCrawl(player);
+ Crawl crawl = GSitAPI.startCrawl(player);
+ if (crawl != null) {
+ ownedCrawls.put(player.getUniqueId(), crawl);
+ }
}
}
void release(Player player) {
- Crawl crawl = GSitAPI.getCrawlByPlayer(player);
- if (crawl != null) {
+ Crawl crawl = ownedCrawls.remove(player.getUniqueId());
+ if (crawl != null && GSitAPI.getCrawlByPlayer(player) == crawl) {
GSitAPI.stopCrawl(crawl, StopReason.PLUGIN);
}
}
- @EventHandler(priority = EventPriority.HIGHEST)
- public void onStartCrawl(PrePlayerCrawlEvent event) {
- // Being knocked down is compulsory, even where voluntary crawling is blocked.
- if (knockedOut.test(event.getPlayer())) {
- event.setCancelled(false);
- }
- }
-
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onStopCrawl(PrePlayerStopCrawlEvent event) {
if (event.getReason().isCancellable() && knockedOut.test(event.getPlayer())) {
diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java
index e20a79b..e03a101 100644
--- a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java
+++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java
@@ -16,7 +16,6 @@
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import dev.geco.gsit.api.GSitAPI;
-import dev.geco.gsit.api.event.PrePlayerCrawlEvent;
import dev.geco.gsit.api.event.PrePlayerStopCrawlEvent;
import dev.geco.gsit.model.Crawl;
import dev.geco.gsit.model.StopReason;
@@ -126,6 +125,7 @@ void releasesCrawlWhenKnockoutEnds(String reason) throws Exception {
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);
@@ -162,18 +162,37 @@ void releasesCrawlWhenKnockoutEnds(String reason) throws Exception {
}
}
- @Test void onlyKnockoutsOverrideVoluntaryCrawlRestrictions() throws Exception {
+ @Test void recoveryPreservesCrawlThatPredatesKnockout() throws Exception {
Player victim = knockoutPlayer();
var manager = new PvpKnockoutManager();
- var integration = attachCrawl(manager);
- var blocked = new PrePlayerCrawlEvent(victim);
- blocked.setCancelled(true);
- integration.onStartCrawl(blocked);
- assertTrue(blocked.isCancelled());
- try (var gsit = mockStatic(GSitAPI.class)) {
+ 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);
- integration.onStartCrawl(blocked);
- assertFalse(blocked.isCancelled());
+ 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());
}
}