tweaked /nopvp

This commit is contained in:
Martin Dvořák 2026-09-28 11:12:03 +00:00
commit 4326c1d7bd
6 changed files with 318 additions and 4 deletions

View file

@ -5,7 +5,7 @@ A quality-of-life SMP plugin for Paper/Folia.
## Features
- **Join messages** — a "Welcome to the SMP" title on every join, friendly welcome messages (including a first-join message that lists the server's plugins and a warning that `/nopvp` abuse is bannable and that combat logging is a bad idea), and a special announcement when the owner logs in.
- **`/nopvp`** — toggle no-PvP mode. While active you cannot damage other players and other players cannot damage you (direct hits, projectiles, and player-deployed explosives such as TNT, TNT minecarts, end crystals and bed bombs). While enabled you also glow so everyone can see you're protected. Turning no-PvP *off* starts a **1-hour cooldown** before you can enable it again, and the state persists across restarts so relogging can't bypass it.
- **`/nopvp`** — toggle no-PvP mode. While active you cannot damage other players and other players cannot damage you (direct hits, projectiles, pets, lingering potions, and player-deployed explosives such as TNT, TNT minecarts, end crystals and bed bombs). While enabled you also glow so everyone can see you're protected. To stop the immunity being used offensively, protected players (and anyone within **25 blocks** of one) are also blocked from placing, priming or detonating PvP explosives — including redstone-primed TNT whose placer is remembered, and dispensers near a protected player firing TNT/lava/fire charges. No-PvP also can't be switched on while you're in combat. Turning no-PvP *off* starts a **1-hour cooldown** before you can enable it again, and the state persists across restarts so relogging can't bypass it.
- **Anti-combat-logging** — whenever you hit another player or get hit by one, you're tagged as being in combat for **15 seconds** with a chat warning. Logging off while tagged kills you on the spot and announces it to the server.
## Commands

View file

@ -6,6 +6,7 @@ import com.dvorakmortonxd.qoltweaks.listeners.JoinListener;
import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPCommand;
import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPListener;
import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPManager;
import com.dvorakmortonxd.qoltweaks.nopvp.NoPvPProtectionListener;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
@ -22,11 +23,12 @@ public final class QoLtweaks extends JavaPlugin {
getServer().getPluginManager().registerEvents(new JoinListener(), this);
getServer().getPluginManager().registerEvents(new NoPvPListener(noPvPManager), this);
getServer().getPluginManager().registerEvents(new NoPvPProtectionListener(noPvPManager), this);
getServer().getPluginManager().registerEvents(new CombatLogListener(this, combatLogManager), this);
PluginCommand noPvPCommand = getCommand("nopvp");
if (noPvPCommand != null) {
noPvPCommand.setExecutor(new NoPvPCommand(noPvPManager));
noPvPCommand.setExecutor(new NoPvPCommand(noPvPManager, combatLogManager));
}
}

View file

@ -1,5 +1,6 @@
package com.dvorakmortonxd.qoltweaks.nopvp;
import com.dvorakmortonxd.qoltweaks.combatlog.CombatLogManager;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
@ -8,9 +9,11 @@ import org.bukkit.entity.Player;
public final class NoPvPCommand implements CommandExecutor {
private final NoPvPManager manager;
private final CombatLogManager combatLogManager;
public NoPvPCommand(NoPvPManager manager) {
public NoPvPCommand(NoPvPManager manager, CombatLogManager combatLogManager) {
this.manager = manager;
this.combatLogManager = combatLogManager;
}
@Override
@ -37,8 +40,13 @@ public final class NoPvPCommand implements CommandExecutor {
return true;
}
if (combatLogManager.isTagged(player)) {
player.sendMessage("§cYou can't enable no-PvP while in combat. Wait a few seconds and try again.");
return true;
}
manager.enable(player);
player.sendMessage("§aYou enabled no-PvP. Other players can no longer damage you (including TNT, TNT minecarts, crystals and bed bombs), and you can no longer damage other players.");
player.sendMessage("§aYou enabled no-PvP. Other players can no longer damage you (including TNT, TNT minecarts, crystals and bed bombs), and you can no longer damage other players or place PvP explosives.");
return true;
}

View file

@ -1,11 +1,14 @@
package com.dvorakmortonxd.qoltweaks.nopvp;
import org.bukkit.damage.DamageSource;
import org.bukkit.entity.AreaEffectCloud;
import org.bukkit.entity.EnderCrystal;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.entity.TNTPrimed;
import org.bukkit.entity.Tameable;
import org.bukkit.entity.Vehicle;
import org.bukkit.entity.minecart.ExplosiveMinecart;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
@ -113,6 +116,22 @@ public final class NoPvPListener implements Listener {
if (damager instanceof TNTPrimed tnt && tnt.getSource() instanceof Player player) {
return player;
}
// A protected player's pet is still the player attacking.
if (damager instanceof Tameable tameable && tameable.getOwner() instanceof Player player) {
return player;
}
// Lingering potions carry the player who threw them.
if (damager instanceof AreaEffectCloud cloud && cloud.getSource() instanceof Player player) {
return player;
}
// A player riding/pushing a vehicle that rams someone is responsible.
if (damager instanceof Vehicle vehicle) {
for (Entity passenger : vehicle.getPassengers()) {
if (passenger instanceof Player player) {
return player;
}
}
}
return null;
}

View file

@ -1,5 +1,7 @@
package com.dvorakmortonxd.qoltweaks.nopvp;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;
import org.bukkit.entity.Player;
@ -15,6 +17,9 @@ public final class NoPvPManager {
public static final long COOLDOWN_MILLIS = 60 * 60 * 1000L; // 1 hour
/** How close a no-PvP player has to be before PvP gear can't be placed or detonated. */
public static final double PROTECTION_RADIUS = 25.0;
private final JavaPlugin plugin;
private final File dataFile;
private final Map<UUID, Boolean> enabled = new ConcurrentHashMap<>();
@ -30,6 +35,18 @@ public final class NoPvPManager {
return enabled.getOrDefault(player.getUniqueId(), false);
}
/**
* Whether any player with no-PvP enabled is within {@code radius} blocks of
* the given location. Used to keep PvP gear away from protected players.
*/
public boolean isProtectedWithin(Location location, double radius) {
World world = location.getWorld();
if (world == null) {
return false;
}
return !world.getNearbyPlayers(location, radius, this::isEnabled).isEmpty();
}
public long getRemainingCooldownMillis(Player player) {
long until = cooldownUntil.getOrDefault(player.getUniqueId(), 0L);
return Math.max(0L, until - System.currentTimeMillis());

View file

@ -0,0 +1,268 @@
package com.dvorakmortonxd.qoltweaks.nopvp;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.Tag;
import org.bukkit.World;
import org.bukkit.block.Block;
import org.bukkit.block.data.type.RespawnAnchor;
import org.bukkit.entity.EnderCrystal;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.entity.TNTPrimed;
import org.bukkit.entity.minecart.ExplosiveMinecart;
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.BlockDispenseEvent;
import org.bukkit.event.block.BlockExplodeEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.block.TNTPrimeEvent;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityExplodeEvent;
import org.bukkit.event.entity.EntityPlaceEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
/**
* Keeps player-deployable explosives (TNT, TNT minecarts, end crystals, bed
* bombs and respawn anchors) out of the hands of no-PvP players, and away from
* players who are currently protected by no-PvP.
* <p>
* Without this, a no-PvP player is immune to explosions but could still set
* them off freely, which would be a one-sided exploit.
*/
public final class NoPvPProtectionListener implements Listener {
private final NoPvPManager manager;
/** Tracks who placed each TNT block so remote (redstone) priming can be attributed. */
private final Map<BlockKey, UUID> tntPlacers = new ConcurrentHashMap<>();
public NoPvPProtectionListener(NoPvPManager manager) {
this.manager = manager;
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockPlace(BlockPlaceEvent event) {
Material type = event.getBlockPlaced().getType();
if (type == Material.TNT) {
if (isRestricted(event.getPlayer(), event.getBlockPlaced().getLocation())) {
event.setCancelled(true);
sendBlocked(event.getPlayer(), "place TNT");
return;
}
if (event.getPlayer() != null) {
tntPlacers.put(BlockKey.of(event.getBlockPlaced().getLocation()),
event.getPlayer().getUniqueId());
}
return;
}
if (type == Material.RESPAWN_ANCHOR
&& isRestricted(event.getPlayer(), event.getBlockPlaced().getLocation())) {
event.setCancelled(true);
sendBlocked(event.getPlayer(), "place respawn anchors");
}
}
@EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true)
public void onBlockBreak(BlockBreakEvent event) {
if (event.getBlock().getType() == Material.TNT) {
tntPlacers.remove(BlockKey.of(event.getBlock().getLocation()));
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onEntityPlace(EntityPlaceEvent event) {
Entity entity = event.getEntity();
String what;
if (entity instanceof EnderCrystal) {
what = "end crystals";
} else if (entity instanceof ExplosiveMinecart) {
what = "TNT minecarts";
} else {
return;
}
if (isRestricted(event.getPlayer(), event.getBlock().getLocation())) {
event.setCancelled(true);
sendBlocked(event.getPlayer(), "place " + what);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onInteract(PlayerInteractEvent event) {
if (event.getAction() != Action.RIGHT_CLICK_BLOCK) {
return;
}
Block block = event.getClickedBlock();
if (block == null) {
return;
}
if (block.getType() == Material.RESPAWN_ANCHOR && wouldDetonateAnchor(block)) {
if (isRestricted(event.getPlayer(), block.getLocation())) {
event.setCancelled(true);
sendBlocked(event.getPlayer(), "detonate a respawn anchor");
}
return;
}
if (Tag.BEDS.isTagged(block.getType()) && isExplosiveBedDimension(block.getWorld())) {
if (isRestricted(event.getPlayer(), block.getLocation())) {
event.setCancelled(true);
sendBlocked(event.getPlayer(), "detonate a bed");
}
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onTNTPrime(TNTPrimeEvent event) {
Entity primer = event.getPrimingEntity();
if (primer instanceof Player player && manager.isEnabled(player)) {
event.setCancelled(true);
player.sendMessage("§cYou can't prime TNT while no-PvP is on.");
return;
}
// Catches redstone priming of TNT placed before no-PvP was enabled.
Player owner = peekTntOwner(event.getBlock().getLocation());
if (owner != null && manager.isEnabled(owner)) {
event.setCancelled(true);
owner.sendMessage("§cYour TNT can't be primed while no-PvP is on.");
return;
}
if (isNearbyRestricted(event.getBlock().getLocation())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockDispense(BlockDispenseEvent event) {
Material item = event.getItem().getType();
if (item != Material.TNT && item != Material.LAVA_BUCKET && item != Material.FIRE_CHARGE) {
return;
}
if (isNearbyRestricted(event.getBlock().getLocation())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onEntityExplode(EntityExplodeEvent event) {
Entity explosive = event.getEntity();
Player owner = null;
if (explosive instanceof TNTPrimed tnt) {
owner = takeTntOwner(tnt.getOrigin());
} else if (!(explosive instanceof ExplosiveMinecart || explosive instanceof EnderCrystal)) {
return;
}
if (isPlayerRestricted(resolveSource(explosive))
|| isPlayerRestricted(owner)
|| isNearbyRestricted(event.getLocation())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockExplode(BlockExplodeEvent event) {
// In vanilla the only block explosions are bed bombs and respawn anchors.
if (isNearbyRestricted(event.getBlock().getLocation())) {
event.setCancelled(true);
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onEntityDamageByEntity(EntityDamageByEntityEvent event) {
Entity target = event.getEntity();
if (!(target instanceof EnderCrystal || target instanceof ExplosiveMinecart)) {
return;
}
// Don't let a protected player detonate crystals/TNT carts themselves.
if (isPlayerRestricted(resolveAttacker(event.getDamager()))) {
event.setCancelled(true);
}
}
private boolean isRestricted(Player player, Location location) {
return isPlayerRestricted(player) || isNearbyRestricted(location);
}
private boolean isPlayerRestricted(Player player) {
return player != null && manager.isEnabled(player);
}
private boolean isNearbyRestricted(Location location) {
return location != null && manager.isProtectedWithin(location, NoPvPManager.PROTECTION_RADIUS);
}
private void sendBlocked(Player player, String action) {
if (player == null) {
return;
}
if (manager.isEnabled(player)) {
player.sendMessage("§cYou can't " + action + " while no-PvP is on.");
} else {
player.sendMessage("§cYou can't " + action + " within "
+ (long) NoPvPManager.PROTECTION_RADIUS + " blocks of a no-PvP player.");
}
}
private Player peekTntOwner(Location location) {
if (location == null || location.getWorld() == null) {
return null;
}
UUID uuid = tntPlacers.get(BlockKey.of(location));
return uuid == null ? null : Bukkit.getPlayer(uuid);
}
private Player takeTntOwner(Location location) {
if (location == null || location.getWorld() == null) {
return null;
}
UUID uuid = tntPlacers.remove(BlockKey.of(location));
return uuid == null ? null : Bukkit.getPlayer(uuid);
}
private boolean wouldDetonateAnchor(Block block) {
if (block.getWorld().getEnvironment() == World.Environment.NETHER) {
return false;
}
return block.getBlockData() instanceof RespawnAnchor anchor && anchor.getCharges() > 0;
}
private boolean isExplosiveBedDimension(World world) {
World.Environment environment = world.getEnvironment();
return environment == World.Environment.NETHER || environment == World.Environment.THE_END;
}
private Player resolveSource(Entity explosive) {
if (explosive instanceof TNTPrimed tnt && tnt.getSource() instanceof Player player) {
return player;
}
return null;
}
private Player resolveAttacker(Entity damager) {
if (damager instanceof Player player) {
return player;
}
if (damager instanceof Projectile projectile && projectile.getShooter() instanceof Player player) {
return player;
}
return null;
}
private record BlockKey(UUID world, int x, int y, int z) {
static BlockKey of(Location location) {
return new BlockKey(location.getWorld().getUID(),
location.getBlockX(), location.getBlockY(), location.getBlockZ());
}
}
}