From a4054c5fca4a24c8b70bd586e06a039867266806 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:40:36 +0200 Subject: [PATCH 1/3] Let fighters in body armour keep their helmet free during /pvp start /pvp start no longer takes off a recently put-on helmet. Whether a player may put a helmet on during the fight now depends only on the three main pieces: anyone still in a chestplate, leggings and boots once recent armour is off can take their helmet off and put it back on; anyone else can't put one on. Body armour stays locked as before. This is a roleplay choice so people can take their helmets on and off without worry. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- .../rpcharacters/pvp/ArmourDonning.java | 21 ++++++++--- .../rpcharacters/pvp/PvpCommand.java | 23 ++++++++++-- .../rpcharacters/pvp/PvpSituation.java | 26 ++++++++++--- .../rpcharacters/pvp/PvpSituations.java | 12 ++++-- src/main/resources/pvp.yml | 9 +++-- .../rpcharacters/pvp/ArmourDonningTest.java | 37 +++++++++++++++++-- .../rpcharacters/pvp/PvpCommandTest.java | 8 ++++ .../rpcharacters/pvp/PvpSituationTest.java | 10 +++++ .../pvp/PvpStrikeServiceTest.java | 3 +- 10 files changed, 124 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 4f6f2d91..4072442c 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Beyond a name and appearance, characters have traits, professions, injuries, and - **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. While downed, a player can only be finished by another player in lethal mode, a mob, `/kill`, the void or the world border: falls, drowning, fire and other damage with no attacker leave them at half a heart, and the fall distance built up by the freeze is cleared when they get up. Knockouts never apply to players in a started SimpleFactions battle, so the battle records the death and routes the respawn. - **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. The same player must wait 24 hours by default before striking a character again (`strikes.same-target-cooldown-hours` in `pvp.yml`; 0 disables the wait), so at that default they cannot land all three strikes in a day. 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. -- **Armour takes time** — putting armour on takes a while (by default 1 second for a helmet, 20 for a chestplate, 12 for leggings and 8 for boots). Pieces go on one at a time and the player is slowed until they're done. Taking or dealing damage, sprinting, dying or logging out stops it. `/pvp start` takes off any armour that someone in range put on in the last 3 minutes, and nobody in the fight can put armour on until it ends unless they die. The times are set under `armour` in `pvp.yml`. Masks, heads and elytras go on at once. +- **Armour takes time** — putting armour on takes a while (by default 1 second for a helmet, 20 for a chestplate, 12 for leggings and 8 for boots). Pieces go on one at a time and the player is slowed until they're done. Taking or dealing damage, sprinting, dying or logging out stops it. `/pvp start` takes off any chestplate, leggings or boots that someone in range put on in the last 3 minutes, and nobody in the fight can put armour on until it ends unless they die. Helmets never come off. Anyone still in a chestplate, leggings and boots can take their helmet off and put it back on during the fight; anyone else can't put one on. The times are set under `armour` in `pvp.yml`. Masks, heads and elytras go on at once. ## Beyond the game diff --git a/src/main/java/net/tfminecraft/rpcharacters/pvp/ArmourDonning.java b/src/main/java/net/tfminecraft/rpcharacters/pvp/ArmourDonning.java index 2ad08a27..d169d9cd 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/pvp/ArmourDonning.java +++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/ArmourDonning.java @@ -52,7 +52,8 @@ * looks likely. Pieces are fastened one after another while the player is * slowed. Taking or dealing damage, sprinting, dying or logging out stops it. * Masks, heads and elytras go on at once, and creative or spectator players - * are never delayed. {@code /pvp start} takes off anything put on recently. + * are never delayed. {@code /pvp start} takes off any body armour put on + * recently. Helmets are left alone, so they can come off and on for roleplay. */ public final class ArmourDonning implements Listener { @@ -111,6 +112,13 @@ static boolean isArmourPiece(ItemStack item) { return armour && !MaskService.isMaskItem(item); } + /** Wearing a real chestplate, leggings and boots. The helmet doesn't count. */ + public static boolean wearsBodyArmour(Player player) { + PlayerInventory inventory = player.getInventory(); + return isArmourPiece(inventory.getChestplate()) && isArmourPiece(inventory.getLeggings()) + && isArmourPiece(inventory.getBoots()); + } + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onArmorEquip(ArmorEquipEvent event) { Player player = event.getPlayer(); @@ -178,7 +186,7 @@ static void undoSwap(Player player, ArmorType type, int handSlot, ItemStack want || !previous.isSimilar(inventory.getItem(handSlot))) { return; } - boolean locked = PvpSituations.locksArmour(player.getUniqueId()); + boolean locked = PvpSituations.locksArmour(player.getUniqueId(), type); if (!locked && !delays(player, type, wanted)) { return; } @@ -227,8 +235,9 @@ public void onQuit(PlayerQuitEvent event) { } /** - * For {@code /pvp start}: stop fastening, and take off any piece put on within - * {@code armour.recent-seconds}. Returns whether anything came off. + * For {@code /pvp start}: stop fastening, and take off any body armour put on + * within {@code armour.recent-seconds}. The helmet stays. Returns whether + * anything came off. */ public static boolean takeOffRecent(Player player, long nowMs) { stop(player, true); @@ -241,7 +250,7 @@ public static boolean takeOffRecent(Player player, long nowMs) { List pieces = new ArrayList<>(); for (ArmorType type : ArmorType.values()) { Long at = times.get(type); - if (at == null || nowMs - at > windowMs) { + if (type == ArmorType.HELMET || at == null || nowMs - at > windowMs) { continue; } times.remove(type); @@ -318,7 +327,7 @@ static void tick(long nowMs) { private static void finish(Player player, Donning state, long nowMs) { Piece piece = state.current; - if (PvpSituations.locksArmour(player.getUniqueId())) { + if (PvpSituations.locksArmour(player.getUniqueId(), piece.type())) { RPTexts.send(player, PvpLoader.getArmourLocked()); stop(player, false); return; diff --git a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpCommand.java b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpCommand.java index 8e92c319..7012eabe 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpCommand.java +++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpCommand.java @@ -113,10 +113,10 @@ private void startWarning(Player player) { if (previous != null) { finish(previous); } - PvpSituation situation = new PvpSituation(player.getUniqueId(), targets); + takeOffRecentArmour(targets); + PvpSituation situation = new PvpSituation(player.getUniqueId(), targets, armoured(targets)); PvpSituations.track(situation); tasksBySituation.put(situation, new ArrayList<>()); - takeOffRecentArmour(targets); String warning = PvpLoader.getStartWarning() .replace("{seconds}", String.valueOf(PvpLoader.getStartWarnSeconds())); @@ -229,12 +229,15 @@ private void schedule(PvpSituation situation, BukkitTask task) { tasks.add(task); } - /** Everyone caught in an open fight keeps the armour they had when it was called. */ + /** + * Everyone caught in an open fight keeps the armour they had when it was called. + * Those in body armour may still put a helmet on. + */ @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) public void onArmorEquip(ArmorEquipEvent event) { Player player = event.getPlayer(); if (player == null || event.getNewArmorPiece() == null - || !PvpSituations.locksArmour(player.getUniqueId())) { + || !PvpSituations.locksArmour(player.getUniqueId(), event.getType())) { return; } event.setCancelled(true); @@ -257,6 +260,18 @@ private void takeOffRecentArmour(List targets) { } } + /** Who is still in a chestplate, leggings and boots once recent armour is off. */ + private static List armoured(List targets) { + List armoured = new ArrayList<>(); + for (UUID id : targets) { + Player online = Bukkit.getPlayer(id); + if (online != null && ArmourDonning.wearsBodyArmour(online)) { + armoured.add(id); + } + } + return armoured; + } + private void broadcast(List targets, String raw) { for (UUID id : targets) { Player online = Bukkit.getPlayer(id); diff --git a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpSituation.java b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpSituation.java index 7ccf62a8..39a5185d 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpSituation.java +++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpSituation.java @@ -16,21 +16,32 @@ public final class PvpSituation { private final UUID issuerId; private final Set participants; + private final Set armoured; private final Set died = ConcurrentHashMap.newKeySet(); private boolean open = true; private boolean started; public PvpSituation(UUID issuerId, Collection participants) { + this(issuerId, participants, null); + } + + /** {@code armoured} are the participants who wore body armour when it was called. */ + public PvpSituation(UUID issuerId, Collection participants, Collection armoured) { this.issuerId = issuerId; + this.participants = idsOf(participants); + this.armoured = idsOf(armoured); + } + + private static Set idsOf(Collection source) { Set ids = ConcurrentHashMap.newKeySet(); - if (participants != null) { - for (UUID participant : participants) { - if (participant != null) { - ids.add(participant); + if (source != null) { + for (UUID id : source) { + if (id != null) { + ids.add(id); } } } - this.participants = Set.copyOf(ids); + return Set.copyOf(ids); } public UUID issuerId() { @@ -53,6 +64,11 @@ public boolean includes(UUID playerId) { return playerId != null && participants.contains(playerId); } + /** Came into the fight in a chestplate, leggings and boots, so may put a helmet on during it. */ + public boolean wasArmoured(UUID playerId) { + return playerId != null && armoured.contains(playerId); + } + public boolean hasDied(UUID playerId) { return playerId != null && died.contains(playerId); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpSituations.java b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpSituations.java index 60cfd5e6..ac2a6e1a 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpSituations.java +++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpSituations.java @@ -3,6 +3,8 @@ import java.util.UUID; import java.util.concurrent.CopyOnWriteArrayList; +import net.tfminecraft.tlibs.armour.ArmorType; + /** Open {@code /pvp start} situations. The command layer owns timers and titles. */ public final class PvpSituations { @@ -42,13 +44,17 @@ public static void markDeath(UUID playerId) { } } - /** From {@code /pvp start} until the fight closes, nobody in it who is still alive may put armour on. */ - public static boolean locksArmour(UUID playerId) { + /** + * From {@code /pvp start} until the fight closes, nobody in it who is still alive + * may put armour on. Helmets are free for those who came in wearing body armour. + */ + public static boolean locksArmour(UUID playerId, ArmorType type) { if (playerId == null) { return false; } for (PvpSituation situation : open) { - if (situation.isOpen() && situation.includes(playerId) && !situation.hasDied(playerId)) { + if (situation.isOpen() && situation.includes(playerId) && !situation.hasDied(playerId) + && (type != ArmorType.HELMET || !situation.wasArmoured(playerId))) { return true; } } diff --git a/src/main/resources/pvp.yml b/src/main/resources/pvp.yml index 84982302..2cd5cedd 100644 --- a/src/main/resources/pvp.yml +++ b/src/main/resources/pvp.yml @@ -48,9 +48,12 @@ armour: boots: 8 # Slowness while fastening (0 = Slowness I). -1 for none. slowness-amplifier: 1 - # /pvp start takes off armour that anyone in range put on within this many - # seconds, and from then until the fight ends nobody in it can put armour on. - # Someone who dies in the fight can gear up again. 0 takes nothing off. + # /pvp start takes off body armour that anyone in range put on within this + # many seconds, and from then until the fight ends nobody in it can put armour + # on. Helmets are never taken off. Anyone still in a chestplate, leggings and + # boots can take their helmet off and put it back on during the fight; anyone + # else can't put one on. Someone who dies in the fight can gear up again. + # 0 takes nothing off. recent-seconds: 180 messages: diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/ArmourDonningTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/ArmourDonningTest.java index dcbeb74b..1acd963a 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/pvp/ArmourDonningTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/ArmourDonningTest.java @@ -330,7 +330,7 @@ ArmorEquipEvent equip(ArmorType type, ItemStack piece, EquipMethod method) { } @Test void leggingsGoOnAndShutdownStopsFastening() { - assertFalse(PvpSituations.locksArmour(null)); + assertFalse(PvpSituations.locksArmour(null, ArmorType.HELMET)); ItemStack leggings = new ItemStack(Material.IRON_LEGGINGS); player.getInventory().setItem(0, leggings.clone()); equip(ArmorType.LEGGINGS, leggings, EquipMethod.SHIFT_CLICK); @@ -360,8 +360,8 @@ ArmorEquipEvent equip(ArmorType type, ItemStack piece, EquipMethod method) { listener.onArmorChanged(new ArmorEquipEvent(player, EquipMethod.SHIFT_CLICK, ArmorType.HELMET, null, helmet)); listener.onArmorChanged(new ArmorEquipEvent(player, EquipMethod.SHIFT_CLICK, ArmorType.HELMET, helmet, null)); long put = System.currentTimeMillis(); - player.getInventory().setHelmet(new ItemStack(Material.PLAYER_HEAD)); - listener.onArmorChanged(new ArmorEquipEvent(player, EquipMethod.HOTBAR, ArmorType.HELMET, null, new ItemStack(Material.PLAYER_HEAD))); + player.getInventory().setHelmet(helmet.clone()); + listener.onArmorChanged(new ArmorEquipEvent(player, EquipMethod.SHIFT_CLICK, ArmorType.HELMET, null, helmet)); player.getInventory().setChestplate(chestplate.clone()); listener.onArmorChanged(new ArmorEquipEvent(player, EquipMethod.SHIFT_CLICK, ArmorType.CHESTPLATE, null, chestplate)); player.getInventory().setBoots(boots.clone()); @@ -380,12 +380,41 @@ ArmorEquipEvent equip(ArmorType type, ItemStack piece, EquipMethod method) { assertTrue(lines.stream().anyMatch(line -> line.contains("stopped fastening your boots"))); assertNull(player.getInventory().getChestplate()); assertNull(player.getInventory().getBoots()); - assertEquals(Material.PLAYER_HEAD, player.getInventory().getHelmet().getType(), "Heads aren't armour"); + assertEquals(Material.IRON_HELMET, player.getInventory().getHelmet().getType(), "Helmets stay on for roleplay"); assertTrue(player.getInventory().contains(Material.IRON_CHESTPLATE)); assertEquals(1, player.getWorld().getEntities().stream().filter(org.bukkit.entity.Item.class::isInstance).count(), "What doesn't fit is dropped at their feet"); assertFalse(ArmourDonning.takeOffRecent(player, put + 60_000L), "Each piece comes off once"); } + @Test void onlyThoseInBodyArmourMayPutAHelmetOnInAFight() { + ItemStack helmet = new ItemStack(Material.IRON_HELMET), leggings = new ItemStack(Material.IRON_LEGGINGS); + assertFalse(ArmourDonning.wearsBodyArmour(player)); + player.getInventory().setChestplate(chestplate.clone()); player.getInventory().setLeggings(leggings.clone()); + player.getInventory().setBoots(new ItemStack(Material.ELYTRA)); + assertFalse(ArmourDonning.wearsBodyArmour(player), "Boots are needed too"); + player.getInventory().setBoots(boots.clone()); + assertTrue(ArmourDonning.wearsBodyArmour(player)); + + PvpSituations.track(new PvpSituation(player.getUniqueId(), List.of(player.getUniqueId()), List.of(player.getUniqueId()))); + assertFalse(PvpSituations.locksArmour(player.getUniqueId(), ArmorType.HELMET)); + assertTrue(PvpSituations.locksArmour(player.getUniqueId(), ArmorType.CHESTPLATE)); + player.getInventory().setItem(0, helmet.clone()); + equip(ArmorType.HELMET, helmet, EquipMethod.SHIFT_CLICK); + tickAt(System.currentTimeMillis() + 2_000L); + assertEquals(Material.IRON_HELMET, player.getInventory().getHelmet().getType(), "Armoured fighters can put their helmet back on"); + + ItemStack hood = new ItemStack(Material.LEATHER_HELMET); + player.getInventory().setItemInOffHand(hood.clone()); + player.getInventory().setHelmet(hood.clone()); player.getInventory().setItemInOffHand(helmet.clone()); + ArmourDonning.undoSwap(player, ArmorType.HELMET, 40, hood, helmet); + tickAt(System.currentTimeMillis() + 4_000L); + assertEquals(Material.LEATHER_HELMET, player.getInventory().getHelmet().getType(), "A helmet swap is fastened, not refused"); + assertFalse(said("can't put armour on")); + + lockInFight(); + assertTrue(PvpSituations.locksArmour(player.getUniqueId(), ArmorType.HELMET), "Another fight they came into unarmoured still locks it"); + } + static EntityDamageByEntityEvent hit(org.bukkit.entity.Entity damager, org.bukkit.entity.Entity victim) { EntityDamageByEntityEvent event = mock(EntityDamageByEntityEvent.class); when(event.getDamager()).thenReturn(damager); when(event.getEntity()).thenReturn(victim); return event; diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpCommandTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpCommandTest.java index 13e85c79..3c580dba 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpCommandTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpCommandTest.java @@ -68,6 +68,14 @@ class PvpCommandTest extends PvpRuntimeFixture { command("end");var released=armor(victim,true);handler.onArmorEquip(released);assertFalse(released.isCancelled()); } + @Test void helmetsStayFreeForThoseWhoCameInBodyArmour() { + var inventory=victim.getInventory();when(inventory.getChestplate()).thenReturn(new ItemStack(Material.IRON_CHESTPLATE));when(inventory.getLeggings()).thenReturn(new ItemStack(Material.IRON_LEGGINGS));when(inventory.getBoots()).thenReturn(new ItemStack(Material.IRON_BOOTS)); + try(var masks=mockStatic(net.tfminecraft.rpcharacters.identity.MaskService.class)){start();}countdown(); + var helmet=armor(victim,true);handler.onArmorEquip(helmet);assertFalse(helmet.isCancelled(),"Armoured fighters keep their helmet free"); + var chestplate=new ArmorEquipEvent(victim,ArmorEquipEvent.EquipMethod.SHIFT_CLICK,ArmorType.CHESTPLATE,null,new ItemStack(Material.IRON_CHESTPLATE));handler.onArmorEquip(chestplate);assertTrue(chestplate.isCancelled()); + var unarmoured=armor(killer,true);handler.onArmorEquip(unarmoured);assertTrue(unarmoured.isCancelled(),"Without body armour a helmet stays off");var removing=armor(killer,false);handler.onArmorEquip(removing);assertFalse(removing.isCancelled()); + } + @Test void startTakesOffRecentArmourAndTellsTheOthersAtOnce() { try(var donning=mockStatic(ArmourDonning.class)){donning.when(()->ArmourDonning.takeOffRecent(eq(victim),anyLong())).thenReturn(true);start(); donning.verify(()->ArmourDonning.takeOffRecent(eq(killer),anyLong()));donning.verify(()->ArmourDonning.takeOffRecent(eq(other),anyLong()),never());assertTrue(text(killer).contains("Victim hadn't finished fastening their armour"));assertFalse(text(victim).contains("hadn't finished fastening their"));assertTrue(allTasks.stream().allMatch(task->task.delay()>0),"Armour comes off when the command runs, before the countdown");} diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpSituationTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpSituationTest.java index f446a9b5..56948908 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpSituationTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpSituationTest.java @@ -1,5 +1,6 @@ package net.tfminecraft.rpcharacters.pvp; +import java.util.Arrays; import java.util.List; import java.util.Set; import java.util.UUID; @@ -78,6 +79,15 @@ void anotherOpenFightKeepsThePlayerActive() { assertFalse(PvpSituations.remainsActiveElsewhere(issuer, first)); } + @Test + void onlyTheArmouredGetAFreeHelmet() { + PvpSituation situation = new PvpSituation(issuer, List.of(issuer, survivor), Arrays.asList(null, survivor)); + assertTrue(situation.wasArmoured(survivor)); + assertFalse(situation.wasArmoured(issuer)); + assertFalse(situation.wasArmoured(null)); + assertFalse(situation().wasArmoured(survivor)); + } + private PvpSituation situation() { return new PvpSituation(issuer, List.of(issuer, survivor, dead)); } diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpStrikeServiceTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpStrikeServiceTest.java index 8368f971..9b519114 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpStrikeServiceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpStrikeServiceTest.java @@ -25,6 +25,7 @@ import net.tfminecraft.rpcharacters.utils.TraitChangeService; import org.bukkit.*; import org.bukkit.entity.Player; +import org.bukkit.inventory.PlayerInventory; import org.bukkit.scheduler.*; import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.*; @@ -191,7 +192,7 @@ record PendingTask(Runnable run,long delay,BukkitTask task){} config=mockStatic(PvpLoader.class,CALLS_REAL_METHODS);mocks.add(config);var identities=boundary(DisplayIdentityService.class);identities.when(()->DisplayIdentityService.resolveDisplay(any(Player.class))).thenAnswer(call->((Player)call.getArgument(0)).getName());evil=boundary(EvilRpService.class);evil.when(()->EvilRpService.applyStrike(any(Player.class),any(RPCharacter.class),any(),anyBoolean())).thenAnswer(call->{RPCharacter c=call.getArgument(1);c.setEvilRpStrikes(c.getEvilRpStrikes()+1);if(c.getEvilRpStrikes()>=3)c.setStatus(Status.DEAD);return StrikeOutcome.forStrike(c.getEvilRpStrikes());});evil.when(()->EvilRpService.killByStrike(any(),any(),any(),anyBoolean())).thenAnswer(call->{((RPCharacter)call.getArgument(1)).setStatus(Status.DEAD);return true;});injuries=boundary(PermadeathService.class);battles=boundary(PermadeathBattleExemption.class);tutorials=boundary(TutorialService.class);injury=mock(Trait.class);var traits=boundary(TraitChangeService.class);traits.when(()->TraitChangeService.resolveGainedMessage(injury)).thenReturn("Injured"); } MockedStatic boundary(Class type){var b=mockStatic(type);mocks.add(b);return b;} - Player player(String name,double x){var p=mock(Player.class);var id=UUID.randomUUID();when(p.getUniqueId()).thenReturn(id);when(p.getName()).thenReturn(name);when(p.isOnline()).thenReturn(true);when(p.getWorld()).thenReturn(world);when(p.getLocation()).thenAnswer(call->new Location(world,x,64,0));online.put(id,p);messages.put(p,new ArrayList<>());components.put(p,new ArrayList<>());doAnswer(call->{messages.get(p).add(call.getArgument(0));return null;}).when(p).sendMessage(anyString());doAnswer(call->{components.get(p).add(call.getArgument(0));return null;}).when(p).sendMessage(any(Component.class));return p;} + Player player(String name,double x){var p=mock(Player.class);var id=UUID.randomUUID();when(p.getUniqueId()).thenReturn(id);when(p.getName()).thenReturn(name);when(p.isOnline()).thenReturn(true);when(p.getWorld()).thenReturn(world);when(p.getInventory()).thenReturn(mock(PlayerInventory.class));when(p.getLocation()).thenAnswer(call->new Location(world,x,64,0));online.put(id,p);messages.put(p,new ArrayList<>());components.put(p,new ArrayList<>());doAnswer(call->{messages.get(p).add(call.getArgument(0));return null;}).when(p).sendMessage(anyString());doAnswer(call->{components.get(p).add(call.getArgument(0));return null;}).when(p).sendMessage(any(Component.class));return p;} String text(Player p){return String.join("\n",messages.get(p));} void runNext(){assertFalse(delayed.isEmpty());delayed.removeFirst().run.run();} @AfterEach void cleanupPvp() throws Exception {PvpStrikeService.shutdown();for(int i=mocks.size()-1;i>=0;i--)mocks.get(i).close();MockBukkit.unmock();state.close();} From 0c32b28836586910b321b96f58c262c1db0683d9 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:45:35 +0200 Subject: [PATCH 2/3] Cover the non-armour piece skip in takeOffRecent again Co-Authored-By: Claude Opus 5.5 (1M context) --- .../net/tfminecraft/rpcharacters/pvp/ArmourDonningTest.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/ArmourDonningTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/ArmourDonningTest.java index 1acd963a..25c5cb14 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/pvp/ArmourDonningTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/ArmourDonningTest.java @@ -384,6 +384,10 @@ ArmorEquipEvent equip(ArmorType type, ItemStack piece, EquipMethod method) { assertTrue(player.getInventory().contains(Material.IRON_CHESTPLATE)); assertEquals(1, player.getWorld().getEntities().stream().filter(org.bukkit.entity.Item.class::isInstance).count(), "What doesn't fit is dropped at their feet"); assertFalse(ArmourDonning.takeOffRecent(player, put + 60_000L), "Each piece comes off once"); + ItemStack elytra = new ItemStack(Material.ELYTRA); player.getInventory().setChestplate(elytra.clone()); + listener.onArmorChanged(new ArmorEquipEvent(player, EquipMethod.HOTBAR, ArmorType.CHESTPLATE, null, elytra)); + assertFalse(ArmourDonning.takeOffRecent(player, put + 60_000L)); + assertEquals(Material.ELYTRA, player.getInventory().getChestplate().getType(), "Elytras aren't armour"); } @Test void onlyThoseInBodyArmourMayPutAHelmetOnInAFight() { From a893d400aec3a7240d2bcfed83837d1b426cd1c5 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:56:29 +0200 Subject: [PATCH 3/3] Say the helmet rule is set when the fight is called Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 2 +- src/main/resources/pvp.yml | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4072442c..03fcd7c9 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Beyond a name and appearance, characters have traits, professions, injuries, and - **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. While downed, a player can only be finished by another player in lethal mode, a mob, `/kill`, the void or the world border: falls, drowning, fire and other damage with no attacker leave them at half a heart, and the fall distance built up by the freeze is cleared when they get up. Knockouts never apply to players in a started SimpleFactions battle, so the battle records the death and routes the respawn. - **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. The same player must wait 24 hours by default before striking a character again (`strikes.same-target-cooldown-hours` in `pvp.yml`; 0 disables the wait), so at that default they cannot land all three strikes in a day. 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. -- **Armour takes time** — putting armour on takes a while (by default 1 second for a helmet, 20 for a chestplate, 12 for leggings and 8 for boots). Pieces go on one at a time and the player is slowed until they're done. Taking or dealing damage, sprinting, dying or logging out stops it. `/pvp start` takes off any chestplate, leggings or boots that someone in range put on in the last 3 minutes, and nobody in the fight can put armour on until it ends unless they die. Helmets never come off. Anyone still in a chestplate, leggings and boots can take their helmet off and put it back on during the fight; anyone else can't put one on. The times are set under `armour` in `pvp.yml`. Masks, heads and elytras go on at once. +- **Armour takes time** — putting armour on takes a while (by default 1 second for a helmet, 20 for a chestplate, 12 for leggings and 8 for boots). Pieces go on one at a time and the player is slowed until they're done. Taking or dealing damage, sprinting, dying or logging out stops it. `/pvp start` takes off any chestplate, leggings or boots that someone in range put on in the last 3 minutes, and nobody in the fight can put armour on until it ends unless they die. `/pvp start` never takes a helmet off. Once recent armour is off, it notes who is still in a chestplate, leggings and boots: they can take their helmet off and put it back on for the rest of the fight, and anyone else can't put one on. This is decided when the fight is called and doesn't change during it. The times are set under `armour` in `pvp.yml`. Masks, heads and elytras go on at once. ## Beyond the game diff --git a/src/main/resources/pvp.yml b/src/main/resources/pvp.yml index 2cd5cedd..69f925e6 100644 --- a/src/main/resources/pvp.yml +++ b/src/main/resources/pvp.yml @@ -50,9 +50,10 @@ armour: slowness-amplifier: 1 # /pvp start takes off body armour that anyone in range put on within this # many seconds, and from then until the fight ends nobody in it can put armour - # on. Helmets are never taken off. Anyone still in a chestplate, leggings and - # boots can take their helmet off and put it back on during the fight; anyone - # else can't put one on. Someone who dies in the fight can gear up again. + # on. Helmets are never taken off. Whoever is still in a chestplate, leggings + # and boots when the fight is called can take their helmet off and put it back + # on until it ends; anyone else can't put one on. Someone who dies in the fight + # can gear up again. # 0 takes nothing off. recent-seconds: 180