diff --git a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java index b919c6b7..8b69e534 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java +++ b/src/main/java/net/tfminecraft/rpcharacters/RPCharacters.java @@ -106,6 +106,7 @@ import net.tfminecraft.rpcharacters.pvp.ArmourDonning; import net.tfminecraft.rpcharacters.pvp.DuelCommand; import net.tfminecraft.rpcharacters.pvp.Duels; +import net.tfminecraft.rpcharacters.pvp.KnockoutRestrictions; import net.tfminecraft.rpcharacters.pvp.PvpCommand; import net.tfminecraft.rpcharacters.pvp.PvpKnockoutManager; import net.tfminecraft.rpcharacters.pvp.PvpStrikeService; @@ -442,6 +443,7 @@ public void registerListeners() { getServer().getPluginManager().registerEvents(wardrobeListener, this); getServer().getPluginManager().registerEvents(rpInjureListener, this); getServer().getPluginManager().registerEvents(pvpKnockoutManager, this); + getServer().getPluginManager().registerEvents(new KnockoutRestrictions(pvpKnockoutManager::isDown), this); getServer().getPluginManager().registerEvents(pvpCommand, this); getServer().getPluginManager().registerEvents(duelCommand, this); getServer().getPluginManager().registerEvents(new ArmourDonning(), this); diff --git a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java index 51c40e15..6cff6017 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/database/Database.java +++ b/src/main/java/net/tfminecraft/rpcharacters/database/Database.java @@ -411,7 +411,6 @@ private void loadCharacter(PlayerData pd, SavedCharacter saved) { loadExtraAttributeAllocation(c, json); loadTraitState(c, json); loadLastLocation(c, json); - loadPvpLethal(c, json); CharacterEvilRpFields.load(c, json); CharacterStageChangeFields.load(c, json); CharacterClassPickFields.load(c, json); @@ -594,7 +593,6 @@ private boolean trySaveCharacter(PlayerData pd, RPCharacter c, boolean durable) saveExtraAttributeAllocation(defaults, c); saveTraitState(defaults, c); saveLastLocation(defaults, c); - defaults.put("pvp-lethal", String.valueOf(c.isPvpLethal())); CharacterEvilRpFields.save(defaults, c); CharacterStageChangeFields.save(defaults, c); CharacterClassPickFields.save(defaults, c); @@ -796,20 +794,6 @@ private void loadLastLocation(RPCharacter character, JSONObject characterJson) { ((Number) zRaw).doubleValue()); } - private void loadPvpLethal(RPCharacter character, JSONObject characterJson) { - if (characterJson == null || !characterJson.containsKey("pvp-lethal")) { - return; - } - Object raw = characterJson.get("pvp-lethal"); - if (raw instanceof Boolean bool) { - character.setPvpLethal(bool.booleanValue()); - return; - } - if (raw != null) { - character.setPvpLethal(Boolean.parseBoolean(String.valueOf(raw))); - } - } - private void loadNutritionFields(RPCharacter character, JSONObject characterJson) { if (characterJson.containsKey("food-value")) { character.setFoodValue(((Number) characterJson.get("food-value")).intValue()); diff --git a/src/main/java/net/tfminecraft/rpcharacters/grave/Grave.java b/src/main/java/net/tfminecraft/rpcharacters/grave/Grave.java index 002ebef2..0609cf6d 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/grave/Grave.java +++ b/src/main/java/net/tfminecraft/rpcharacters/grave/Grave.java @@ -30,6 +30,8 @@ public final class Grave { private String killerDisplay; private boolean protect; private boolean locked; + /** Left by a character a strike killed: only the killer (or an admin) may open it. */ + private boolean killerOnly; private final long created; private int experience; private final ItemStack[] storage; @@ -153,6 +155,14 @@ public void setLocked(boolean locked) { this.locked = locked; } + public boolean isKillerOnly() { + return killerOnly; + } + + public void setKillerOnly(boolean killerOnly) { + this.killerOnly = killerOnly; + } + public long getCreated() { return created; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveCommand.java b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveCommand.java index 7e50bf24..f1e12655 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveCommand.java +++ b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveCommand.java @@ -35,6 +35,10 @@ public boolean onCommand(CommandSender sender, Command command, String label, St GraveRecover.sendMessage(player, GraveLoader.getMessageUnlockNone()); return true; } + if (grave.isKillerOnly()) { + GraveRecover.sendMessage(player, GraveLoader.getMessageUnlockKillerOnly()); + return true; + } if (!grave.isLocked()) { GraveRecover.sendMessage(player, GraveLoader.getMessageUnlockAlready()); return true; diff --git a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveDeathListener.java b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveDeathListener.java index 1d657c18..c5b1ace9 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveDeathListener.java +++ b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveDeathListener.java @@ -52,8 +52,10 @@ public void onPlayerDeath(PlayerDeathEvent event) { Player killerPlayer = killerId != null ? Bukkit.getPlayer(killerId) : null; boolean killedByOther = killerId != null && !killerId.equals(victim.getUniqueId()); boolean killerCanLoot = killerPlayer != null && killedByOther && GraveLootRules.canLootGrave(killerPlayer); - boolean unlocked = context.inPvpStart() && killedByOther && context.evilVictim(); - if (PvpStartDeathPolicy.keepInventory(context.inPvpStart(), killedByOther, killerCanLoot, unlocked)) { + // A strike killed the character: everything goes in a grave only the killer can open. + boolean killerOnly = context.characterDies() && killedByOther; + boolean unlocked = !killerOnly && context.inPvpStart() && killedByOther && context.evilVictim(); + if (!killerOnly && PvpStartDeathPolicy.keepInventory(context.inPvpStart(), killedByOther, killerCanLoot, unlocked)) { // Set on MONITOR, after DenarEconomy's NORMAL handler has dropped the pouch. keepInventory.add(victim.getUniqueId()); return; @@ -66,7 +68,8 @@ public void onPlayerDeath(PlayerDeathEvent event) { offhand = stripKept(storage, armor, offhand, stash); ItemStack[] offhandSlot = new ItemStack[] { offhand }; ItemStack bundledTicket = null; - if (GraveLoader.isInsuranceEnabled() && GraveInsuranceTickets.bindableSlot(stash) == null) { + // Insurance can't recover a killer-only grave, so a ticket in a bundle stays an ordinary item there. + if (!killerOnly && GraveLoader.isInsuranceEnabled() && GraveInsuranceTickets.bindableSlot(stash) == null) { bundledTicket = GraveInsuranceExtract.pull(storage, armor, offhandSlot, stash.values()); offhand = offhandSlot[0]; } @@ -97,13 +100,19 @@ public void onPlayerDeath(PlayerDeathEvent event) { if (grave == null) { return; } - if (unlocked) { + if (killerOnly) { + grave.setKillerOnly(true); + grave.flush(); + KillerOnlyGraves.remember(victim.getUniqueId(), grave.getId()); + } else if (unlocked) { // Evil RP victims killed in a PvP start leave their items for anyone to take. grave.setLocked(false); grave.flush(); } - GraveInsuranceTickets.Binding binding = GraveInsuranceTickets.bind(stash, grave); + // Loose tickets stay unbound and come back at respawn rather than tying up a grave its owner can't open. + GraveInsuranceTickets.Binding binding = killerOnly ? GraveInsuranceTickets.Binding.NONE + : GraveInsuranceTickets.bind(stash, grave); if (!binding.bound() && bundledTicket != null) { ItemStack bound = GraveInsuranceTickets.bindTicket(bundledTicket, grave); if (bound != null) { @@ -116,7 +125,8 @@ public void onPlayerDeath(PlayerDeathEvent event) { if (!stash.isEmpty()) { excludedStash.put(victim.getUniqueId(), stash); } - sendPlaced(victim, chestBlock, unlocked, binding.bound()); + sendPlaced(victim, chestBlock, killerOnly ? GraveLoader.getMessageKillerOnly() + : unlocked ? GraveLoader.getMessageUnlockedByStrike() : GraveLoader.getMessageUnlockHint(), binding.bound()); event.setDroppedExp(0); event.getDrops().clear(); } @@ -136,6 +146,7 @@ public void keepItemsAfterPouchDrop(PlayerDeathEvent event) { @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onPlayerRespawn(PlayerRespawnEvent event) { Player player = event.getPlayer(); + KillerOnlyGraves.forget(player.getUniqueId()); Map stashed = excludedStash.remove(player.getUniqueId()); if (stashed != null && !stashed.isEmpty()) { PlayerInventory inventory = player.getInventory(); @@ -246,7 +257,7 @@ private static ItemStack cloneItem(ItemStack item) { return item != null ? item.clone() : null; } - private void sendPlaced(Player player, Block chest, boolean unlocked, boolean insured) { + private void sendPlaced(Player player, Block chest, String hint, boolean insured) { List notices = new ArrayList<>(); String template = GraveLoader.getMessagePlaced(); if (template != null && !template.isBlank()) { @@ -258,7 +269,6 @@ private void sendPlaced(Player player, Block chest, boolean unlocked, boolean in .replace("{world}", world); notices.add(StringFormatter.formatHex(text.replace('&', '\u00A7'))); } - String hint = unlocked ? GraveLoader.getMessageUnlockedByStrike() : GraveLoader.getMessageUnlockHint(); if (hint != null && !hint.isBlank()) { notices.add(StringFormatter.formatHex(hint.replace('&', '\u00A7'))); } diff --git a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveLoader.java b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveLoader.java index cf294f38..2d34ed0c 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveLoader.java +++ b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveLoader.java @@ -53,6 +53,8 @@ public final class GraveLoader implements LoaderInterface { private static String messageUnlockedByStrike = "&cYou were in an evil RP session, so your grave is unlocked. Anyone can loot it."; private static String messageUnlockNone = "&eYou have no grave to unlock."; private static String messageUnlockAlready = "&eYour grave is already unlocked."; + private static String messageKillerOnly = "&cYour character died from a strike. Only your killer can open this grave."; + private static String messageUnlockKillerOnly = "&cOnly the killer can open this grave. It can't be unlocked."; private static String messageUnlockSuccess = "&aYour grave is now unlocked. Anyone can loot it."; private static String messageRobHint = "&aRight click to rob"; @@ -100,6 +102,8 @@ public void load(File configFile) { messageUnlockedByStrike = config.getString("messages.unlocked-evil-rp", "&cYou were in an evil RP session, so your grave is unlocked. Anyone can loot it."); messageUnlockNone = config.getString("messages.unlock-none", "&eYou have no grave to unlock."); messageUnlockAlready = config.getString("messages.unlock-already", "&eYour grave is already unlocked."); + messageKillerOnly = config.getString("messages.killer-only", "&cYour character died from a strike. Only your killer can open this grave."); + messageUnlockKillerOnly = config.getString("messages.unlock-killer-only", "&cOnly the killer can open this grave. It can't be unlocked."); messageUnlockSuccess = config.getString("messages.unlock-success", "&aYour grave is now unlocked. Anyone can loot it."); messageRobHint = config.getString("messages.rob-hint", "&aRight click to rob"); } @@ -254,6 +258,14 @@ public static String getMessageUnlockedByStrike() { return messageUnlockedByStrike; } + public static String getMessageKillerOnly() { + return messageKillerOnly; + } + + public static String getMessageUnlockKillerOnly() { + return messageUnlockKillerOnly; + } + public static String getMessageUnlockHint() { return messageUnlockHint; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveLootRules.java b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveLootRules.java index 62b360ea..655dc682 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveLootRules.java +++ b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveLootRules.java @@ -18,10 +18,13 @@ public static boolean canRecover(Player player, Grave grave) { if (player == null || grave == null) { return false; } - if (grave.isOwner(player.getUniqueId()) || player.hasPermission(ADMIN_PERMISSION)) { + if (player.hasPermission(ADMIN_PERMISSION)) { return true; } - return !grave.isLocked(); + if (grave.isKillerOnly()) { + return player.getUniqueId().equals(grave.getKiller()); + } + return grave.isOwner(player.getUniqueId()) || !grave.isLocked(); } /** Whether this player could take items from a grave they had just made by killing someone. */ diff --git a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveManager.java b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveManager.java index cca6b425..bce41194 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/grave/GraveManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/grave/GraveManager.java @@ -589,6 +589,7 @@ private static GraveRecord toRecord(Grave grave) throws IOException { record.killerDisplay = grave.getKillerDisplay(); record.protect = grave.isProtected(); record.locked = grave.isLocked(); + record.killerOnly = grave.isKillerOnly() ? Boolean.TRUE : null; record.created = grave.getCreated(); record.experience = grave.getExperience(); record.hologramUuid = null; @@ -615,7 +616,7 @@ private static Grave fromRecord(GraveRecord record) { return null; } Location block = new Location(world, record.x, record.y, record.z); - return new Grave( + Grave grave = new Grave( UUID.fromString(record.id), UUID.fromString(record.owner), record.characterOwner != null ? UUID.fromString(record.characterOwner) : null, @@ -631,6 +632,8 @@ private static Grave fromRecord(GraveRecord record) { decodeList(record.extras), parseUuid(record.hologramUuid), block); + grave.setKillerOnly(Boolean.TRUE.equals(record.killerOnly)); + return grave; } private static UUID parseUuid(String raw) { @@ -717,6 +720,7 @@ private static final class GraveRecord { String killerDisplay; boolean protect; Boolean locked; + Boolean killerOnly; long created; int experience; String hologramUuid; diff --git a/src/main/java/net/tfminecraft/rpcharacters/grave/KillerOnlyGraves.java b/src/main/java/net/tfminecraft/rpcharacters/grave/KillerOnlyGraves.java new file mode 100644 index 00000000..2738332c --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/grave/KillerOnlyGraves.java @@ -0,0 +1,41 @@ +package net.tfminecraft.rpcharacters.grave; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The grave listener runs before the strike, so it marks a grave killer-only from the + * strike it expects to kill. The strike then settles it: if the kill did not happen (a + * cancelled permakill), the grave goes back to an ordinary locked grave its owner can open. + */ +public final class KillerOnlyGraves { + + private static final Map awaitingStrike = new ConcurrentHashMap<>(); + + private KillerOnlyGraves() { + } + + static void remember(UUID victimId, UUID graveId) { + awaitingStrike.put(victimId, graveId); + } + + /** Called once the strike for this death has landed. */ + public static void settle(UUID victimId, boolean characterDied) { + UUID graveId = awaitingStrike.remove(victimId); + if (graveId == null || characterDied) { + return; + } + Grave grave = GraveManager.get().getById(graveId); + if (grave == null) { + return; + } + grave.setKillerOnly(false); + grave.flush(); + } + + /** A respawn ends the death; a strike that never came leaves the grave as it is. */ + static void forget(UUID victimId) { + awaitingStrike.remove(victimId); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/loaders/PvpLoader.java b/src/main/java/net/tfminecraft/rpcharacters/loaders/PvpLoader.java index 712f1370..0013a0ab 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/loaders/PvpLoader.java +++ b/src/main/java/net/tfminecraft/rpcharacters/loaders/PvpLoader.java @@ -18,8 +18,10 @@ public final class PvpLoader implements LoaderInterface { private static int startActiveMinutes = 15; private static int knockoutSeconds = 30; private static int blindnessAmplifier = 4; + private static int knockoutBlindnessSeconds = 60; + private static int lethalConfirmSeconds = 30; private static long freezePeriodTicks = 1L; - private static int decisionSeconds = 30; + private static int decisionSeconds = 300; private static int sameTargetCooldownHours = 24; private static boolean strikeDecayEnabled = false; private static int strikeDecayDays = 14; @@ -29,7 +31,7 @@ public final class PvpLoader implements LoaderInterface { private static int bootsDonSeconds = 2; private static int donSlownessAmplifier = 1; - private static String startWarning = "&ePvP will start in {seconds} seconds! &7Deaths in this fight can cost strikes."; + private static String startWarning = "&ePvP will start in {seconds} seconds! &7Lethal kills in this fight give strikes, and a knocked out player's fate is up to the winner."; private static String countdown = "&c{count}"; private static String startedTitle = "&cPVP STARTED"; private static String situationDuration = "&eThis PvP situation lasts {minutes} minutes. &7After that, a new RP interaction is needed."; @@ -37,8 +39,20 @@ public final class PvpLoader implements LoaderInterface { private static String endedSubtitle = "&eA new RP interaction is needed"; private static String startCancelled = "&7The PvP start was cancelled."; private static String noSituation = "&cYou have no PvP situation to end."; - private static String lethal = "&aThis character is now in lethal PvP."; + private static String lethal = "&cThis character is now in lethal PvP. &7It goes back to nonlethal when you log out, switch character or run &f/pvp nonlethal&7."; private static String nonlethal = "&aThis character is now in nonlethal PvP."; + private static String lethalWarning = "&c&l⚠ Lethal PvP\n" + + "&7In a &f/pvp start &7fight, anyone you kill gets a strike at once. You decided not to spare anyone when you turned lethal on, so you can't spare, wound or maim them.\n" + + "&7If it's their last strike, or they're in an evil RP session, their character dies and only you can open their grave.\n" + + "&7Players you kill outside &f/pvp start &7fights get no strike.\n" + + "&7Lethal lasts until you log out, switch character or run &f/pvp nonlethal&7."; + private static String lethalConfirmButton = "&c&l[Confirm]"; + private static String lethalConfirmHover = "&7Turn on lethal PvP for this character"; + private static String lethalConfirmPrompt = "&7Click &c[Confirm] &7or run &f/pvp lethal confirm &7within {seconds} seconds."; + private static String lethalConfirmNone = "&cRun &f/pvp lethal &cfirst, then confirm within {seconds} seconds."; + private static String alreadyLethal = "&7This character is already in lethal PvP."; + private static String downedBlocked = "&cYou're knocked out. You can still talk, but you can't do that."; + private static String autoStrikeVictim = "&c{killer} &cwas in lethal PvP, so their kill gave you a strike."; private static String usage = "&7Usage: /pvp start | end | lethal | nonlethal"; private static String noCharacter = "&cYou need an active character to set PvP mode."; private static String current = "&7This character's PvP mode: {mode}"; @@ -64,8 +78,10 @@ public void load(File configFile) { startActiveMinutes = Math.max(0, config.getInt("start-active-minutes", 15)); knockoutSeconds = Math.max(1, config.getInt("knockout-seconds", 30)); blindnessAmplifier = Math.max(0, config.getInt("blindness-amplifier", 4)); + knockoutBlindnessSeconds = Math.max(0, config.getInt("knockout-blindness-seconds", 60)); + lethalConfirmSeconds = Math.max(1, config.getInt("lethal-confirm-seconds", 30)); freezePeriodTicks = Math.max(1L, config.getLong("freeze-period-ticks", 1L)); - decisionSeconds = Math.max(1, config.getInt("strikes.decision-seconds", 30)); + decisionSeconds = Math.max(1, config.getInt("strikes.decision-seconds", 300)); sameTargetCooldownHours = Math.max(0, config.getInt("strikes.same-target-cooldown-hours", 24)); strikeDecayEnabled = config.getBoolean("strikes.decay.enabled", false); strikeDecayDays = Math.max(1, config.getInt("strikes.decay.days", 14)); @@ -85,6 +101,14 @@ public void load(File configFile) { noSituation = config.getString("messages.no-situation", noSituation); lethal = config.getString("messages.lethal", lethal); nonlethal = config.getString("messages.nonlethal", nonlethal); + lethalWarning = config.getString("messages.lethal-warning", lethalWarning); + lethalConfirmButton = config.getString("messages.lethal-confirm-button", lethalConfirmButton); + lethalConfirmHover = config.getString("messages.lethal-confirm-hover", lethalConfirmHover); + lethalConfirmPrompt = config.getString("messages.lethal-confirm-prompt", lethalConfirmPrompt); + lethalConfirmNone = config.getString("messages.lethal-confirm-none", lethalConfirmNone); + alreadyLethal = config.getString("messages.already-lethal", alreadyLethal); + downedBlocked = config.getString("messages.downed-blocked", downedBlocked); + autoStrikeVictim = config.getString("messages.auto-strike-victim", autoStrikeVictim); usage = config.getString("messages.usage", usage); noCharacter = config.getString("messages.no-character", noCharacter); current = config.getString("messages.current", current); @@ -124,6 +148,19 @@ public static int getBlindnessAmplifier() { return blindnessAmplifier; } + /** How long a knockout blinds, counted from when the player goes down. 0 for no blindness. */ + public static int getKnockoutBlindnessSeconds() { + return knockoutBlindnessSeconds; + } + + public static int getLethalConfirmSeconds() { + return lethalConfirmSeconds; + } + + public static long getLethalConfirmMs() { + return lethalConfirmSeconds * 1000L; + } + public static long getFreezePeriodTicks() { return freezePeriodTicks; } @@ -193,6 +230,38 @@ public static String getNonlethal() { return nonlethal; } + public static String getLethalWarning() { + return lethalWarning; + } + + public static String getLethalConfirmButton() { + return lethalConfirmButton; + } + + public static String getLethalConfirmHover() { + return lethalConfirmHover; + } + + public static String getLethalConfirmPrompt() { + return lethalConfirmPrompt; + } + + public static String getLethalConfirmNone() { + return lethalConfirmNone; + } + + public static String getAlreadyLethal() { + return alreadyLethal; + } + + public static String getDownedBlocked() { + return downedBlocked; + } + + public static String getAutoStrikeVictim() { + return autoStrikeVictim; + } + public static String getUsage() { return usage; } diff --git a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java index ea40a47f..ac24dbce 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java +++ b/src/main/java/net/tfminecraft/rpcharacters/objects/RPCharacter.java @@ -108,8 +108,8 @@ public class RPCharacter { public net.tfminecraft.rpcharacters.will.CharacterWill getWill() { return will; } public void setWill(net.tfminecraft.rpcharacters.will.CharacterWill will) { this.will = java.util.Objects.requireNonNull(will, "will"); } - /** Default lethal (vanilla death). Missing JSON key loads as true. */ - private boolean pvpLethal = true; + /** Never saved: every character starts nonlethal and returns to it when it becomes active. */ + private boolean pvpLethal; private int evilRpStrikes; /** Epoch ms when the evil RP session ends; 0 when there is none. Runs while offline. */ diff --git a/src/main/java/net/tfminecraft/rpcharacters/pvp/KnockoutRestrictions.java b/src/main/java/net/tfminecraft/rpcharacters/pvp/KnockoutRestrictions.java new file mode 100644 index 00000000..ac43d72c --- /dev/null +++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/KnockoutRestrictions.java @@ -0,0 +1,136 @@ +package net.tfminecraft.rpcharacters.pvp; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; + +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityDamageEvent; +import org.bukkit.event.entity.EntityShootBowEvent; +import org.bukkit.event.entity.ProjectileLaunchEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerItemConsumeEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerSwapHandItemsEvent; + +import io.lumine.mythic.lib.api.event.skill.PlayerCastSkillEvent; +import net.tfminecraft.rpcharacters.loaders.PvpLoader; +import net.tfminecraft.rpcharacters.utils.RPTexts; + +/** + * A knocked out player can talk and roleplay, but can't fight back: no hits, shots, skills + * or spells, and no eating, drinking, item use, dropping items or building. Everything runs + * first so other plugins never act on the attempt. + */ +public final class KnockoutRestrictions implements Listener { + + private static final long NOTICE_INTERVAL_MS = 1_000L; + + private final Predicate knockedOut; + private final Map lastNotice = new ConcurrentHashMap<>(); + + public KnockoutRestrictions(Predicate knockedOut) { + this.knockedOut = knockedOut; + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onAttack(EntityDamageEvent event) { + Player attacker = PvpKnockoutManager.attackingPlayer(event); + if (attacker != null && !attacker.equals(event.getEntity())) { + block(attacker, event); + } + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onShootBow(EntityShootBowEvent event) { + if (event.getEntity() instanceof Player player) { + block(player, event); + } + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onLaunch(ProjectileLaunchEvent event) { + if (event.getEntity().getShooter() instanceof Player player) { + block(player, event); + } + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onCastSkill(PlayerCastSkillEvent event) { + block(event.getPlayer(), event); + } + + // Not ignoreCancelled: Bukkit fires air clicks already cancelled. + @EventHandler(priority = EventPriority.LOWEST) + public void onInteract(PlayerInteractEvent event) { + if (event.getAction() == Action.PHYSICAL || !knockedOut.test(event.getPlayer())) { + return; + } + event.setUseItemInHand(Event.Result.DENY); + event.setUseInteractedBlock(Event.Result.DENY); + notice(event.getPlayer()); + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onInteractEntity(PlayerInteractEntityEvent event) { + block(event.getPlayer(), event); + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onConsume(PlayerItemConsumeEvent event) { + block(event.getPlayer(), event); + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onSwapHands(PlayerSwapHandItemsEvent event) { + block(event.getPlayer(), event); + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onDrop(PlayerDropItemEvent event) { + block(event.getPlayer(), event); + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onPlace(BlockPlaceEvent event) { + block(event.getPlayer(), event); + } + + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onBreak(BlockBreakEvent event) { + block(event.getPlayer(), event); + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + lastNotice.remove(event.getPlayer().getUniqueId()); + } + + private void block(Player player, Cancellable event) { + if (knockedOut.test(player)) { + event.setCancelled(true); + notice(player); + } + } + + private void notice(Player player) { + long now = System.currentTimeMillis(); + Long last = lastNotice.get(player.getUniqueId()); + if (last != null && now - last < NOTICE_INTERVAL_MS) { + return; + } + lastNotice.put(player.getUniqueId(), now); + RPTexts.send(player, PvpLoader.getDownedBlocked()); + } +} diff --git a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpCommand.java b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpCommand.java index d95748de..42d9f694 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpCommand.java +++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpCommand.java @@ -6,6 +6,7 @@ import java.util.Locale; import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -22,6 +23,10 @@ import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.scheduler.BukkitTask; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import net.tfminecraft.rpcharacters.loaders.PvpLoader; import net.tfminecraft.rpcharacters.managers.PlayerManager; import net.tfminecraft.rpcharacters.objects.PlayerData; @@ -29,12 +34,17 @@ import net.tfminecraft.rpcharacters.RPCharacters; import net.tfminecraft.rpcharacters.tutorial.TutorialService; import net.tfminecraft.rpcharacters.utils.RPTexts; +import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; public final class PvpCommand implements CommandExecutor, TabCompleter, Listener { public static final String COMMAND = "pvp"; + private static final String CONFIRM = "confirm"; + private final Map> tasksBySituation = new IdentityHashMap<>(); + /** Players who ran /pvp lethal and may still confirm it, with the character they ran it for. */ + private final Map lethalRequests = new ConcurrentHashMap<>(); @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { @@ -60,11 +70,15 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return true; } case "lethal" -> { - setLethal(player, true); + if (args.length > 1 && CONFIRM.equalsIgnoreCase(args[1])) { + confirmLethal(player); + } else { + requestLethal(player); + } return true; } case "nonlethal" -> { - setLethal(player, false); + setNonlethal(player); return true; } default -> { @@ -84,16 +98,68 @@ private void sendUsage(Player player) { RPTexts.send(player, PvpLoader.getCurrent().replace("{mode}", mode)); } - private void setLethal(Player player, boolean lethal) { - PlayerData pd = PlayerManager.get(player); - if (pd == null || !pd.hasActiveCharacter()) { + /** Lethal is never switched on directly: the player reads the warning and confirms it. */ + private void requestLethal(Player player) { + RPCharacter character = activeCharacter(player); + if (character == null) { + RPTexts.send(player, PvpLoader.getNoCharacter()); + return; + } + if (character.isPvpLethal()) { + RPTexts.send(player, PvpLoader.getAlreadyLethal()); + return; + } + lethalRequests.put(player.getUniqueId(), new LethalRequest(character.getId(), + System.currentTimeMillis() + PvpLoader.getLethalConfirmMs())); + for (String line : PvpLoader.getLethalWarning().split("\\R|\\\\n")) { + RPTexts.send(player, line); + } + String seconds = String.valueOf(PvpLoader.getLethalConfirmSeconds()); + // Only the button runs the command; children would inherit its click. + player.sendMessage(Component.empty() + .append(legacy(PvpLoader.getLethalConfirmButton()) + .clickEvent(ClickEvent.runCommand("/" + COMMAND + " lethal " + CONFIRM)) + .hoverEvent(HoverEvent.showText(legacy(PvpLoader.getLethalConfirmHover())))) + .append(Component.space()) + .append(legacy(PvpLoader.getLethalConfirmPrompt().replace("{seconds}", seconds)))); + } + + private void confirmLethal(Player player) { + LethalRequest request = lethalRequests.remove(player.getUniqueId()); + RPCharacter character = activeCharacter(player); + if (character == null) { RPTexts.send(player, PvpLoader.getNoCharacter()); return; } - RPCharacter character = pd.getActiveCharacter(); - character.setPvpLethal(lethal); - RPCharacters.getPlayerManager().savePlayer(player); - RPTexts.send(player, lethal ? PvpLoader.getLethal() : PvpLoader.getNonlethal()); + // The confirmation only covers the character the warning was shown for. + if (request == null || System.currentTimeMillis() >= request.expiresAtMs() + || !request.characterId().equals(character.getId())) { + RPTexts.send(player, PvpLoader.getLethalConfirmNone() + .replace("{seconds}", String.valueOf(PvpLoader.getLethalConfirmSeconds()))); + return; + } + character.setPvpLethal(true); + RPTexts.send(player, PvpLoader.getLethal()); + } + + private void setNonlethal(Player player) { + lethalRequests.remove(player.getUniqueId()); + RPCharacter character = activeCharacter(player); + if (character == null) { + RPTexts.send(player, PvpLoader.getNoCharacter()); + return; + } + character.setPvpLethal(false); + RPTexts.send(player, PvpLoader.getNonlethal()); + } + + private static RPCharacter activeCharacter(Player player) { + PlayerData pd = PlayerManager.get(player); + return pd != null && pd.hasActiveCharacter() ? pd.getActiveCharacter() : null; + } + + private static Component legacy(String raw) { + return LegacyComponentSerializer.legacySection().deserialize(RPTexts.formatDisplay(raw)); } private void startWarning(Player player) { @@ -131,6 +197,7 @@ private void startWarning(Player player) { : cooldownHours == 1 ? "1 hour" : cooldownHours + " hours"; TutorialService.show(online, TutorialService.PVP_STRIKES, Map.of( "seconds", String.valueOf(PvpLoader.getDecisionSeconds()), + "time", PvpStrikeService.formatDuration(PvpLoader.getDecisionSeconds()), "hours", String.valueOf(cooldownHours), "cooldown", cooldown)); } @@ -268,11 +335,29 @@ public void onDeath(PlayerDeathEvent event) { @EventHandler public void onQuit(PlayerQuitEvent event) { - PvpStartSessions.end(event.getPlayer().getUniqueId()); + Player player = event.getPlayer(); + PvpStartSessions.end(player.getUniqueId()); + lethalRequests.remove(player.getUniqueId()); + PvpStrikeService.handleQuit(player); + } + + /** Every character starts nonlethal when it becomes active: on login, after a restart and on a switch. */ + @EventHandler + public void onSessionStart(CharacterSessionStartEvent event) { + Player player = event.getPlayer(); + lethalRequests.remove(player.getUniqueId()); + RPCharacter character = activeCharacter(player); + if (character != null) { + character.setPvpLethal(false); + } } @Override public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + if (args.length == 2 && "lethal".equalsIgnoreCase(args[0]) + && CONFIRM.startsWith(args[1].toLowerCase(Locale.ROOT))) { + return List.of(CONFIRM); + } if (args.length == 1) { String prefix = args[0].toLowerCase(Locale.ROOT); List out = new ArrayList<>(); @@ -285,4 +370,7 @@ public List onTabComplete(CommandSender sender, Command command, String } return List.of(); } + + private record LethalRequest(String characterId, long expiresAtMs) { + } } diff --git a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java index 00862e4e..638de121 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java +++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManager.java @@ -102,10 +102,22 @@ public void onLethalDamage(EntityDamageEvent event) { } event.setCancelled(true); - applyKnockout(player); Player attacker = attackingPlayer(event); CoreProtectKnockouts.log(attacker, player); - PvpStrikeService.handleKnockout(player, attacker); + boolean deciding = PvpStrikeService.handleKnockout(player, attacker); + applyKnockout(player, deciding); + } + + /** + * Nothing hurts a downed player, so no one can finish them off while the winner decides. + * Runs first so other plugins never see the hit. SimpleFactions battles stay lethal. + */ + @EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true) + public void onDownedDamage(EntityDamageEvent event) { + if (event.getEntity() instanceof Player player && isKnockedOut(player) && downedPlayerSurvives(event) + && !PermadeathBattleExemption.isInStartedBattle(player)) { + event.setCancelled(true); + } } /** @@ -118,6 +130,11 @@ static boolean usesNonlethalMode(EntityDamageEvent event, Player player) { if (attacker == null || attacker.getUniqueId().equals(player.getUniqueId())) { return false; } + return isNonlethal(attacker); + } + + /** Whether this player's active character is in nonlethal PvP. Players without one are lethal. */ + static boolean isNonlethal(Player attacker) { PlayerData pd = PlayerManager.get(attacker); if (pd == null || !pd.hasActiveCharacter()) { return false; @@ -125,22 +142,12 @@ static boolean usesNonlethalMode(EntityDamageEvent event, Player player) { return !pd.getActiveCharacter().isPvpLethal(); } - /** - * A downed player is held in place at half a heart and cannot dodge drowning, fire, or the - * fall when the freeze ends, so damage with no attacker cannot kill them. Players and mobs - * still can, and so can /kill, the void and the world border. - */ + /** A downed player can't be hurt, except by /kill, the void and the world border. */ static boolean downedPlayerSurvives(EntityDamageEvent event) { - switch (event.getCause()) { - case KILL, VOID, WORLD_BORDER: - return false; - default: - break; - } - if (event instanceof EntityDamageByEntityEvent) { - return false; - } - return event.getDamageSource() == null || event.getDamageSource().getCausingEntity() == null; + return switch (event.getCause()) { + case KILL, VOID, WORLD_BORDER -> false; + default -> true; + }; } static Player attackingPlayer(EntityDamageEvent event) { @@ -171,11 +178,16 @@ public void onDeath(PlayerDeathEvent event) { } private void releaseKnockout(Player player) { - if (knockouts.remove(player.getUniqueId()) == null) { + Knockout knockout = knockouts.remove(player.getUniqueId()); + if (knockout == null) { return; } // Each freeze teleport keeps the fall distance, so it would kill them on landing. player.setFallDistance(0f); + // A spare or strike can come before the blindness wears off. + if (System.currentTimeMillis() < knockout.blindUntilMs) { + player.removePotionEffect(PotionEffectType.BLINDNESS); + } if (crawl != null) { crawl.release(player); } @@ -187,10 +199,15 @@ public boolean isDown(Player player) { private boolean isKnockedOut(Player player) { Knockout knockout = knockouts.get(player.getUniqueId()); - return knockout != null && System.currentTimeMillis() < knockout.untilMs && !shouldSkipTick(player); + return knockout != null && !knockout.isOver(player.getUniqueId(), System.currentTimeMillis()) + && !shouldSkipTick(player); } - private void applyKnockout(Player player) { + /** + * Knocks the player out. While the winner decides ({@code deciding}) they stay down until + * the choice is made or the decision time runs out; otherwise for knockout-seconds. + */ + private void applyKnockout(Player player, boolean deciding) { double maxHealth = 20.0; var maxAttr = player.getAttribute(Attribute.MAX_HEALTH); if (maxAttr != null) { @@ -198,18 +215,24 @@ private void applyKnockout(Player player) { } player.setHealth(Math.max(0.1, Math.min(1.0, maxHealth))); - int durationTicks = (int) Math.min(Integer.MAX_VALUE, PvpLoader.getKnockoutSeconds() * 20L); - player.addPotionEffect(new PotionEffect( - PotionEffectType.BLINDNESS, - durationTicks, - PvpLoader.getBlindnessAmplifier(), - false, - false, - true)); + long seconds = deciding ? PvpLoader.getDecisionSeconds() : PvpLoader.getKnockoutSeconds(); + long blindSeconds = Math.min(seconds, PvpLoader.getKnockoutBlindnessSeconds()); + if (blindSeconds > 0) { + player.addPotionEffect(new PotionEffect( + PotionEffectType.BLINDNESS, + (int) Math.min(Integer.MAX_VALUE, blindSeconds * 20L), + PvpLoader.getBlindnessAmplifier(), + false, + false, + true)); + } + long now = System.currentTimeMillis(); knockouts.put(player.getUniqueId(), new Knockout( player.getLocation().clone(), - System.currentTimeMillis() + PvpLoader.getKnockoutSeconds() * 1000L)); + now + seconds * 1000L, + deciding, + now + blindSeconds * 1000L)); if (crawl != null) { crawl.enforce(player); } @@ -225,7 +248,7 @@ private void tick() { it.remove(); continue; } - if (!player.isOnline() || now >= entry.getValue().untilMs || shouldSkipTick(player)) { + if (!player.isOnline() || entry.getValue().isOver(player.getUniqueId(), now) || shouldSkipTick(player)) { releaseKnockout(player); continue; } @@ -280,10 +303,19 @@ private void enforceFreeze(Player player, Location freezeAt) { private static final class Knockout { private final Location location; private final long untilMs; + /** Held down by a winner's decision rather than for a fixed time. */ + private final boolean deciding; + private final long blindUntilMs; - private Knockout(Location location, long untilMs) { + private Knockout(Location location, long untilMs, boolean deciding, long blindUntilMs) { this.location = location; this.untilMs = untilMs; + this.deciding = deciding; + this.blindUntilMs = blindUntilMs; + } + + private boolean isOver(UUID playerId, long nowMs) { + return nowMs >= untilMs || deciding && !PvpStrikeService.isAwaitingDecision(playerId); } } } diff --git a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpStrikeService.java b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpStrikeService.java index 9a82f4cb..1ee65ee6 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpStrikeService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/pvp/PvpStrikeService.java @@ -25,9 +25,11 @@ import net.kyori.adventure.text.format.TextDecoration; import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import net.tfminecraft.rpcharacters.RPCharacters; +import net.tfminecraft.rpcharacters.clues.discovery.ClueAdminModeService; import net.tfminecraft.rpcharacters.enums.Status; import net.tfminecraft.rpcharacters.evilrp.EvilRpService; import net.tfminecraft.rpcharacters.evilrp.StrikeOutcome; +import net.tfminecraft.rpcharacters.grave.KillerOnlyGraves; import net.tfminecraft.rpcharacters.identity.DisplayIdentityService; import net.tfminecraft.rpcharacters.loaders.PvpLoader; import net.tfminecraft.rpcharacters.managers.PlayerManager; @@ -40,13 +42,15 @@ import net.tfminecraft.rpcharacters.utils.TraitChangeService; /** - * Strikes from {@code /pvp start} fights. When a tagged player is killed or knocked out by - * another player, the killer chooses to spare them or strike them. The same player cannot - * strike that character again until the cooldown in pvp.yml has passed, so one person - * cannot land all three strikes in a day. The strike is labelled Kill when it would kill: - * the character's last strike, or any strike during an evil RP session. Then the killer can - * also Wound (healing injury) or Maim (permanent injury) instead, with no strike. Running - * out of time spares them. + * Strikes from {@code /pvp start} fights. A lethal player chose not to spare anyone when they + * turned lethal on, so a tagged player they kill gets a strike at once, or dies when the strike + * would kill. When a nonlethal player knocks a tagged player out, the winner chooses to spare + * or strike them while they stay down. The same player cannot strike that character again + * until the cooldown in pvp.yml has passed, so one person cannot land all three strikes in a + * day. The strike is labelled Kill when it would kill: the character's last strike, or any + * strike during an evil RP session. Then the winner of a knockout can also Wound (healing + * injury) or Maim (permanent injury) instead, with no strike. Running out of time, or the + * winner logging out, spares them. */ public final class PvpStrikeService { @@ -86,20 +90,24 @@ static boolean counts(boolean inPvpStart, boolean inBattle, UUID victimId, UUID return inPvpStart && !inBattle && killerId != null && !killerId.equals(victimId); } - /** A knockout ends the victim's PvP start and, if it counts, asks the attacker what to do. */ - public static void handleKnockout(Player victim, Player attacker) { + /** + * A knockout ends the victim's PvP start and, if it counts, asks the attacker what to do. + * Returns whether the attacker is now deciding, which keeps the victim down until they do. + */ + public static boolean handleKnockout(Player victim, Player attacker) { boolean inPvpStart = PvpStartSessions.consume(victim.getUniqueId(), System.currentTimeMillis()); if (!counts(inPvpStart, PermadeathBattleExemption.isInStartedBattle(victim), victim.getUniqueId(), attacker != null ? attacker.getUniqueId() : null)) { - return; + return false; } openDecision(victim, attacker, false); + return pending.containsKey(victim.getUniqueId()); } /** - * Called from the death event after the battle check. Returns true when a killer's decision - * covers this death, so the permadeath-zone roll is skipped. Deaths without a player killer - * are an automatic spare. + * Called from the death event after the battle check. Returns true when a strike, or a + * killer's decision, covers this death, so the permadeath-zone roll is skipped. Deaths + * without a player killer are an automatic spare. */ public static boolean handleDeath(Player victim, boolean inPvpStart) { UUID victimId = victim.getUniqueId(); @@ -113,27 +121,94 @@ public static boolean handleDeath(Player victim, boolean inPvpStart) { if (!counts(inPvpStart, false, victimId, killer != null ? killer.getUniqueId() : null)) { return false; } - return openDecision(victim, killer, true); + // A nonlethal blow only kills when no knockout was possible; that killer still decides. + if (PvpKnockoutManager.isNonlethal(killer)) { + return openDecision(victim, killer, true); + } + return strikeOnDeath(victim, killer); } public static boolean hasPendingDecision(Player player) { return player != null && (pending.containsKey(player.getUniqueId()) || verdicts.containsKey(player.getUniqueId())); } - /** How the grave listener should treat this death. Consumes a pending Kill. */ + /** Whether a knockout winner is still choosing what happens to this player. */ + public static boolean isAwaitingDecision(UUID victimId) { + return victimId != null && pending.containsKey(victimId); + } + + /** A winner who logs out while choosing spares their victim. */ + public static void handleQuit(Player player) { + UUID killerId = player.getUniqueId(); + Iterator> it = pending.entrySet().iterator(); + while (it.hasNext()) { + Map.Entry entry = it.next(); + if (!entry.getValue().killerId.equals(killerId)) { + continue; + } + it.remove(); + Player victim = Bukkit.getPlayer(entry.getKey()); + if (victim != null) { + RPTexts.send(victim, RPTexts.SUCCESS + "You were spared. " + RPTexts.MUTED + "They logged out before choosing."); + } + } + } + + /** + * How the grave listener should treat this death. Consumes a pending Kill. It runs before + * {@link #handleDeath}, so it works out the same strike to know whether the character dies. + */ public static GraveContext graveContext(Player victim, boolean inPvpStart) { UUID victimId = victim.getUniqueId(); Execution execution = executions.remove(victimId); if (execution != null) { - return new GraveContext(true, execution.evil, execution.killerId); + return new GraveContext(true, execution.evil, execution.killerId, true); } Decision downed = pending.get(victimId); if (downed != null) { - return new GraveContext(true, downed.evil, null); + return new GraveContext(true, downed.evil, null, false); } - boolean evil = inPvpStart && !PermadeathBattleExemption.isInStartedBattle(victim) - && EvilRpService.isInSession(victim); - return new GraveContext(inPvpStart, evil, null); + boolean inBattle = PermadeathBattleExemption.isInStartedBattle(victim); + boolean evil = inPvpStart && !inBattle && EvilRpService.isInSession(victim); + return new GraveContext(inPvpStart, evil, null, inPvpStart && !inBattle && strikeKillsOnDeath(victim)); + } + + /** Whether {@link #handleDeath} will kill this character for a lethal player's kill. */ + private static boolean strikeKillsOnDeath(Player victim) { + Player killer = victim.getKiller(); + if (!counts(true, false, victim.getUniqueId(), killer != null ? killer.getUniqueId() : null) + || ClueAdminModeService.isEnabled(victim) || PvpKnockoutManager.isNonlethal(killer)) { + return false; + } + RPCharacter character = activeCharacter(victim); + if (character == null) { + return false; + } + long now = System.currentTimeMillis(); + if (EvilRpService.applyDecay(character, now)) { + RPCharacters.getPlayerManager().savePlayer(victim); + } + return !onCooldown(character, killer.getUniqueId(), now) + && StrikeOutcome.nextStrikeKills(character.getEvilRpStrikes(), EvilRpService.isInSession(character)); + } + + /** A lethal player's kill: they chose not to spare anyone, so the strike, or the Kill, lands at once. */ + private static boolean strikeOnDeath(Player victim, Player killer) { + RPCharacter character = activeCharacter(victim); + if (character == null) { + return false; + } + long now = System.currentTimeMillis(); + if (!onCooldown(character, killer.getUniqueId(), now)) { + RPTexts.send(victim, PvpLoader.getAutoStrikeVictim() + .replace("{killer}", RPTexts.formatGui(DisplayIdentityService.resolveDisplay(killer)))); + } + Decision strike = new Decision(character.getId(), killer.getUniqueId(), EvilRpService.isInSession(character), + true, false, now, StrikeChoice.STRIKE); + execute(victim, PlayerManager.get(victim), strike, killer); + // The grave was made killer-only from the expected kill; a cancelled permakill hands it back. + KillerOnlyGraves.settle(victim.getUniqueId(), character.getStatus() != Status.ALIVE); + return true; } /** The killer's choice. */ @@ -248,11 +323,12 @@ private static boolean openDecision(Player victim, Player killer, boolean died) System.currentTimeMillis() + PvpLoader.getDecisionMs(), StrikeChoice.STRIKE); pending.put(victim.getUniqueId(), decision); - int seconds = PvpLoader.getDecisionSeconds(); + String time = formatDuration(PvpLoader.getDecisionSeconds()); RPTexts.send(victim, RPTexts.ERROR + "You're at " + RPTexts.formatGui(DisplayIdentityService.resolveDisplay(killer)) - + RPTexts.ERROR + "'s mercy. " + RPTexts.MUTED + "They have " + seconds + " seconds to " - + (kills ? "spare, wound, maim or kill you." : "spare or strike you.")); - sendPrompt(killer, victim, character, evil, kills, seconds); + + RPTexts.ERROR + "'s mercy. " + RPTexts.MUTED + "They have " + time + " to " + + (kills ? "spare, wound, maim or kill you." : "spare or strike you.") + + (died ? "" : " Until then you stay down: you can talk, but you can't fight.")); + sendPrompt(killer, victim, character, evil, kills, time); return true; } @@ -379,8 +455,20 @@ private static void tick() { executions.values().removeIf(execution -> now >= execution.expiresAtMs); } + /** "30 seconds", "1 minute", "5 minutes" or "2 minutes 30 seconds". */ + static String formatDuration(int totalSeconds) { + int minutes = totalSeconds / 60; + int seconds = totalSeconds % 60; + String secondsText = seconds + (seconds == 1 ? " second" : " seconds"); + if (minutes == 0) { + return secondsText; + } + String minutesText = minutes + (minutes == 1 ? " minute" : " minutes"); + return seconds == 0 ? minutesText : minutesText + " " + secondsText; + } + private static void sendPrompt(Player killer, Player victim, RPCharacter character, boolean evil, boolean kills, - int seconds) { + String time) { String victimName = RPTexts.formatGui(DisplayIdentityService.resolveDisplay(victim)); String command = "/rpcharacter %s " + victim.getUniqueId(); int next = character.getEvilRpStrikes() + 1; @@ -406,7 +494,7 @@ private static void sendPrompt(Player killer, Player victim, RPCharacter charact String reason = evil ? " was in an evil RP session, so any strike kills them." : " is at your mercy."; killer.sendMessage(Component.empty() .append(LegacyComponentSerializer.legacySection().deserialize(victimName)) - .append(Component.text(reason + " If you don't choose in " + seconds + " seconds, they're spared. ", + .append(Component.text(reason + " If you don't choose in " + time + ", they're spared. ", NamedTextColor.GRAY)) .append(buttons)); } @@ -490,8 +578,11 @@ private static boolean saveVerdicts() { } } - /** {@code evilVictim} leaves an unlocked grave; {@code killerId} overrides the death's own killer. */ - public record GraveContext(boolean inPvpStart, boolean evilVictim, UUID killerId) { + /** + * {@code evilVictim} leaves an unlocked grave; {@code killerId} overrides the death's own killer; + * {@code characterDies} means a strike kills the character, so only the killer may open the grave. + */ + public record GraveContext(boolean inPvpStart, boolean evilVictim, UUID killerId, boolean characterDies) { } /** {@code strikeKills} is whether Kill (with Wound and Maim) was offered; {@code choice} is set once picked. */ diff --git a/src/main/java/net/tfminecraft/rpcharacters/tutorial/TutorialService.java b/src/main/java/net/tfminecraft/rpcharacters/tutorial/TutorialService.java index a98f450e..191d8111 100644 --- a/src/main/java/net/tfminecraft/rpcharacters/tutorial/TutorialService.java +++ b/src/main/java/net/tfminecraft/rpcharacters/tutorial/TutorialService.java @@ -23,8 +23,8 @@ public final class TutorialService { public static final String PERMADEATH_ZONE = "permadeath-zone"; - public static final String EVIL_RP = "evil-rp"; - public static final String PVP_STRIKES = "pvp-strikes"; + public static final String EVIL_RP = "evil-rp-lethal"; + public static final String PVP_STRIKES = "pvp-modes"; private static final String GOT_IT_LABEL = "[Got It]"; private static final int SEPARATOR_WIDTH = 40; diff --git a/src/main/resources/graves.yml b/src/main/resources/graves.yml index 2e63f809..df1472f0 100644 --- a/src/main/resources/graves.yml +++ b/src/main/resources/graves.yml @@ -50,6 +50,9 @@ messages: unlock-hint: "&eRun &f/grave unlock &eto let others loot this grave." # Shown instead of unlock-hint when an evil RP victim dies in a /pvp start fight. unlocked-evil-rp: "&cYou were in an evil RP session, so your grave is unlocked. Anyone can loot it." + # Shown instead of unlock-hint when a strike kills the character. Only the killer can open that grave. + killer-only: "&cYour character died from a strike. Only your killer can open this grave." + unlock-killer-only: "&cOnly the killer can open this grave. It can't be unlocked." insurance-none: "&eThis ticket is not bound to a grave. Carry it when you die to insure that grave." insurance-gone: "&eThe grave this ticket was bound to is gone." # Shown after respawn when a ticket was bound to the new grave. diff --git a/src/main/resources/pvp.yml b/src/main/resources/pvp.yml index 32ac868f..bb88a9d1 100644 --- a/src/main/resources/pvp.yml +++ b/src/main/resources/pvp.yml @@ -1,4 +1,9 @@ -# /pvp start warning and nonlethal knockout +# /pvp start warning, PvP modes and nonlethal knockout +# +# Every character is nonlethal when it becomes active: after logging in, after a +# restart and after switching character. A nonlethal player's killing blow knocks the +# victim out instead. /pvp lethal shows messages.lethal-warning and must be confirmed +# with /pvp lethal confirm within lethal-confirm-seconds. Lethal is never saved. start-radius: 16 start-warn-seconds: 10 start-countdown-from: 5 @@ -6,26 +11,36 @@ start-countdown-from: 5 # who have not died see a title that it has ended and a new RP interaction is # needed. The player who ran /pvp start can end it sooner with /pvp end. # 0 disables the timeout. Dying, being knocked out, or logging out ends strike -# eligibility early. A death or knockout in this window by another player lets -# the killer spare or strike the victim (see strikes below). A death to a player -# who cannot loot graves keeps the victim's items unless the victim was in an -# evil RP session. Their coin pouch still drops. +# eligibility early. In this window a lethal player's kill gives the victim a strike +# at once, and a nonlethal player's knockout lets them spare or strike the victim +# (see strikes below). A death to a player who cannot loot graves keeps the victim's +# items unless the character dies from it. Their coin pouch still drops. start-active-minutes: 15 +lethal-confirm-seconds: 30 +# Knockouts outside a /pvp start last this long. In a /pvp start the victim stays down +# until the winner decides (strikes.decision-seconds at most). knockout-seconds: 30 +# Blindness for this many seconds after going down (never longer than the knockout). +# 0 = no blindness. +knockout-blindness-seconds: 60 # Minecraft effect level 5 = amplifier 4 blindness-amplifier: 4 # 1 tick = 20Hz freeze loop (only knocks out players) freeze-period-ticks: 1 -# Strikes from deaths and knockouts during a PvP start. SimpleFactions battles never -# give strikes. The first strike is a healing injury, the second a permanent one, -# and the third kills the character. In an evil RP session any strike kills. -# When a strike would kill, the killer can also Wound (healing injury) or Maim -# (permanent injury) instead, with no strike added. The same player cannot strike -# the same character again until the cooldown below has passed. +# Strikes from deaths and knockouts during a PvP start. SimpleFactions battles and +# duels never give strikes. The first strike is a healing injury, the second a +# permanent one, and the third kills the character. In an evil RP session any strike +# kills. A lethal player's kill always strikes, or kills when the strike would kill, +# and the dead character's grave can only be opened by the killer. After a nonlethal +# knockout the winner chooses: Spare or Strike, and when a strike would kill, Wound +# (healing injury) or Maim (permanent injury) instead, with no strike added, or Kill. +# The same player cannot strike the same character again until the cooldown below +# has passed. strikes: - # How long the killer has to choose. No choice means spare. - decision-seconds: 30 + # How long the winner of a knockout has to choose. The victim stays knocked out, + # unhurt and unable to fight until then, but can still talk. No choice means spare. + decision-seconds: 300 # Hours before that same player can strike this character again. # The 24 hour default stops one person landing all three strikes in a single day. # 0 disables the wait. @@ -50,7 +65,7 @@ armour: slowness-amplifier: 1 messages: - start-warning: "&ePvP will start in {seconds} seconds! &7Deaths in this fight can cost strikes." + start-warning: "&ePvP will start in {seconds} seconds! &7Lethal kills in this fight give strikes, and a knocked out player's fate is up to the winner." countdown: "&c{count}" started-title: "&cPVP STARTED" situation-duration: "&eThis PvP situation lasts {minutes} minutes. &7After that, a new RP interaction is needed." @@ -58,8 +73,20 @@ messages: ended-subtitle: "&eA new RP interaction is needed" start-cancelled: "&7The PvP start was cancelled." no-situation: "&cYou have no PvP situation to end." - lethal: "&aThis character is now in lethal PvP." + # Shown by /pvp lethal before it is confirmed. Use \n for new lines. + lethal-warning: "&c&l⚠ Lethal PvP\n&7In a &f/pvp start &7fight, anyone you kill gets a strike at once. You decided not to spare anyone when you turned lethal on, so you can't spare, wound or maim them.\n&7If it's their last strike, or they're in an evil RP session, their character dies and only you can open their grave.\n&7Players you kill outside &f/pvp start &7fights get no strike.\n&7Lethal lasts until you log out, switch character or run &f/pvp nonlethal&7." + lethal-confirm-button: "&c&l[Confirm]" + lethal-confirm-hover: "&7Turn on lethal PvP for this character" + # {seconds} = lethal-confirm-seconds + lethal-confirm-prompt: "&7Click &c[Confirm] &7or run &f/pvp lethal confirm &7within {seconds} seconds." + lethal-confirm-none: "&cRun &f/pvp lethal &cfirst, then confirm within {seconds} seconds." + already-lethal: "&7This character is already in lethal PvP." + lethal: "&cThis character is now in lethal PvP. &7It goes back to nonlethal when you log out, switch character or run &f/pvp nonlethal&7." nonlethal: "&aThis character is now in nonlethal PvP." + # Shown (at most once a second) when a knocked out player tries to fight or use an item. + downed-blocked: "&cYou're knocked out. You can still talk, but you can't do that." + # Sent to a player struck by a lethal kill. {killer} = the killer's display name. + auto-strike-victim: "&c{killer} &cwas in lethal PvP, so their kill gave you a strike." usage: "&7Usage: /pvp start | end | lethal | nonlethal" no-character: "&cYou need an active character to set PvP mode." current: "&7This character's PvP mode: {mode}" diff --git a/src/main/resources/tutorials.yml b/src/main/resources/tutorials.yml index 0d55b7c4..7a270d2a 100644 --- a/src/main/resources/tutorials.yml +++ b/src/main/resources/tutorials.yml @@ -17,25 +17,27 @@ tutorials: # Shown when an evil RP session starts. {minutes} is the session length, # {strikes} the character's current strikes. - evil-rp: + evil-rp-lethal: lines: - "#d65c5cEvil RP" - "§7You've started an evil RP session. It lasts {minutes} minutes." - "§7Picking a lock, robbing, pickpocketing or looting a locked grave sets it back to {minutes}." - - "§7If you're killed or knocked out in a §c/pvp start §7fight before it runs out, your killer can spare, wound, maim or kill you." - "§7During a session any strike kills your character, no matter how many strikes you have ({strikes} so far)." - - "§7If another player kills you, your grave is left unlocked and anyone can loot it." + - "§7If a §clethal §7player kills you in a §c/pvp start §7fight before it runs out, your character dies and only they can open your grave." + - "§7If a §anonlethal §7player knocks you out in one, they can spare, wound, maim or kill you." - "§7You can't switch characters until it's over. Check with §a/rpcharacter strikes§7." - # Shown to everyone caught in a /pvp start. {seconds} is how long a killer has to choose. - # {cooldown} is that wait: "24 hours", "1 hour", or "no extra time" when the cooldown is off. - pvp-strikes: + # Shown to everyone caught in a /pvp start. {time} is how long a knockout winner has + # to choose ({seconds} is the same in seconds). {cooldown} is the wait before the same + # player can strike again: "24 hours", "1 hour", or "no extra time" when it is off. + pvp-modes: lines: - "#d65c5cPvP Strikes" - - "§7Someone started a fight near you. If another player kills you or knocks you out in it, they decide your fate." - - "§7They have {seconds} seconds to §aspare §7you or give you a §cstrike§7. If they don't choose, you're spared." - - "§7First strike is a healing injury, the second a permanent one. The third kills your character." + - "§7Someone started a fight near you. What happens if you lose depends on the winner's PvP mode (§a/pvp§7). Everyone starts §anonlethal§7." + - "§7If a §clethal §7player kills you, you get a §cstrike §7at once. You keep your items and wake at your spawn point, unless they can loot graves." + - "§7If a §anonlethal §7player knocks you out, you stay down for up to {time} while they decide. You can talk, but you can't fight or use items. They can §aspare §7you or give you a §cstrike§7. If they don't choose, you're spared." + - "§7First strike is a healing injury, the second a permanent one. The third kills your character, and only your killer can open your grave." - "§7The same player must wait {cooldown} before striking you again." - - "§7When a strike would kill you, they can also §ewound §7(healing injury) or §6maim §7(permanent injury) you instead, with no strike." - - "§7In an evil RP session any strike kills, and if a player kills you your grave is left unlocked." + - "§7When a strike would kill you, a nonlethal winner can also §ewound §7(healing injury) or §6maim §7(permanent injury) you instead, with no strike." + - "§7In an evil RP session any strike kills." - "§7Deaths with no player to blame don't count. Check your strikes with §a/rpcharacter strikes§7." diff --git a/src/test/java/net/tfminecraft/rpcharacters/database/CharacterFieldsRuntimeTest.java b/src/test/java/net/tfminecraft/rpcharacters/database/CharacterFieldsRuntimeTest.java index a99fb683..875cd178 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/database/CharacterFieldsRuntimeTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/database/CharacterFieldsRuntimeTest.java @@ -112,7 +112,7 @@ class CharacterFieldsRuntimeTest { var account = new PlayerData(UUID.randomUUID()); account.setTutorialDismissed("existing", true); PlayerTutorialFields.load(null, json(Map.of())); PlayerTutorialFields.load(account, null); assertTrue(account.hasDismissedTutorial("existing")); - PlayerTutorialFields.load(account, json(Map.of("dismissed-tutorials", Arrays.asList("EVIL-RP", null, " "), + PlayerTutorialFields.load(account, json(Map.of("dismissed-tutorials", Arrays.asList("EVIL-RP-LETHAL", null, " "), "permadeath-tutorial-dismissed", true))); assertEquals(Set.of("existing", TutorialService.EVIL_RP, TutorialService.PERMADEATH_ZONE), account.getDismissedTutorials()); var saved = new HashMap(); PlayerTutorialFields.save(saved, account); diff --git a/src/test/java/net/tfminecraft/rpcharacters/database/DatabaseLoadTest.java b/src/test/java/net/tfminecraft/rpcharacters/database/DatabaseLoadTest.java index ef6d6a8d..478758d7 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/database/DatabaseLoadTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/database/DatabaseLoadTest.java @@ -124,7 +124,7 @@ class DatabaseLoadTest extends DatabaseFixture { @Test void optionalMalformedShapesDoNotDiscardOtherwiseValidCharacters() throws Exception { var shapes=List.of( obj("conversations","invalid","conversation-last-at","invalid","last-location","invalid","trait-state","invalid","extra-attribute-allocation","invalid","kit-statuses","invalid","kit-customisations","invalid","name-colour","invalid","pvp-lethal",null), - obj("last-location",obj("world"," ","x",1,"y",2,"z",3),"pvp-lethal","false","kit-status","eligible"), + obj("last-location",obj("world"," ","x",1,"y",2,"z",3),"pvp-lethal","true","kit-status","eligible"), obj("last-location",obj("world","world","x","bad","y",2,"z",3),"last-kit-claims","invalid")); int index=0; for(var extra:shapes) {var json=characterJson("shape"+index); json.putAll(extra); write(characterFile("shape"+index++),json);} var data=new PlayerData(owner); db.loadCharacters(data); assertEquals(3,data.getCharacters().size()); diff --git a/src/test/java/net/tfminecraft/rpcharacters/database/DatabasePersistenceTest.java b/src/test/java/net/tfminecraft/rpcharacters/database/DatabasePersistenceTest.java index 219456b6..b199f263 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/database/DatabasePersistenceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/database/DatabasePersistenceTest.java @@ -24,15 +24,15 @@ class DatabasePersistenceTest extends DatabaseFixture { write(playerFile,richPlayerJson()); write(characterFile("alpha"),richCharacterJson("alpha")); var loaded=db.loadPlayer(player); assertNotNull(loaded); var character=loaded.getCharacters().getFirst(); loaded.takePendingMmoAttributeRemoves(); // The successful login already consumed the previous creation layer. - character.setPaidChangeCount("class",2); character.setStageRevision("class",3,1700000000L); + character.setPaidChangeCount("class",2); character.setPvpLethal(true); character.setStageRevision("class",3,1700000000L); try(var integrations=mockConstruction(Integrator.class,(integrator,context) -> when(integrator.getRemoveList(player,character)).thenReturn(List.of("strength.4","dexterity.2")))) { db.savePlayer(loaded); assertEquals(1,integrations.constructed().size()); } JSONObject playerJson=read(playerFile),characterJson=read(characterFile("alpha")); assertEquals("true",playerJson.get("eighteen")); assertEquals(1234567890123L,playerJson.get("last-character-switch-ms")); assertEquals(array("strength.4","dexterity.2"),playerJson.get("to remove")); assertEquals(12,((Number)playerJson.get("account-skill-points-total")).intValue()); assertEquals(6,((Number)playerJson.get("account-attribute-points-total")).intValue()); assertEquals(3,((Number)((JSONObject)playerJson.get("account-profession-points")).get("forager")).intValue()); - assertEquals("alpha",characterJson.get("id")); assertEquals("Aria",characterJson.get("name")); assertEquals(77,((Number)characterJson.get("online-playtime-seconds")).intValue()); assertEquals("false",characterJson.get("mail-listed")); assertEquals("false",characterJson.get("pvp-lethal")); - var fresh=new Database().loadPlayer(player); assertNotNull(fresh); var restored=fresh.getCharacters().getFirst(); + assertEquals("alpha",characterJson.get("id")); assertEquals("Aria",characterJson.get("name")); assertEquals(77,((Number)characterJson.get("online-playtime-seconds")).intValue()); assertEquals("false",characterJson.get("mail-listed")); assertFalse(characterJson.containsKey("pvp-lethal"),"Lethal PvP is never saved"); + var fresh=new Database().loadPlayer(player); assertNotNull(fresh); var restored=fresh.getCharacters().getFirst(); assertFalse(restored.isPvpLethal()); assertEquals(character.getId(),restored.getId()); assertEquals(character.getRace().getId(),restored.getRace().getId()); assertEquals(character.getConversationCounts(),restored.getConversationCounts()); assertEquals(character.getConversationLastAtMs(),restored.getConversationLastAtMs()); assertEquals(character.getNameColour().getHexCodes(),restored.getNameColour().getHexCodes()); assertEquals(character.getPersonaDescription(),restored.getPersonaDescription()); assertEquals(character.getExtraAttributeAllocation(),restored.getExtraAttributeAllocation()); assertEquals(character.getProfessionUpgrades(),restored.getProfessionUpgrades()); assertEquals(character.getForfeitedProfessionPoints(),restored.getForfeitedProfessionPoints()); assertEquals(character.getLastLocationX(),restored.getLastLocationX()); assertEquals(20D,restored.getFuel("fuel")); assertEquals(2,restored.getPaidChangeCount("class")); assertEquals(3,restored.getStageRevision("class")); assertEquals(123,restored.getFoodValue()); playtime.verify(() -> CharacterPlaytimeDirectory.upsert(owner,character)); diff --git a/src/test/java/net/tfminecraft/rpcharacters/database/EvilRpPersistenceTest.java b/src/test/java/net/tfminecraft/rpcharacters/database/EvilRpPersistenceTest.java index ac6ea54e..5b65b9c0 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/database/EvilRpPersistenceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/database/EvilRpPersistenceTest.java @@ -110,15 +110,15 @@ void oldPermadeathDismissalCarriesOver() { @Test void dismissedTutorialsRoundTrip() { PlayerData pd = new PlayerData(UUID.randomUUID()); - pd.setTutorialDismissed("Evil-RP", true); + pd.setTutorialDismissed("Evil-RP-Lethal", true); HashMap saved = new HashMap<>(); PlayerTutorialFields.save(saved, pd); - assertEquals(List.of("evil-rp"), saved.get("dismissed-tutorials")); + assertEquals(List.of("evil-rp-lethal"), saved.get("dismissed-tutorials")); PlayerData loaded = new PlayerData(UUID.randomUUID()); JSONArray ids = new JSONArray(); - ids.add("evil-rp"); + ids.add("evil-rp-lethal"); PlayerTutorialFields.load(loaded, new JSONObject(Map.of("dismissed-tutorials", ids))); assertTrue(loaded.hasDismissedTutorial(TutorialService.EVIL_RP)); } diff --git a/src/test/java/net/tfminecraft/rpcharacters/grave/GraveEventsTest.java b/src/test/java/net/tfminecraft/rpcharacters/grave/GraveEventsTest.java index 3e2d551b..e5fdf2f4 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/grave/GraveEventsTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/grave/GraveEventsTest.java @@ -40,6 +40,7 @@ PlayerDeathEvent death(int experience) { } PlayerRespawnEvent respawn() { PlayerRespawnEvent event = mock(PlayerRespawnEvent.class); when(event.getPlayer()).thenReturn(player); when(event.getRespawnLocation()).thenReturn(player.getLocation()); return event; } Grave newest() { return manager.findNewestByOwner(player.getUniqueId()); } + Grave nextDeath(GraveDeathListener listener, Grave previous) { try { Thread.sleep(5); } catch (InterruptedException e) { throw new AssertionError(e); } listener.onPlayerDeath(death(0)); Grave grave = newest(); assertNotSame(previous, grave); return grave; } @Test void deathDoesNothingWhenDisabledKeptOrThereIsNoLoot() { GraveDeathListener listener = new GraveDeathListener(); PlayerDeathEvent event = death(0); @@ -140,7 +141,7 @@ PlayerDeathEvent death(int experience) { } @Test void pvpVictimsKeepTheirInventoryOnlyAfterPouchDropsAndOnlyForUncancelledDeaths() { - PlayerMock killer = server.addPlayer("Killer"); strikes.when(() -> PvpStrikeService.graveContext(eq(player), anyBoolean())).thenReturn(new PvpStrikeService.GraveContext(true, false, killer.getUniqueId())); + PlayerMock killer = server.addPlayer("Killer"); strikes.when(() -> PvpStrikeService.graveContext(eq(player), anyBoolean())).thenReturn(new PvpStrikeService.GraveContext(true, false, killer.getUniqueId(), false)); GraveDeathListener listener = new GraveDeathListener(); PlayerDeathEvent event = death(7); listener.onPlayerDeath(event); verify(event, never()).setKeepInventory(true); assertNull(newest()); listener.keepItemsAfterPouchDrop(event); verify(event).setKeepInventory(true); assertTrue(event.getDrops().isEmpty()); listener.keepItemsAfterPouchDrop(event); verify(event, times(1)).setKeepInventory(true); @@ -149,13 +150,46 @@ PlayerDeathEvent death(int experience) { } @Test void evilPvpVictimLeavesUnlockedGraveAndGetsTheCorrectNotice() { - PlayerMock killer = server.addPlayer("Killer"); strikes.when(() -> PvpStrikeService.graveContext(eq(player), anyBoolean())).thenReturn(new PvpStrikeService.GraveContext(true, true, killer.getUniqueId())); + PlayerMock killer = server.addPlayer("Killer"); strikes.when(() -> PvpStrikeService.graveContext(eq(player), anyBoolean())).thenReturn(new PvpStrikeService.GraveContext(true, true, killer.getUniqueId(), false)); identities.when(() -> DisplayIdentityService.resolveDisplay(killer)).thenReturn("§cBandit"); player.getInventory().setItem(0, new ItemStack(Material.DIAMOND)); GraveDeathListener listener = new GraveDeathListener(); listener.onPlayerDeath(death(1)); assertFalse(newest().isLocked()); assertEquals(killer.getUniqueId(), newest().getKiller()); assertEquals("Killed by Bandit", newest().getKillerDisplay()); listener.onPlayerRespawn(respawn()); assertTrue(player.nextMessage().contains("graves")); assertEquals("§cpublic", player.nextMessage()); } + @Test void aStrikeThatKillsTheCharacterLeavesAGraveOnlyTheKillerCanOpen() { + PlayerMock killer = server.addPlayer("Killer"); strikes.when(() -> PvpStrikeService.graveContext(eq(player), anyBoolean())).thenReturn(new PvpStrikeService.GraveContext(true, true, killer.getUniqueId(), true)); + settings.when(GraveLoader::getMessageKillerOnly).thenReturn("&ckiller only"); player.getInventory().setItem(0, new ItemStack(Material.DIAMOND)); + GraveDeathListener listener = new GraveDeathListener(); PlayerDeathEvent event = death(3); listener.onPlayerDeath(event); + Grave grave = newest(); assertNotNull(grave); + assertTrue(grave.isKillerOnly()); assertTrue(grave.isLocked(), "An evil victim's strike death is not left open to everyone"); assertEquals(killer.getUniqueId(), grave.getKiller()); + assertTrue(event.getDrops().isEmpty()); listener.keepItemsAfterPouchDrop(event); verify(event, never()).setKeepInventory(true); + listener.onPlayerRespawn(respawn()); assertTrue(player.nextMessage().contains("graves")); assertEquals("§ckiller only", player.nextMessage()); + } + + @Test void killerOnlyGravesBindNoInsuranceAndRevertWhenThePermakillIsCancelled() { + PlayerMock killer = server.addPlayer("Killer"); strikes.when(() -> PvpStrikeService.graveContext(eq(player), anyBoolean())).thenReturn(new PvpStrikeService.GraveContext(true, false, killer.getUniqueId(), true)); + player.getInventory().setItem(0, new ItemStack(Material.DIAMOND)); player.getInventory().setItem(1, new ItemStack(Material.PAPER)); + GraveDeathListener listener = new GraveDeathListener(); listener.onPlayerDeath(death(0)); Grave grave = newest(); assertTrue(grave.isKillerOnly()); + player.getInventory().clear(); listener.onPlayerRespawn(respawn()); + assertNull(GraveInsuranceTickets.boundGrave(player.getInventory().getItem(1)), "A ticket can't insure a grave its owner can't open"); + // Respawning before the strike settled forgets the grave: a late settle changes nothing. + KillerOnlyGraves.settle(player.getUniqueId(), false); assertTrue(grave.isKillerOnly()); + player.getInventory().setItem(0, new ItemStack(Material.EMERALD)); Grave second = nextDeath(listener, grave); assertTrue(second.isKillerOnly()); + KillerOnlyGraves.settle(player.getUniqueId(), false); assertFalse(second.isKillerOnly(), "A cancelled permakill hands the grave back to its owner"); assertTrue(second.isLocked()); + KillerOnlyGraves.settle(player.getUniqueId(), false); + player.getInventory().setItem(0, new ItemStack(Material.GOLD_INGOT)); Grave third = nextDeath(listener, second); + KillerOnlyGraves.settle(player.getUniqueId(), true); assertTrue(third.isKillerOnly()); + player.getInventory().setItem(0, new ItemStack(Material.IRON_INGOT)); Grave fourth = nextDeath(listener, third); + manager.despawn(fourth); assertDoesNotThrow(() -> KillerOnlyGraves.settle(player.getUniqueId(), false)); + } + + @Test void aDeathPredictedAsAKillWithoutAnotherPlayerKeepsTheNormalGrave() { + strikes.when(() -> PvpStrikeService.graveContext(eq(player), anyBoolean())).thenReturn(new PvpStrikeService.GraveContext(true, false, null, true)); + player.getInventory().setItem(0, new ItemStack(Material.DIAMOND)); new GraveDeathListener().onPlayerDeath(death(0)); + assertFalse(newest().isKillerOnly()); assertTrue(newest().isLocked()); + } + @Test void splitInsuranceTicketIsReturnedOneTickAfterRespawn() { player.getInventory().setItem(0, new ItemStack(Material.DIAMOND)); player.getInventory().setItem(1, new ItemStack(Material.PAPER, 3)); GraveDeathListener listener = new GraveDeathListener(); listener.onPlayerDeath(death(0)); UUID id = newest().getId(); @@ -366,7 +400,7 @@ abstract class GraveRuntimeFixture { settings.when(GraveLoader::getMessageInsuranceBound).thenReturn("&ainsured"); settings.when(GraveLoader::getInsuranceBoundLore).thenReturn("&7{world} {x},{y},{z}"); settings.when(GraveLoader::getMessageInsuranceNone).thenReturn("&enone"); settings.when(GraveLoader::getMessageInsuranceGone).thenReturn("&egone"); settings.when(GraveLoader::getMessageLocked).thenReturn("&clocked"); settings.when(GraveLoader::getMessageEmpty).thenReturn("&7empty"); settings.when(GraveLoader::getMessageRecovered).thenReturn("&arecovered"); settings.when(GraveLoader::getMessageLooted).thenReturn("&alooted"); settings.when(GraveLoader::getMessageInventoryFull).thenReturn("&efull"); - protocol=mockStatic(ProtocolLibBridge.class); strikes=mockStatic(PvpStrikeService.class); strikes.when(() -> PvpStrikeService.graveContext(any(),anyBoolean())).thenReturn(new PvpStrikeService.GraveContext(false,false,null)); + protocol=mockStatic(ProtocolLibBridge.class); strikes=mockStatic(PvpStrikeService.class); strikes.when(() -> PvpStrikeService.graveContext(any(),anyBoolean())).thenReturn(new PvpStrikeService.GraveContext(false,false,null,false)); starts=mockStatic(PvpStartSessions.class); players=mockStatic(PlayerManager.class); identities=mockStatic(DisplayIdentityService.class); } @AfterEach void restoreRuntime() throws Exception { diff --git a/src/test/java/net/tfminecraft/rpcharacters/grave/GraveLoaderTest.java b/src/test/java/net/tfminecraft/rpcharacters/grave/GraveLoaderTest.java index 3fda6d48..fcf7851d 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/grave/GraveLoaderTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/grave/GraveLoaderTest.java @@ -44,7 +44,7 @@ class GraveLoaderTest { assertTrue(GraveLoader.getExcludedSlots().isEmpty()); assertTrue(GraveLoader.isInsuranceEnabled()); assertTrue(GraveLoader.isInsuranceConsume()); assertEquals("&7Bound to your grave at &f{x}, {y}, {z}", GraveLoader.getInsuranceBoundLore()); assertEquals("&cThis grave is locked.", GraveLoader.getMessageLocked()); - assertEquals(15, messages().size()); assertTrue(messages().values().stream().allMatch(value -> value != null && !value.isBlank())); + assertEquals(17, messages().size()); assertTrue(messages().values().stream().allMatch(value -> value != null && !value.isBlank())); } @Test void configuredSettingsAndEveryMessageAreExposedWithoutLosingPlaceholders() throws Exception { @@ -132,6 +132,8 @@ static Map messages() { messages.put("insurance-gone", GraveLoader.getMessageInsuranceGone()); messages.put("insurance-bound", GraveLoader.getMessageInsuranceBound()); messages.put("unlocked-evil-rp", GraveLoader.getMessageUnlockedByStrike()); messages.put("unlock-none", GraveLoader.getMessageUnlockNone()); messages.put("unlock-already", GraveLoader.getMessageUnlockAlready()); messages.put("unlock-success", GraveLoader.getMessageUnlockSuccess()); + messages.put("killer-only", GraveLoader.getMessageKillerOnly()); messages.put("unlock-killer-only", GraveLoader.getMessageUnlockKillerOnly()); + messages.put("killer-only", GraveLoader.getMessageKillerOnly()); messages.put("unlock-killer-only", GraveLoader.getMessageUnlockKillerOnly()); messages.put("rob-hint", GraveLoader.getMessageRobHint()); return messages; } } diff --git a/src/test/java/net/tfminecraft/rpcharacters/grave/GraveLootRulesTest.java b/src/test/java/net/tfminecraft/rpcharacters/grave/GraveLootRulesTest.java index f2fe6cb6..fb2d6a34 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/grave/GraveLootRulesTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/grave/GraveLootRulesTest.java @@ -78,6 +78,26 @@ void lockedGraveCanStillBeRobbedByItsKiller() { } } + @Test + void killerOnlyGraveOpensForItsKillerAndAdminsButNotItsOwner() { + UUID ownerId = UUID.randomUUID(); + UUID killerId = UUID.randomUUID(); + Player owner = player(ownerId); + Player killer = player(killerId); + Player admin = player(UUID.randomUUID()); + when(admin.hasPermission("rpchar.grave.admin")).thenReturn(true); + Grave grave = grave(ownerId, killerId, false); + grave.setKillerOnly(true); + assertTrue(grave.isKillerOnly()); + assertTrue(GraveLootRules.canRecover(killer, grave)); + assertTrue(GraveLootRules.canRecover(admin, grave)); + assertFalse(GraveLootRules.canRecover(owner, grave), "The dead character's owner can't take the loot back"); + assertFalse(GraveLootRules.canSteal(killer, grave)); + Grave noKiller = grave(ownerId, null, true); + noKiller.setKillerOnly(true); + assertFalse(GraveLootRules.canRecover(killer, noKiller)); + } + private static Player player(UUID id) { Player player = mock(Player.class); when(player.getUniqueId()).thenReturn(id); diff --git a/src/test/java/net/tfminecraft/rpcharacters/grave/GraveManagerTest.java b/src/test/java/net/tfminecraft/rpcharacters/grave/GraveManagerTest.java index 7384643d..e9154fdf 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/grave/GraveManagerTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/grave/GraveManagerTest.java @@ -145,14 +145,14 @@ ItemStack namedItem() { @Test void saveAndLoadPreserveItemMetadataOwnershipFlagsAndExperience() throws Exception { Grave grave = grave(4, 64, 8); grave.getBlockLocation().getBlock().setType(Material.CHEST); - UUID killer = UUID.randomUUID(); grave.setKiller(killer); grave.setKillerDisplay("Kïller\n\\n"); grave.setProtected(true); grave.setLocked(false); + UUID killer = UUID.randomUUID(); grave.setKiller(killer); grave.setKillerDisplay("Kïller\n\\n"); grave.setProtected(true); grave.setLocked(false); grave.setKillerOnly(true); grave.setExperience(31); grave.setHologramId(UUID.randomUUID()); ItemStack sword = namedItem(); grave.setItem(0, sword); grave.setItem(36, new ItemStack(Material.DIAMOND_BOOTS)); grave.setItem(40, new ItemStack(Material.SHIELD)); grave.addExtra(new ItemStack(Material.EMERALD, 3)); - manager.save(grave); JsonObject json = read(grave); assertEquals(killer.toString(), json.get("killer").getAsString()); + manager.save(grave); JsonObject json = read(grave); assertEquals(killer.toString(), json.get("killer").getAsString()); assertTrue(json.get("killerOnly").getAsBoolean()); assertFalse(json.has("hologramUuid")); assertEquals(36, json.getAsJsonArray("storage").size()); assertEquals(4, json.getAsJsonArray("armor").size()); manager.loadAll(); Grave loaded = manager.getById(grave.getId()); assertNotNull(loaded); assertNotSame(grave, loaded); assertEquals(player.getUniqueId(), loaded.getOwner()); assertEquals(killer, loaded.getKiller()); assertEquals("Kïller\n\\n", loaded.getKillerDisplay()); - assertTrue(loaded.isProtected()); assertFalse(loaded.isLocked()); assertEquals(31, loaded.getExperience()); assertEquals(grave.getCreated(), loaded.getCreated()); + assertTrue(loaded.isProtected()); assertFalse(loaded.isLocked()); assertTrue(loaded.isKillerOnly()); assertEquals(31, loaded.getExperience()); assertEquals(grave.getCreated(), loaded.getCreated()); assertEquals(grave.getBlockLocation(), loaded.getBlockLocation()); assertEquals(sword, loaded.getItem(0)); assertEquals("kept", loaded.getItem(0).getItemMeta().getPersistentDataContainer().get(new NamespacedKey("test", "marker"), PersistentDataType.STRING)); assertEquals(Material.DIAMOND_BOOTS, loaded.getItem(36).getType()); assertEquals(Material.SHIELD, loaded.getItem(40).getType()); assertEquals(3, loaded.getExtras().getFirst().getAmount()); diff --git a/src/test/java/net/tfminecraft/rpcharacters/grave/GraveVisualServicesTest.java b/src/test/java/net/tfminecraft/rpcharacters/grave/GraveVisualServicesTest.java index a6e808d5..7a3b2183 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/grave/GraveVisualServicesTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/grave/GraveVisualServicesTest.java @@ -200,6 +200,8 @@ FakeTextDisplayPackets readyPackets() { settings.when(GraveLoader::getMessageUnlockNone).thenReturn("none"); settings.when(GraveLoader::getMessageUnlockAlready).thenReturn("already"); settings.when(GraveLoader::getMessageUnlockSuccess).thenReturn("success"); executor.onCommand(player,command,"grave",new String[]{"unlock"}); assertEquals("none",player.nextMessage()); Grave grave=grave(); manager.register(grave); executor.onCommand(player,command,"grave",new String[]{"UNLOCK"}); assertFalse(grave.isLocked()); assertEquals("success",player.nextMessage()); executor.onCommand(player,command,"grave",new String[]{"unlock"}); assertEquals("already",player.nextMessage()); + settings.when(GraveLoader::getMessageUnlockKillerOnly).thenReturn("killer only"); grave.setLocked(true); grave.setKillerOnly(true); + executor.onCommand(player,command,"grave",new String[]{"unlock"}); assertEquals("killer only",player.nextMessage()); assertTrue(grave.isLocked()); assertEquals(List.of("unlock"),executor.onTabComplete(player,command,"grave",new String[]{"UN"})); assertTrue(executor.onTabComplete(player,command,"grave",new String[]{"x"}).isEmpty()); assertTrue(executor.onTabComplete(player,command,"grave",new String[0]).isEmpty()); } diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/KnockoutRestrictionsTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/KnockoutRestrictionsTest.java new file mode 100644 index 00000000..f79342a6 --- /dev/null +++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/KnockoutRestrictionsTest.java @@ -0,0 +1,180 @@ +package net.tfminecraft.rpcharacters.pvp; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.util.List; +import java.util.UUID; +import java.util.function.Consumer; + +import org.bukkit.entity.Arrow; +import org.bukkit.entity.Player; +import org.bukkit.entity.Zombie; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityShootBowEvent; +import org.bukkit.event.entity.ProjectileLaunchEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.event.player.PlayerItemConsumeEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.bukkit.event.player.PlayerSwapHandItemsEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import io.lumine.mythic.lib.api.event.skill.PlayerCastSkillEvent; + +class KnockoutRestrictionsTest { + + Player downed, standing; + KnockoutRestrictions restrictions; + + @BeforeEach void setup() { + downed = player(); + standing = player(); + restrictions = new KnockoutRestrictions(player -> player == downed); + } + + @Test void aDownedPlayerCannotHitShootOrCastButOthersCan() { + var hit = mock(EntityDamageByEntityEvent.class); + when(hit.getDamager()).thenReturn(downed); + when(hit.getEntity()).thenReturn(standing); + restrictions.onAttack(hit); + verify(hit).setCancelled(true); + var hitBack = mock(EntityDamageByEntityEvent.class); + when(hitBack.getDamager()).thenReturn(standing); + when(hitBack.getEntity()).thenReturn(downed); + restrictions.onAttack(hitBack); + verify(hitBack, never()).setCancelled(true); + // Their own fall or fire damage is not an attack. + var self = mock(EntityDamageByEntityEvent.class); + when(self.getDamager()).thenReturn(downed); + when(self.getEntity()).thenReturn(downed); + restrictions.onAttack(self); + verify(self, never()).setCancelled(true); + var environment = mock(EntityDamageByEntityEvent.class); + when(environment.getDamager()).thenReturn(mock(Zombie.class)); + restrictions.onAttack(environment); + verify(environment, never()).setCancelled(true); + + var bow = mock(EntityShootBowEvent.class); + when(bow.getEntity()).thenReturn(downed); + restrictions.onShootBow(bow); + verify(bow).setCancelled(true); + var mobBow = mock(EntityShootBowEvent.class); + when(mobBow.getEntity()).thenReturn(mock(Zombie.class)); + restrictions.onShootBow(mobBow); + verify(mobBow, never()).setCancelled(true); + + Arrow arrow = mock(Arrow.class); + when(arrow.getShooter()).thenReturn(downed); + var launch = mock(ProjectileLaunchEvent.class); + when(launch.getEntity()).thenReturn(arrow); + restrictions.onLaunch(launch); + verify(launch).setCancelled(true); + Arrow mobArrow = mock(Arrow.class); + when(mobArrow.getShooter()).thenReturn(mock(Zombie.class)); + var mobLaunch = mock(ProjectileLaunchEvent.class); + when(mobLaunch.getEntity()).thenReturn(mobArrow); + restrictions.onLaunch(mobLaunch); + verify(mobLaunch, never()).setCancelled(true); + + var cast = mock(PlayerCastSkillEvent.class); + when(cast.getPlayer()).thenReturn(downed); + restrictions.onCastSkill(cast); + verify(cast).setCancelled(true); + var otherCast = mock(PlayerCastSkillEvent.class); + when(otherCast.getPlayer()).thenReturn(standing); + restrictions.onCastSkill(otherCast); + verify(otherCast, never()).setCancelled(true); + } + + @Test void aDownedPlayerCannotUseEatDropOrBuild() { + List> actions = List.of( + player -> check(mock(PlayerInteractEntityEvent.class), player, restrictions::onInteractEntity), + player -> check(mock(PlayerItemConsumeEvent.class), player, restrictions::onConsume), + player -> check(mock(PlayerSwapHandItemsEvent.class), player, restrictions::onSwapHands), + player -> check(mock(PlayerDropItemEvent.class), player, restrictions::onDrop), + player -> check(mock(BlockPlaceEvent.class), player, restrictions::onPlace), + player -> check(mock(BlockBreakEvent.class), player, restrictions::onBreak)); + for (var action : actions) { + action.accept(downed); + action.accept(standing); + } + } + + @Test void clicksAreDeniedButPressurePlatesStillWork() { + var click = interact(downed, Action.RIGHT_CLICK_AIR); + restrictions.onInteract(click); + verify(click).setUseItemInHand(Event.Result.DENY); + verify(click).setUseInteractedBlock(Event.Result.DENY); + var plate = interact(downed, Action.PHYSICAL); + restrictions.onInteract(plate); + verify(plate, never()).setUseItemInHand(any()); + var standingClick = interact(standing, Action.RIGHT_CLICK_BLOCK); + restrictions.onInteract(standingClick); + verify(standingClick, never()).setUseItemInHand(any()); + } + + @Test void theNoticeIsSentAtMostOnceASecondAndResetsOnQuit() { + var first = interact(downed, Action.LEFT_CLICK_AIR); + restrictions.onInteract(first); + restrictions.onInteract(interact(downed, Action.RIGHT_CLICK_AIR)); + verify(downed, times(1)).sendMessage(contains("knocked out")); + var quit = mock(PlayerQuitEvent.class); + when(quit.getPlayer()).thenReturn(downed); + restrictions.onQuit(quit); + restrictions.onInteract(interact(downed, Action.RIGHT_CLICK_AIR)); + verify(downed, times(2)).sendMessage(contains("knocked out")); + verify(standing, never()).sendMessage(anyString()); + } + + @Test void theNoticeRepeatsOnceTheIntervalHasPassed() throws Exception { + restrictions.onInteract(interact(downed, Action.RIGHT_CLICK_AIR)); + var field = KnockoutRestrictions.class.getDeclaredField("lastNotice"); + field.setAccessible(true); + @SuppressWarnings("unchecked") + var notices = (java.util.Map) field.get(restrictions); + notices.put(downed.getUniqueId(), System.currentTimeMillis() - 1_000L); + restrictions.onInteract(interact(downed, Action.RIGHT_CLICK_AIR)); + verify(downed, times(2)).sendMessage(contains("knocked out")); + } + + private void check(E event, Player player, Consumer handler) { + stubPlayer(event, player); + handler.accept(event); + if (player == downed) { + verify(event).setCancelled(true); + } else { + verify(event, never()).setCancelled(true); + } + } + + private static void stubPlayer(Event event, Player player) { + if (event instanceof org.bukkit.event.player.PlayerEvent playerEvent) { + when(playerEvent.getPlayer()).thenReturn(player); + } else if (event instanceof BlockPlaceEvent place) { + when(place.getPlayer()).thenReturn(player); + } else if (event instanceof BlockBreakEvent breaking) { + when(breaking.getPlayer()).thenReturn(player); + } + } + + private static PlayerInteractEvent interact(Player player, Action action) { + var event = mock(PlayerInteractEvent.class); + when(event.getPlayer()).thenReturn(player); + when(event.getAction()).thenReturn(action); + return event; + } + + private static Player player() { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(UUID.randomUUID()); + return player; + } +} diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpCommandTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpCommandTest.java index ca26aa7b..be177f5b 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpCommandTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpCommandTest.java @@ -4,7 +4,10 @@ import static org.mockito.Mockito.*; import java.util.*; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.tfminecraft.rpcharacters.loaders.PvpLoader; +import net.tfminecraft.rpcharacters.objects.RPCharacter; +import net.tfminecraft.tlibs.character.CharacterSessionStartEvent; import org.bukkit.command.*; import org.bukkit.event.entity.PlayerDeathEvent; import org.bukkit.event.player.PlayerQuitEvent; @@ -19,13 +22,34 @@ class PvpCommandTest extends PvpRuntimeFixture { void start(){command("start");} void countdown(){runNext();runNext();runNext();} - @Test void commandRoutingUsageAndLethalityPersistTheActiveCharacter() { - when(command.getName()).thenReturn("other");assertFalse(handler.onCommand(victim,command,"pvp",new String[0]));when(command.getName()).thenReturn("PVP");var console=mock(CommandSender.class);assertTrue(handler.onCommand(console,command,"pvp",new String[0]));verify(console).sendMessage(contains("Players only"));command();assertTrue(text(victim).contains("nonlethal"));command("lethal");assertTrue(character.isPvpLethal());command("unknown");assertTrue(text(victim).contains("lethal"));command("nonlethal");assertFalse(character.isPvpLethal());verify(manager,times(2)).savePlayer(victim); - loaded.remove(victim);command();command("lethal");assertTrue(text(victim).contains("active character"));loaded.put(victim,data);doReturn(false).when(data).hasActiveCharacter();command("nonlethal");command();verify(manager,times(2)).savePlayer(victim); + @Test void commandRoutingUsageAndLethalModeNeedsAConfirmedWarningAndIsNeverSaved() { + when(command.getName()).thenReturn("other");assertFalse(handler.onCommand(victim,command,"pvp",new String[0]));when(command.getName()).thenReturn("PVP");var console=mock(CommandSender.class);assertTrue(handler.onCommand(console,command,"pvp",new String[0]));verify(console).sendMessage(contains("Players only"));command();assertTrue(text(victim).contains("nonlethal")); + command("lethal");assertFalse(character.isPvpLethal(),"Lethal needs a confirmation");assertTrue(text(victim).contains("Lethal PvP"));assertTrue(text(victim).contains("can't spare, wound or maim")); + var button=components.get(victim).getLast();assertEquals(Set.of("/pvp lethal confirm"),PvpStrikeServiceTest.commands(button));assertTrue(PlainTextComponentSerializer.plainText().serialize(button).contains("within 30 seconds")); + command("lethal","CONFIRM");assertTrue(character.isPvpLethal());assertTrue(text(victim).contains("now in lethal PvP"));command("lethal");assertTrue(text(victim).contains("already in lethal")); + command("unknown");assertTrue(text(victim).contains("lethal"));command("nonlethal");assertFalse(character.isPvpLethal()); + command("lethal","confirm");assertFalse(character.isPvpLethal());assertTrue(text(victim).contains("first, then confirm within 30 seconds")); + command("lethal");command("nonlethal");command("lethal","confirm");assertFalse(character.isPvpLethal(),"Going nonlethal drops an unconfirmed request");verify(manager,never()).savePlayer(victim); + loaded.remove(victim);messages.get(victim).clear();command();command("lethal");assertTrue(text(victim).contains("active character"));messages.get(victim).clear();command("lethal","confirm");assertTrue(text(victim).contains("active character"));messages.get(victim).clear();command("nonlethal");assertTrue(text(victim).contains("active character")); + loaded.put(victim,data);doReturn(false).when(data).hasActiveCharacter();command("nonlethal");command(); + } + + @Test void aConfirmationExpiresAndOnlyCoversTheCharacterThatWasWarned() { + config.when(PvpLoader::getLethalConfirmMs).thenReturn(-1L);command("lethal");command("lethal","confirm");assertFalse(character.isPvpLethal()); + config.when(PvpLoader::getLethalConfirmMs).thenReturn(60_000L);command("lethal");var switched=new RPCharacter(victim);data.addCharacter(switched);doReturn(switched).when(data).getActiveCharacter();command("lethal","confirm");assertFalse(switched.isPvpLethal());assertFalse(character.isPvpLethal()); + config.when(PvpLoader::getLethalWarning).thenReturn("one\\ntwo\nthree");messages.get(victim).clear();command("lethal");assertEquals(List.of("one","two","three"),messages.get(victim)); + } + + @Test void everyCharacterSessionStartsNonlethalAndQuittingDropsTheRequest() { + command("lethal");command("lethal","confirm");assertTrue(character.isPvpLethal()); + var session=mock(CharacterSessionStartEvent.class);when(session.getPlayer()).thenReturn(victim);handler.onSessionStart(session);assertFalse(character.isPvpLethal()); + command("lethal");handler.onSessionStart(session);command("lethal","confirm");assertFalse(character.isPvpLethal(),"A request from before the session started does not carry over"); + loaded.remove(victim);handler.onSessionStart(session);loaded.put(victim,data); + command("lethal");var quit=mock(PlayerQuitEvent.class);when(quit.getPlayer()).thenReturn(victim);handler.onQuit(quit);command("lethal","confirm");assertFalse(character.isPvpLethal()); } @Test void completionIsLocaleIndependentAndOnlyCompletesTheFirstArgument() { - Locale.setDefault(Locale.forLanguageTag("tr-TR"));assertEquals(List.of("start","end","lethal","nonlethal"),handler.onTabComplete(victim,command,"pvp",new String[]{""}));assertEquals(List.of("lethal"),handler.onTabComplete(victim,command,"pvp",new String[]{"L"}));assertTrue(handler.onTabComplete(victim,command,"pvp",new String[0]).isEmpty());assertTrue(handler.onTabComplete(victim,command,"pvp",new String[]{"start","x"}).isEmpty());assertTrue(handler.onTabComplete(victim,command,"pvp",new String[]{"missing"}).isEmpty()); + Locale.setDefault(Locale.forLanguageTag("tr-TR"));assertEquals(List.of("start","end","lethal","nonlethal"),handler.onTabComplete(victim,command,"pvp",new String[]{""}));assertEquals(List.of("lethal"),handler.onTabComplete(victim,command,"pvp",new String[]{"L"}));assertTrue(handler.onTabComplete(victim,command,"pvp",new String[0]).isEmpty());assertTrue(handler.onTabComplete(victim,command,"pvp",new String[]{"start","x"}).isEmpty());assertTrue(handler.onTabComplete(victim,command,"pvp",new String[]{"missing"}).isEmpty());assertEquals(List.of("confirm"),handler.onTabComplete(victim,command,"pvp",new String[]{"LETHAL","C"}));assertTrue(handler.onTabComplete(victim,command,"pvp",new String[]{"lethal","x"}).isEmpty());assertTrue(handler.onTabComplete(victim,command,"pvp",new String[]{"end","c"}).isEmpty()); } @Test void countdownIncludesOnlyNearbyPlayersAndStartsTimedSessions() { diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java index e0b30da4..9a09b696 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutManagerTest.java @@ -26,6 +26,7 @@ import org.junit.jupiter.params.provider.ValueSource; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.BeforeAll; +import net.tfminecraft.rpcharacters.loaders.PvpLoader; import net.tfminecraft.rpcharacters.managers.PlayerManager; import net.tfminecraft.rpcharacters.objects.PlayerData; import net.tfminecraft.rpcharacters.objects.RPCharacter; @@ -155,18 +156,84 @@ void killCommandVoidAndBorderStillKillDownedPlayer(String cause) { assertFalse(PvpKnockoutManager.downedPlayerSurvives(event)); } - @Test void mobsAndPlayersCanStillFinishDownedPlayer() { + @Test void mobsAndPlayersCannotFinishDownedPlayer() { var mobHit = mock(EntityDamageByEntityEvent.class); when(mobHit.getCause()).thenReturn(EntityDamageEvent.DamageCause.ENTITY_ATTACK); when(mobHit.getDamager()).thenReturn(mock(Entity.class)); - assertFalse(PvpKnockoutManager.downedPlayerSurvives(mobHit)); + assertTrue(PvpKnockoutManager.downedPlayerSurvives(mobHit)); var caused = mock(EntityDamageEvent.class); DamageSource source = mock(DamageSource.class); when(caused.getCause()).thenReturn(EntityDamageEvent.DamageCause.MAGIC); when(caused.getDamageSource()).thenReturn(source); Player attacker = player(); when(source.getCausingEntity()).thenReturn(attacker); - assertFalse(PvpKnockoutManager.downedPlayerSurvives(caused)); + assertTrue(PvpKnockoutManager.downedPlayerSurvives(caused)); + } + + @Test void downedPlayersTakeNoDamageBeforeOtherPluginsSeeIt() throws Exception { + Player victim = knockoutPlayer(); + var manager = new PvpKnockoutManager(); + var hit = mock(EntityDamageEvent.class); + when(hit.getEntity()).thenReturn(victim); + when(hit.getCause()).thenReturn(EntityDamageEvent.DamageCause.ENTITY_ATTACK); + manager.onDownedDamage(hit); + verify(hit, never()).setCancelled(true); + invoke(manager, "applyKnockout", victim); + manager.onDownedDamage(hit); + verify(hit).setCancelled(true); + var kill = mock(EntityDamageEvent.class); + when(kill.getEntity()).thenReturn(victim); + when(kill.getCause()).thenReturn(EntityDamageEvent.DamageCause.KILL); + manager.onDownedDamage(kill); + verify(kill, never()).setCancelled(true); + var mob = mock(EntityDamageEvent.class); + when(mob.getEntity()).thenReturn(mock(Entity.class)); + manager.onDownedDamage(mob); + verify(mob, never()).setCancelled(true); + var battle = mock(EntityDamageEvent.class); + when(battle.getEntity()).thenReturn(victim); + when(battle.getCause()).thenReturn(EntityDamageEvent.DamageCause.ENTITY_ATTACK); + try { + PermadeathBattleExemption.set(p -> p == victim); + manager.onDownedDamage(battle); + verify(battle, never()).setCancelled(true); + } finally { + PermadeathBattleExemption.set(null); + } + } + + @Test void aDecisionKeepsThePlayerDownUntilTheWinnerChooses() throws Exception { + Player victim = knockoutPlayer(); + var manager = new PvpKnockoutManager(); + try (var bukkit = mockStatic(Bukkit.class); var strikes = mockStatic(PvpStrikeService.class)) { + bukkit.when(() -> Bukkit.getPlayer(victim.getUniqueId())).thenReturn(victim); + strikes.when(() -> PvpStrikeService.isAwaitingDecision(victim.getUniqueId())).thenReturn(true); + invoke(manager, "applyKnockout", victim, true); + // Blind only for the first minute, however long the decision takes. + verify(victim).addPotionEffect(argThat(effect -> + effect.getType().equals(PotionEffectType.BLINDNESS) && effect.getDuration() == 1200)); + invoke(manager, "tick"); + assertTrue(manager.isDown(victim)); + strikes.when(() -> PvpStrikeService.isAwaitingDecision(victim.getUniqueId())).thenReturn(false); + assertFalse(manager.isDown(victim)); + invoke(manager, "tick"); + // Spared or struck before the blindness wore off: they can see again at once. + verify(victim).removePotionEffect(PotionEffectType.BLINDNESS); + } + } + + @Test void knockoutAfterTheBlindnessWoreOffLeavesOtherBlindnessAlone() throws Exception { + Player victim = knockoutPlayer(); + var manager = new PvpKnockoutManager(); + try (var bukkit = mockStatic(Bukkit.class); var config = mockStatic(PvpLoader.class, CALLS_REAL_METHODS)) { + bukkit.when(() -> Bukkit.getPlayer(victim.getUniqueId())).thenReturn(victim); + config.when(PvpLoader::getKnockoutBlindnessSeconds).thenReturn(0); + invoke(manager, "applyKnockout", victim); + verify(victim, never()).addPotionEffect(any()); + expire(manager, victim); + invoke(manager, "tick"); + verify(victim, never()).removePotionEffect(any()); + } } @Test void existingCrawlIsKeptAndInterruptedCrawlIsRestored() throws Exception { @@ -323,7 +390,17 @@ private void expire(PvpKnockoutManager manager, Player victim) throws Exception until.setLong(knockout, 0L); } + private void invoke(PvpKnockoutManager manager, String name, Player victim, boolean deciding) throws Exception { + var method = PvpKnockoutManager.class.getDeclaredMethod(name, Player.class, boolean.class); + method.setAccessible(true); + method.invoke(manager, victim, deciding); + } + private void invoke(PvpKnockoutManager manager, String name, Player... players) throws Exception { + if (name.equals("applyKnockout")) { + invoke(manager, name, players[0], false); + return; + } var method = players.length == 0 ? PvpKnockoutManager.class.getDeclaredMethod(name) : PvpKnockoutManager.class.getDeclaredMethod(name, Player.class); diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutRuntimeTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutRuntimeTest.java index 75193de3..f444f345 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutRuntimeTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpKnockoutRuntimeTest.java @@ -107,7 +107,7 @@ EntityDamageByEntityEvent hit(Entity target, double damage) { } @Test void largeConfiguredDurationCannotWrapBlindnessToANegativeDuration() throws Exception { - Path config = folder.resolve("pvp.yml"); Files.writeString(config, "knockout-seconds: 2147483647\n"); new PvpLoader().load(config.toFile()); + Path config = folder.resolve("pvp.yml"); Files.writeString(config, "knockout-seconds: 2147483647\nknockout-blindness-seconds: 2147483647\n"); new PvpLoader().load(config.toFile()); hit(victim, 30); assertEquals(Integer.MAX_VALUE, victim.getPotionEffect(PotionEffectType.BLINDNESS).getDuration()); } diff --git a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpStrikeServiceTest.java b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpStrikeServiceTest.java index 9b519114..f69457bc 100644 --- a/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpStrikeServiceTest.java +++ b/src/test/java/net/tfminecraft/rpcharacters/pvp/PvpStrikeServiceTest.java @@ -13,6 +13,7 @@ import net.kyori.adventure.text.Component; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import net.tfminecraft.rpcharacters.*; +import net.tfminecraft.rpcharacters.clues.discovery.ClueAdminModeService; import net.tfminecraft.rpcharacters.enums.Status; import net.tfminecraft.rpcharacters.evilrp.*; import net.tfminecraft.rpcharacters.identity.DisplayIdentityService; @@ -42,7 +43,7 @@ class PvpStrikeServiceTest extends PvpRuntimeFixture { @Test void onlyEligibleKnockoutsAndDeathsOfferDecisions() { assertFalse(PvpStrikeService.hasPendingDecision(null));PvpStrikeService.handleJoin(victim);assertTrue(delayed.isEmpty());assertFalse(PvpStrikeService.handleDeath(victim,false));PvpStrikeService.handleKnockout(victim,null);assertFalse(PvpStrikeService.hasPendingDecision(victim)); PvpStartSessions.begin(List.of(victim.getUniqueId()),System.currentTimeMillis(),60_000);battles.when(()->PermadeathBattleExemption.isInStartedBattle(victim)).thenReturn(true);PvpStrikeService.handleKnockout(victim,killer);assertFalse(PvpStrikeService.hasPendingDecision(victim));battles.when(()->PermadeathBattleExemption.isInStartedBattle(victim)).thenReturn(false); - loaded.remove(victim);assertFalse(PvpStrikeService.handleDeath(victim,true));loaded.put(victim,data);doReturn(false).when(data).hasActiveCharacter();assertFalse(PvpStrikeService.handleDeath(victim,true));doReturn(true).when(data).hasActiveCharacter();character.setStatus(Status.DEAD);assertFalse(PvpStrikeService.handleDeath(victim,true));character.setStatus(Status.ALIVE);assertTrue(PvpStrikeService.handleDeath(victim,true));assertTrue(PvpStrikeService.hasPendingDecision(victim)); + loaded.remove(victim);assertFalse(PvpStrikeService.handleDeath(victim,true));loaded.put(victim,data);doReturn(false).when(data).hasActiveCharacter();assertFalse(PvpStrikeService.handleDeath(victim,true));doReturn(true).when(data).hasActiveCharacter();character.setStatus(Status.DEAD);assertFalse(PvpStrikeService.handleDeath(victim,true));character.setStatus(Status.ALIVE);assertTrue(PvpStrikeService.handleDeath(victim,true));assertFalse(PvpStrikeService.hasPendingDecision(victim));assertEquals(1,character.getEvilRpStrikes()); } @Test void promptsExposeOnlyAllowedCommandsAndAccessibleHoverText() { @@ -176,6 +177,61 @@ class PvpStrikeServiceTest extends PvpRuntimeFixture { } finally {if(staged.get()!=null)Files.deleteIfExists(staged.get());} } + PlayerData nonlethalKiller(){var killerData=spy(new PlayerData(killer));var killerCharacter=new RPCharacter(killer);killerData.addCharacter(killerCharacter);doReturn(true).when(killerData).hasActiveCharacter();doReturn(killerCharacter).when(killerData).getActiveCharacter();loaded.put(killer,killerData);return killerData;} + + @Test void aLethalKillStrikesAtOnceWithoutAPromptAndKeepsTheVictimsCharacter() { + assertTrue(PvpStrikeService.handleDeath(victim,true));assertEquals(1,character.getEvilRpStrikes());assertEquals(Status.ALIVE,character.getStatus());assertFalse(PvpStrikeService.hasPendingDecision(victim));assertTrue(components.get(killer).isEmpty(),"A lethal killer already chose not to spare anyone"); + assertTrue(text(victim).contains("was in lethal PvP"));assertTrue(text(killer).contains("You struck"));evil.verify(()->EvilRpService.applyStrike(victim,character,killer,false)); + } + + @Test void aLethalKillOnTheLastStrikeKillsAndTheGraveIsForTheKillerOnly() { + character.setEvilRpStrikes(2);var grave=PvpStrikeService.graveContext(victim,true);assertTrue(grave.inPvpStart());assertTrue(grave.characterDies());assertNull(grave.killerId()); + assertTrue(PvpStrikeService.handleDeath(victim,true));assertEquals(Status.DEAD,character.getStatus());assertTrue(text(killer).contains("You killed"));assertTrue(components.get(killer).isEmpty()); + } + + @Test void anEvilVictimDiesFromAnyLethalKill() { + evil.when(()->EvilRpService.isInSession(character)).thenReturn(true);evil.when(()->EvilRpService.isInSession(victim)).thenReturn(true); + var grave=PvpStrikeService.graveContext(victim,true);assertTrue(grave.characterDies());assertTrue(grave.evilVictim()); + assertTrue(PvpStrikeService.handleDeath(victim,true));evil.verify(()->EvilRpService.killByStrike(victim,character,killer,false));assertEquals(Status.DEAD,character.getStatus()); + } + + @Test void theGraveOnlyExpectsADeathTheStrikeReallyCauses() { + character.setEvilRpStrikes(2);assertFalse(PvpStrikeService.graveContext(victim,false).characterDies()); + battles.when(()->PermadeathBattleExemption.isInStartedBattle(victim)).thenReturn(true);assertFalse(PvpStrikeService.graveContext(victim,true).characterDies());battles.when(()->PermadeathBattleExemption.isInStartedBattle(victim)).thenReturn(false); + var clues=boundary(ClueAdminModeService.class);clues.when(()->ClueAdminModeService.isEnabled(victim)).thenReturn(true);assertFalse(PvpStrikeService.graveContext(victim,true).characterDies());clues.when(()->ClueAdminModeService.isEnabled(victim)).thenReturn(false); + loaded.remove(victim);assertFalse(PvpStrikeService.graveContext(victim,true).characterDies());loaded.put(victim,data); + when(victim.getKiller()).thenReturn(null);assertFalse(PvpStrikeService.graveContext(victim,true).characterDies());when(victim.getKiller()).thenReturn(killer); + character.setStrikesByKiller(Map.of(killer.getUniqueId(),System.currentTimeMillis()));assertFalse(PvpStrikeService.graveContext(victim,true).characterDies());character.setStrikesByKiller(Map.of()); + character.setEvilRpStrikes(0);assertFalse(PvpStrikeService.graveContext(victim,true).characterDies());character.setEvilRpStrikes(2); + evil.when(()->EvilRpService.applyDecay(eq(character),anyLong())).thenReturn(true);assertTrue(PvpStrikeService.graveContext(victim,true).characterDies());verify(manager).savePlayer(victim); + nonlethalKiller();assertFalse(PvpStrikeService.graveContext(victim,true).characterDies()); + } + + @Test void aLethalKillDuringTheCooldownIsASpareWithoutTheStrikeNotice() { + character.setStrikesByKiller(Map.of(killer.getUniqueId(),System.currentTimeMillis()));assertTrue(PvpStrikeService.handleDeath(victim,true));assertEquals(0,character.getEvilRpStrikes());assertFalse(text(victim).contains("was in lethal PvP"));assertTrue(text(victim).contains("spared"));assertTrue(text(killer).contains("already struck")); + } + + @Test void aNonlethalKillerWhoseBlowStillKilledStillDecides() { + nonlethalKiller();doReturn(false).when(data).hasActiveCharacter();assertFalse(PvpStrikeService.handleDeath(victim,true));doReturn(true).when(data).hasActiveCharacter();assertTrue(PvpStrikeService.handleDeath(victim,true));assertTrue(PvpStrikeService.hasPendingDecision(victim));assertEquals(0,character.getEvilRpStrikes());assertFalse(text(victim).contains("stay down"),"Someone who already died isn't held down");assertTrue(text(victim).contains("5 minutes")); + } + + @Test void aKnockoutKeepsItsVictimDownOnlyWhileTheWinnerDecides() { + assertFalse(PvpStrikeService.handleKnockout(victim,killer),"No PvP start, no decision");assertFalse(PvpStrikeService.isAwaitingDecision(null)); + PvpStartSessions.begin(List.of(victim.getUniqueId()),System.currentTimeMillis(),60_000);assertTrue(PvpStrikeService.handleKnockout(victim,killer));assertTrue(PvpStrikeService.isAwaitingDecision(victim.getUniqueId()));assertTrue(text(victim).contains("you stay down"));assertTrue(text(victim).contains("5 minutes")); + assertTrue(PvpStrikeService.choose(killer,victim.getUniqueId(),StrikeChoice.STRIKE));assertFalse(PvpStrikeService.isAwaitingDecision(victim.getUniqueId())); + PvpStartSessions.begin(List.of(victim.getUniqueId()),System.currentTimeMillis(),60_000);assertFalse(PvpStrikeService.handleKnockout(victim,killer),"A spare for the cooldown leaves nothing to wait for"); + } + + @Test void aWinnerWhoLogsOutSparesTheirVictim() { + knockout();PvpStrikeService.handleQuit(other);assertTrue(PvpStrikeService.isAwaitingDecision(victim.getUniqueId())); + PvpStrikeService.handleQuit(killer);assertFalse(PvpStrikeService.isAwaitingDecision(victim.getUniqueId()));assertTrue(text(victim).contains("logged out before choosing"));assertEquals(0,character.getEvilRpStrikes()); + knockout();online.remove(victim.getUniqueId());messages.get(victim).clear();PvpStrikeService.handleQuit(killer);assertFalse(PvpStrikeService.hasPendingDecision(victim));assertTrue(messages.get(victim).isEmpty()); + } + + @Test void decisionTimesReadNaturally() { + assertEquals("1 second",PvpStrikeService.formatDuration(1));assertEquals("30 seconds",PvpStrikeService.formatDuration(30));assertEquals("1 minute",PvpStrikeService.formatDuration(60));assertEquals("5 minutes",PvpStrikeService.formatDuration(300));assertEquals("1 minute 1 second",PvpStrikeService.formatDuration(61));assertEquals("2 minutes 30 seconds",PvpStrikeService.formatDuration(150)); + } + static Set commands(Component component){var out=new HashSet();if(component.clickEvent()!=null){out.add(component.clickEvent().value());assertNotNull(component.hoverEvent());}for(var child:component.children())out.addAll(commands(child));return out;} }