feat(aura): protect blocks within player radius
Release / release (push) Successful in 2m32s
CI / build (push) Successful in 1m11s

This commit is contained in:
dmg
2026-08-11 14:46:54 -04:00
parent b4a8094874
commit 55e4f04f16
5 changed files with 105 additions and 27 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Spigot Creeper Fear # Spigot Creeper Fear
A Spigot 26.2 plugin that lets players earn six Creeper Aura ranks. Once unlocked, an aura prevents creeper explosions that hit the player from breaking blocks, trading that protection for rank-dependent player damage. A Spigot 26.2 plugin that lets players earn six Creeper Aura ranks. Once unlocked, an aura prevents a creeper explosion within 25 blocks of the player from breaking blocks, whether or not the explosion hits the player. Players hit by the explosion receive rank-dependent damage.
## Requirements ## Requirements
+4
View File
@@ -6,6 +6,10 @@ description: Chronological record of material changes to the Spigot Creeper Fear
# Design Log # Design Log
## 2026-08-11
- Updated US-002 so an unlocked player's aura protects blocks from any creeper explosion within 25 blocks, without requiring the explosion to hit the player.
## 2026-08-10 ## 2026-08-10
- Completed US-008 with permission-aware command, online-player, and rank tab completion. - Completed US-008 with permission-aware command, online-player, and rank tab completion.
@@ -26,8 +26,10 @@ As a **player**, I want Creeper Aura to become stronger as I defeat creepers so
- [x] A player unlocks the next rank after earning the configured number of kills within their current tier. - [x] A player unlocks the next rank after earning the configured number of kills within their current tier.
- [x] Unlocking a rank resets current-tier progress to zero. - [x] Unlocking a rank resets current-tier progress to zero.
- [x] A player below rank I receives normal creeper explosion behavior. - [x] A player below rank I receives normal creeper explosion behavior.
- [x] An aura activates when an unlocked player would have been hit by the creeper explosion, even when armor or another modifier would reduce the eventual damage to zero. - [x] An aura protects blocks when a player with rank I or higher is within 25 blocks of a creeper at explosion time, including at exactly 25 blocks.
- [x] Block protection does not require the aura player to be hit by or have line of sight to the explosion.
- [x] An activated aura prevents that creeper explosion from breaking or removing blocks for everyone affected by the explosion. - [x] An activated aura prevents that creeper explosion from breaking or removing blocks for everyone affected by the explosion.
- [x] Players beyond 25 blocks and players whose aura is locked do not activate block protection.
- [x] Other nearby players and entities continue to receive their normal creeper explosion effects unless they have their own aura damage modifier. - [x] Other nearby players and entities continue to receive their normal creeper explosion effects unless they have their own aura damage modifier.
- [x] The protected player's rank multiplier is applied to vanilla creeper explosion damage before armor, enchantments, resistance, and difficulty mitigation. - [x] The protected player's rank multiplier is applied to vanilla creeper explosion damage before armor, enchantments, resistance, and difficulty mitigation.
- [x] At rank VI, the protected player's creeper explosion damage event is cancelled so that damage and knockback are nullified. - [x] At rank VI, the protected player's creeper explosion damage event is cancelled so that damage and knockback are nullified.
@@ -3,11 +3,10 @@ package games.dmg.creeperfear.aura;
import games.dmg.creeperfear.progress.AuraRank; import games.dmg.creeperfear.progress.AuraRank;
import games.dmg.creeperfear.progress.PlayerProgress; import games.dmg.creeperfear.progress.PlayerProgress;
import games.dmg.creeperfear.progress.ProgressService; import games.dmg.creeperfear.progress.ProgressService;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import java.util.function.Function; import java.util.function.Function;
import org.bukkit.Location;
import org.bukkit.entity.Creeper; import org.bukkit.entity.Creeper;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler; import org.bukkit.event.EventHandler;
@@ -18,16 +17,10 @@ import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.bukkit.event.entity.EntityExplodeEvent; import org.bukkit.event.entity.EntityExplodeEvent;
public final class CreeperAuraListener implements Listener { public final class CreeperAuraListener implements Listener {
private static final int RECENT_EXPLOSION_LIMIT = 1024; private static final double BLOCK_PROTECTION_RADIUS_SQUARED = 25.0 * 25.0;
private final Function<UUID, Optional<PlayerProgress>> progressLookup; private final Function<UUID, Optional<PlayerProgress>> progressLookup;
private final AuraRules rules; private final AuraRules rules;
private final Map<UUID, Boolean> protectedExplosions = new LinkedHashMap<>(64, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<UUID, Boolean> eldest) {
return size() > RECENT_EXPLOSION_LIMIT;
}
};
public CreeperAuraListener(ProgressService progressService, AuraRules rules) { public CreeperAuraListener(ProgressService progressService, AuraRules rules) {
this(progressService::cached, rules); this(progressService::cached, rules);
@@ -52,7 +45,6 @@ public final class CreeperAuraListener implements Listener {
return; return;
} }
markProtected(creeper.getUniqueId());
AuraRank rank = progress.rank(); AuraRank rank = progress.rank();
if (rank.isMaximum()) { if (rank.isMaximum()) {
event.setCancelled(true); event.setCancelled(true);
@@ -63,17 +55,23 @@ public final class CreeperAuraListener implements Listener {
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCreeperExplode(EntityExplodeEvent event) { public void onCreeperExplode(EntityExplodeEvent event) {
if (event.getEntity() instanceof Creeper creeper && takeProtected(creeper.getUniqueId())) { if (event.getEntity() instanceof Creeper creeper && hasNearbyAuraPlayer(creeper)) {
event.blockList().clear(); event.blockList().clear();
event.setYield(0.0f); event.setYield(0.0f);
} }
} }
private synchronized void markProtected(UUID creeperId) { private boolean hasNearbyAuraPlayer(Creeper creeper) {
protectedExplosions.put(creeperId, Boolean.TRUE); Location creeperLocation = creeper.getLocation();
} for (Player player : creeper.getWorld().getPlayers()) {
if (player.getLocation().distanceSquared(creeperLocation) <= BLOCK_PROTECTION_RADIUS_SQUARED
private synchronized boolean takeProtected(UUID creeperId) { && progressLookup.apply(player.getUniqueId())
return protectedExplosions.remove(creeperId) != null; .map(PlayerProgress::rank)
.filter(AuraRank::isUnlocked)
.isPresent()) {
return true;
}
}
return false;
} }
} }
@@ -1,15 +1,20 @@
package games.dmg.creeperfear.aura; package games.dmg.creeperfear.aura;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import games.dmg.creeperfear.progress.AuraRank; import games.dmg.creeperfear.progress.AuraRank;
import games.dmg.creeperfear.progress.PlayerProgress; import games.dmg.creeperfear.progress.PlayerProgress;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.UUID; import java.util.UUID;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.entity.Creeper; import org.bukkit.entity.Creeper;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
@@ -20,31 +25,94 @@ import org.junit.jupiter.api.Test;
class CreeperAuraListenerTest { class CreeperAuraListenerTest {
@Test @Test
void multipliesPlayerDamageAndClearsBlocksForTheExplosion() { void multipliesDamageForAnAuraPlayerHitByACreeperExplosion() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
UUID creeperId = UUID.randomUUID();
Player player = mock(Player.class); Player player = mock(Player.class);
Creeper creeper = mock(Creeper.class); Creeper creeper = mock(Creeper.class);
EntityDamageByEntityEvent damage = mock(EntityDamageByEntityEvent.class); EntityDamageByEntityEvent damage = mock(EntityDamageByEntityEvent.class);
EntityExplodeEvent explosion = mock(EntityExplodeEvent.class);
ArrayList<Block> blocks = new ArrayList<>();
blocks.add(mock(Block.class));
when(player.getUniqueId()).thenReturn(playerId); when(player.getUniqueId()).thenReturn(playerId);
when(creeper.getUniqueId()).thenReturn(creeperId);
when(damage.getEntity()).thenReturn(player); when(damage.getEntity()).thenReturn(player);
when(damage.getDamager()).thenReturn(creeper); when(damage.getDamager()).thenReturn(creeper);
when(damage.getCause()).thenReturn(DamageCause.ENTITY_EXPLOSION); when(damage.getCause()).thenReturn(DamageCause.ENTITY_EXPLOSION);
when(damage.getDamage()).thenReturn(10.0); when(damage.getDamage()).thenReturn(10.0);
PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.I, 0);
CreeperAuraListener listener = new CreeperAuraListener(id -> Optional.of(progress), AuraRules.defaults());
listener.onCreeperDamage(damage);
verify(damage).setDamage(30.0);
}
@Test
void protectsBlocksWhenAuraPlayerIsExactly25BlocksAwayWithoutBeingHit() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
Creeper creeper = mock(Creeper.class);
World world = mock(World.class);
EntityExplodeEvent explosion = mock(EntityExplodeEvent.class);
ArrayList<Block> blocks = blocksToBreak();
when(player.getUniqueId()).thenReturn(playerId);
when(player.getLocation()).thenReturn(new Location(world, 25.0, 64.0, 0.0));
when(creeper.getWorld()).thenReturn(world);
when(creeper.getLocation()).thenReturn(new Location(world, 0.0, 64.0, 0.0));
when(world.getPlayers()).thenReturn(List.of(player));
when(explosion.getEntity()).thenReturn(creeper); when(explosion.getEntity()).thenReturn(creeper);
when(explosion.blockList()).thenReturn(blocks); when(explosion.blockList()).thenReturn(blocks);
PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.I, 0); PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.I, 0);
CreeperAuraListener listener = new CreeperAuraListener(id -> Optional.of(progress), AuraRules.defaults()); CreeperAuraListener listener = new CreeperAuraListener(id -> Optional.of(progress), AuraRules.defaults());
listener.onCreeperDamage(damage);
listener.onCreeperExplode(explosion); listener.onCreeperExplode(explosion);
verify(damage).setDamage(30.0);
assertTrue(blocks.isEmpty()); assertTrue(blocks.isEmpty());
verify(explosion).setYield(0.0f);
}
@Test
void leavesBlocksWhenAuraPlayerIsBeyond25Blocks() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
Creeper creeper = mock(Creeper.class);
World world = mock(World.class);
EntityExplodeEvent explosion = mock(EntityExplodeEvent.class);
ArrayList<Block> blocks = blocksToBreak();
when(player.getUniqueId()).thenReturn(playerId);
when(player.getLocation()).thenReturn(new Location(world, 25.01, 64.0, 0.0));
when(creeper.getWorld()).thenReturn(world);
when(creeper.getLocation()).thenReturn(new Location(world, 0.0, 64.0, 0.0));
when(world.getPlayers()).thenReturn(List.of(player));
when(explosion.getEntity()).thenReturn(creeper);
when(explosion.blockList()).thenReturn(blocks);
PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.I, 0);
CreeperAuraListener listener = new CreeperAuraListener(id -> Optional.of(progress), AuraRules.defaults());
listener.onCreeperExplode(explosion);
assertFalse(blocks.isEmpty());
verify(explosion, never()).setYield(0.0f);
}
@Test
void leavesBlocksWhenNearbyPlayerHasNotUnlockedAura() {
UUID playerId = UUID.randomUUID();
Player player = mock(Player.class);
Creeper creeper = mock(Creeper.class);
World world = mock(World.class);
EntityExplodeEvent explosion = mock(EntityExplodeEvent.class);
ArrayList<Block> blocks = blocksToBreak();
when(player.getUniqueId()).thenReturn(playerId);
when(player.getLocation()).thenReturn(new Location(world, 1.0, 64.0, 0.0));
when(creeper.getWorld()).thenReturn(world);
when(creeper.getLocation()).thenReturn(new Location(world, 0.0, 64.0, 0.0));
when(world.getPlayers()).thenReturn(List.of(player));
when(explosion.getEntity()).thenReturn(creeper);
when(explosion.blockList()).thenReturn(blocks);
PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.LOCKED, 0);
CreeperAuraListener listener = new CreeperAuraListener(id -> Optional.of(progress), AuraRules.defaults());
listener.onCreeperExplode(explosion);
assertFalse(blocks.isEmpty());
verify(explosion, never()).setYield(0.0f);
} }
@Test @Test
@@ -64,4 +132,10 @@ class CreeperAuraListenerTest {
verify(damage).setCancelled(true); verify(damage).setCancelled(true);
} }
private static ArrayList<Block> blocksToBreak() {
ArrayList<Block> blocks = new ArrayList<>();
blocks.add(mock(Block.class));
return blocks;
}
} }