Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. `/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.
- **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. Once recent armour is off, it notes who is still in a chestplate, leggings and boots: they keep their helmet and can take it off and put it back on for the rest of the fight. Anyone else also loses any helmet, mask or head they put on in the last 3 minutes, and can't put one on until the fight ends. 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

Expand Down
45 changes: 31 additions & 14 deletions src/main/java/net/tfminecraft/rpcharacters/pvp/ArmourDonning.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@
* 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 any body armour put on
* recently. Helmets are left alone, so they can come off and on for roleplay.
* recently. Those left in full body armour keep their helmet free for roleplay;
* anyone else also loses whatever they recently put on their head.
*/
public final class ArmourDonning implements Listener {

Expand Down Expand Up @@ -236,8 +237,10 @@ public void onQuit(PlayerQuitEvent event) {

/**
* 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.
* within {@code armour.recent-seconds}. Someone still in full body armour keeps
* their helmet. Anyone else also loses a helmet, mask or head put on in that
* time, since they can't put one on during the fight. Returns whether anything
* came off.
*/
public static boolean takeOffRecent(Player player, long nowMs) {
stop(player, true);
Expand All @@ -246,21 +249,17 @@ public static boolean takeOffRecent(Player player, long nowMs) {
if (times == null || windowMs <= 0L) {
return false;
}
PlayerInventory inventory = player.getInventory();
List<String> pieces = new ArrayList<>();
for (ArmorType type : ArmorType.values()) {
Long at = times.get(type);
if (type == ArmorType.HELMET || at == null || nowMs - at > windowMs) {
continue;
if (type != ArmorType.HELMET && takeOffIfRecent(player, times, type, nowMs, windowMs)) {
pieces.add(pieceName(type));
}
times.remove(type);
ItemStack worn = armour(inventory, type);
if (!isArmourPiece(worn)) {
continue;
}
if (!wearsBodyArmour(player)) {
String head = isArmourPiece(player.getInventory().getHelmet()) ? pieceName(ArmorType.HELMET) : "headwear";
if (takeOffIfRecent(player, times, ArmorType.HELMET, nowMs, windowMs)) {
pieces.add(head);
}
setArmour(inventory, type, null);
giveBack(player, worn);
pieces.add(pieceName(type));
}
if (pieces.isEmpty()) {
return false;
Expand All @@ -269,6 +268,24 @@ public static boolean takeOffRecent(Player player, long nowMs) {
return true;
}

/** Body slots only count real armour; anything worn on the head counts. */
private static boolean takeOffIfRecent(Player player, EnumMap<ArmorType, Long> times, ArmorType type,
long nowMs, long windowMs) {
Long at = times.get(type);
if (at == null || nowMs - at > windowMs) {
return false;
}
times.remove(type);
PlayerInventory inventory = player.getInventory();
ItemStack worn = armour(inventory, type);
if (type == ArmorType.HELMET ? isEmpty(worn) : !isArmourPiece(worn)) {
return false;
}
setArmour(inventory, type, null);
giveBack(player, worn);
return true;
}

private static boolean delays(Player player, ArmorType type, ItemStack piece) {
GameMode mode = player.getGameMode();
return mode != GameMode.CREATIVE && mode != GameMode.SPECTATOR
Expand Down
8 changes: 4 additions & 4 deletions src/main/resources/pvp.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +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. 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.
# on. Whoever is still in a chestplate, leggings and boots when the fight is
# called keeps their helmet and can take it off and put it back on until it
# ends. Anyone else also loses a helmet, mask or head put on in that time, and
# can't put one on. Someone who dies in the fight can gear up again.
# 0 takes nothing off.
recent-seconds: 180

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,13 +376,13 @@ ArmorEquipEvent equip(ArmorType type, ItemStack piece, EquipMethod method) {
equip(ArmorType.BOOTS, boots, EquipMethod.SHIFT_CLICK);
assertTrue(ArmourDonning.takeOffRecent(player, put + 60_000L));
List<String> lines = messages();
assertTrue(lines.stream().anyMatch(line -> line.contains("chestplate, boots")), lines.toString());
assertTrue(lines.stream().anyMatch(line -> line.contains("chestplate, boots, helmet")), lines.toString());
assertTrue(lines.stream().anyMatch(line -> line.contains("stopped fastening your boots")));
assertNull(player.getInventory().getChestplate());
assertNull(player.getInventory().getBoots());
assertEquals(Material.IRON_HELMET, player.getInventory().getHelmet().getType(), "Helmets stay on for roleplay");
assertNull(player.getInventory().getHelmet(), "Without body armour a recent helmet comes off too");
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");
assertEquals(2, 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));
Expand Down Expand Up @@ -419,6 +419,34 @@ ArmorEquipEvent equip(ArmorType type, ItemStack piece, EquipMethod method) {
assertTrue(PvpSituations.locksArmour(player.getUniqueId(), ArmorType.HELMET), "Another fight they came into unarmoured still locks it");
}

@Test void recentHeadwearComesOffOnlyForThoseOutOfBodyArmour() {
ItemStack helmet = new ItemStack(Material.IRON_HELMET), head = new ItemStack(Material.PLAYER_HEAD);
player.getInventory().setChestplate(chestplate.clone());
player.getInventory().setLeggings(new ItemStack(Material.IRON_LEGGINGS));
player.getInventory().setBoots(boots.clone());
player.getInventory().setHelmet(helmet.clone());
listener.onArmorChanged(new ArmorEquipEvent(player, EquipMethod.SHIFT_CLICK, ArmorType.HELMET, null, helmet));
long put = System.currentTimeMillis();
assertFalse(ArmourDonning.takeOffRecent(player, put + 1_000L));
assertEquals(Material.IRON_HELMET, player.getInventory().getHelmet().getType(), "Fighters in body armour keep a last-minute helmet");

player.getInventory().setBoots(null);
player.getInventory().setHelmet(head.clone());
listener.onArmorChanged(new ArmorEquipEvent(player, EquipMethod.HOTBAR, ArmorType.HELMET, helmet, head));
messages();
assertTrue(ArmourDonning.takeOffRecent(player, put + 1_000L));
assertNull(player.getInventory().getHelmet(), "Masks and heads come off like helmets");
assertTrue(said("headwear"));
assertTrue(player.getInventory().contains(Material.PLAYER_HEAD));

listener.onArmorChanged(new ArmorEquipEvent(player, EquipMethod.SHIFT_CLICK, ArmorType.HELMET, null, helmet));
assertFalse(ArmourDonning.takeOffRecent(player, put + 1_000L), "Nothing on the head any more");
player.getInventory().setHelmet(helmet.clone());
listener.onArmorChanged(new ArmorEquipEvent(player, EquipMethod.SHIFT_CLICK, ArmorType.HELMET, null, helmet));
assertFalse(ArmourDonning.takeOffRecent(player, System.currentTimeMillis() + 181_000L), "Headwear on for longer stays");
assertEquals(Material.IRON_HELMET, player.getInventory().getHelmet().getType());
}

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;
Expand Down
Loading