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());