diff --git a/README.md b/README.md index dc77f06..6df3143 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # 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 diff --git a/design/log.md b/design/log.md index a330b4c..1d835dd 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,10 @@ description: Chronological record of material changes to the Spigot Creeper Fear # 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 - Completed US-008 with permission-aware command, online-player, and rank tab completion. diff --git a/design/user-stories/us-002-unlock-creeper-aura-ranks.md b/design/user-stories/us-002-unlock-creeper-aura-ranks.md index f494e58..e313a40 100644 --- a/design/user-stories/us-002-unlock-creeper-aura-ranks.md +++ b/design/user-stories/us-002-unlock-creeper-aura-ranks.md @@ -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] Unlocking a rank resets current-tier progress to zero. - [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] 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] 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. diff --git a/src/main/java/games/dmg/creeperfear/aura/CreeperAuraListener.java b/src/main/java/games/dmg/creeperfear/aura/CreeperAuraListener.java index 88fa463..8154f75 100644 --- a/src/main/java/games/dmg/creeperfear/aura/CreeperAuraListener.java +++ b/src/main/java/games/dmg/creeperfear/aura/CreeperAuraListener.java @@ -3,11 +3,10 @@ package games.dmg.creeperfear.aura; import games.dmg.creeperfear.progress.AuraRank; import games.dmg.creeperfear.progress.PlayerProgress; import games.dmg.creeperfear.progress.ProgressService; -import java.util.LinkedHashMap; -import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.function.Function; +import org.bukkit.Location; import org.bukkit.entity.Creeper; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; @@ -18,16 +17,10 @@ import org.bukkit.event.entity.EntityDamageEvent.DamageCause; import org.bukkit.event.entity.EntityExplodeEvent; 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> progressLookup; private final AuraRules rules; - private final Map protectedExplosions = new LinkedHashMap<>(64, 0.75f, true) { - @Override - protected boolean removeEldestEntry(Map.Entry eldest) { - return size() > RECENT_EXPLOSION_LIMIT; - } - }; public CreeperAuraListener(ProgressService progressService, AuraRules rules) { this(progressService::cached, rules); @@ -52,7 +45,6 @@ public final class CreeperAuraListener implements Listener { return; } - markProtected(creeper.getUniqueId()); AuraRank rank = progress.rank(); if (rank.isMaximum()) { event.setCancelled(true); @@ -63,17 +55,23 @@ public final class CreeperAuraListener implements Listener { @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) 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.setYield(0.0f); } } - private synchronized void markProtected(UUID creeperId) { - protectedExplosions.put(creeperId, Boolean.TRUE); - } - - private synchronized boolean takeProtected(UUID creeperId) { - return protectedExplosions.remove(creeperId) != null; + private boolean hasNearbyAuraPlayer(Creeper creeper) { + Location creeperLocation = creeper.getLocation(); + for (Player player : creeper.getWorld().getPlayers()) { + if (player.getLocation().distanceSquared(creeperLocation) <= BLOCK_PROTECTION_RADIUS_SQUARED + && progressLookup.apply(player.getUniqueId()) + .map(PlayerProgress::rank) + .filter(AuraRank::isUnlocked) + .isPresent()) { + return true; + } + } + return false; } } diff --git a/src/test/java/games/dmg/creeperfear/aura/CreeperAuraListenerTest.java b/src/test/java/games/dmg/creeperfear/aura/CreeperAuraListenerTest.java index 9e6582f..17d84b1 100644 --- a/src/test/java/games/dmg/creeperfear/aura/CreeperAuraListenerTest.java +++ b/src/test/java/games/dmg/creeperfear/aura/CreeperAuraListenerTest.java @@ -1,15 +1,20 @@ package games.dmg.creeperfear.aura; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import games.dmg.creeperfear.progress.AuraRank; import games.dmg.creeperfear.progress.PlayerProgress; import java.util.ArrayList; +import java.util.List; import java.util.Optional; import java.util.UUID; +import org.bukkit.Location; +import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.entity.Creeper; import org.bukkit.entity.Player; @@ -20,31 +25,94 @@ import org.junit.jupiter.api.Test; class CreeperAuraListenerTest { @Test - void multipliesPlayerDamageAndClearsBlocksForTheExplosion() { + void multipliesDamageForAnAuraPlayerHitByACreeperExplosion() { UUID playerId = UUID.randomUUID(); - UUID creeperId = UUID.randomUUID(); Player player = mock(Player.class); Creeper creeper = mock(Creeper.class); EntityDamageByEntityEvent damage = mock(EntityDamageByEntityEvent.class); - EntityExplodeEvent explosion = mock(EntityExplodeEvent.class); - ArrayList blocks = new ArrayList<>(); - blocks.add(mock(Block.class)); when(player.getUniqueId()).thenReturn(playerId); - when(creeper.getUniqueId()).thenReturn(creeperId); when(damage.getEntity()).thenReturn(player); when(damage.getDamager()).thenReturn(creeper); when(damage.getCause()).thenReturn(DamageCause.ENTITY_EXPLOSION); 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 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.blockList()).thenReturn(blocks); PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.I, 0); CreeperAuraListener listener = new CreeperAuraListener(id -> Optional.of(progress), AuraRules.defaults()); - listener.onCreeperDamage(damage); listener.onCreeperExplode(explosion); - verify(damage).setDamage(30.0); 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 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 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 @@ -64,4 +132,10 @@ class CreeperAuraListenerTest { verify(damage).setCancelled(true); } + + private static ArrayList blocksToBreak() { + ArrayList blocks = new ArrayList<>(); + blocks.add(mock(Block.class)); + return blocks; + } }