From e35facd4462ee00b16a820ae2f006712f99f1381 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 8 Aug 2026 14:10:37 -0400 Subject: [PATCH] feat(aura): apply ranked creeper protection --- design/log.md | 1 + .../us-002-unlock-creeper-aura-ranks.md | 24 +++--- .../dmg/creeperfear/CreeperFearPlugin.java | 16 +++- .../games/dmg/creeperfear/aura/AuraRules.java | 57 +++++++++++++ .../creeperfear/aura/CreeperAuraListener.java | 79 +++++++++++++++++++ .../listener/PlayerSessionListener.java | 35 ++++++++ .../dmg/creeperfear/progress/AuraRank.java | 11 +++ .../creeperfear/progress/ProgressService.java | 42 +++++++++- .../dmg/creeperfear/aura/AuraRulesTest.java | 32 ++++++++ .../aura/CreeperAuraListenerTest.java | 67 ++++++++++++++++ .../RankProgressionIntegrationTest.java | 36 +++++++++ 11 files changed, 384 insertions(+), 16 deletions(-) create mode 100644 src/main/java/games/dmg/creeperfear/aura/AuraRules.java create mode 100644 src/main/java/games/dmg/creeperfear/aura/CreeperAuraListener.java create mode 100644 src/main/java/games/dmg/creeperfear/listener/PlayerSessionListener.java create mode 100644 src/test/java/games/dmg/creeperfear/aura/AuraRulesTest.java create mode 100644 src/test/java/games/dmg/creeperfear/aura/CreeperAuraListenerTest.java create mode 100644 src/test/java/games/dmg/creeperfear/progress/RankProgressionIntegrationTest.java diff --git a/design/log.md b/design/log.md index f72147d..c2b489d 100644 --- a/design/log.md +++ b/design/log.md @@ -14,3 +14,4 @@ description: Chronological record of material changes to the Spigot Creeper Fear - Replaced lifetime cumulative kill tracking with a persisted rank and current-tier progress model. - Added the initial plugin architecture. - Completed US-001 with asynchronous SQLite current-tier progress, direct and indirect kill attribution, bounded death deduplication, and automated tests. +- Completed US-002 with per-tier rank advancement, online aura state, creeper block protection, rank damage multipliers, and rank VI cancellation. 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 ec9fbda..f494e58 100644 --- a/design/user-stories/us-002-unlock-creeper-aura-ranks.md +++ b/design/user-stories/us-002-unlock-creeper-aura-ranks.md @@ -2,7 +2,7 @@ type: User Story title: "US-002: Unlock Creeper Aura ranks" description: Protect blocks and modify creeper damage according to a player's earned aura rank. -status: backlog +status: done --- # US-002: Unlock Creeper Aura ranks @@ -23,17 +23,17 @@ As a **player**, I want Creeper Aura to become stronger as I defeat creepers so ## Acceptance criteria -- [ ] A player unlocks the next rank after earning the configured number of kills within their current tier. -- [ ] Unlocking a rank resets current-tier progress to zero. -- [ ] A player below rank I receives normal creeper explosion behavior. -- [ ] 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. -- [ ] An activated aura prevents that creeper explosion from breaking or removing blocks for everyone affected by the explosion. -- [ ] Other nearby players and entities continue to receive their normal creeper explosion effects unless they have their own aura damage modifier. -- [ ] The protected player's rank multiplier is applied to vanilla creeper explosion damage before armor, enchantments, resistance, and difficulty mitigation. -- [ ] At rank VI, the protected player's creeper explosion damage event is cancelled so that damage and knockback are nullified. -- [ ] Charged creepers obey the same aura rules while retaining their vanilla base explosion strength. -- [ ] Explosions from TNT, beds, respawn anchors, and non-creeper entities are unchanged. -- [ ] Simultaneous exposure of players with different aura ranks is deterministic and tested. +- [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 activated aura prevents that creeper explosion from breaking or removing blocks for everyone affected by the explosion. +- [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. +- [x] Charged creepers obey the same aura rules while retaining their vanilla base explosion strength. +- [x] Explosions from TNT, beds, respawn anchors, and non-creeper entities are unchanged. +- [x] Simultaneous exposure of players with different aura ranks is deterministic and tested. ## Related diff --git a/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java b/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java index 60ded3d..7fb9a53 100644 --- a/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java +++ b/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java @@ -1,6 +1,9 @@ package games.dmg.creeperfear; +import games.dmg.creeperfear.aura.AuraRules; +import games.dmg.creeperfear.aura.CreeperAuraListener; import games.dmg.creeperfear.listener.CreeperDeathListener; +import games.dmg.creeperfear.listener.PlayerSessionListener; import games.dmg.creeperfear.progress.ProgressService; import games.dmg.creeperfear.progress.SqliteProgressRepository; import java.nio.file.Path; @@ -14,9 +17,20 @@ public final class CreeperFearPlugin extends JavaPlugin { public void onEnable() { try { Path databasePath = getDataFolder().toPath().resolve("player-progress.sqlite3"); - progressService = new ProgressService(new SqliteProgressRepository(databasePath)); + AuraRules auraRules = AuraRules.defaults(); + progressService = new ProgressService(new SqliteProgressRepository(databasePath), auraRules); getServer().getPluginManager().registerEvents( new CreeperDeathListener(progressService, getLogger()), this); + getServer().getPluginManager().registerEvents( + new PlayerSessionListener(progressService, getLogger()), this); + getServer().getPluginManager().registerEvents( + new CreeperAuraListener(progressService, auraRules), this); + getServer().getOnlinePlayers().forEach(player -> progressService.loadOnline(player.getUniqueId()) + .exceptionally(failure -> { + getLogger().log(Level.SEVERE, + "Could not load Creeper Aura progress for " + player.getUniqueId(), failure); + return null; + })); getLogger().info("Creeper Fear enabled"); } catch (RuntimeException exception) { getLogger().log(Level.SEVERE, "Creeper Fear could not initialize its progress storage", exception); diff --git a/src/main/java/games/dmg/creeperfear/aura/AuraRules.java b/src/main/java/games/dmg/creeperfear/aura/AuraRules.java new file mode 100644 index 0000000..1fb2760 --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/aura/AuraRules.java @@ -0,0 +1,57 @@ +package games.dmg.creeperfear.aura; + +import games.dmg.creeperfear.progress.AuraRank; +import games.dmg.creeperfear.progress.PlayerProgress; +import java.util.EnumMap; +import java.util.Map; + +public final class AuraRules { + private final Map requirements; + private final Map damageMultipliers; + + public AuraRules(Map requirements, Map damageMultipliers) { + this.requirements = new EnumMap<>(requirements); + this.damageMultipliers = new EnumMap<>(damageMultipliers); + } + + public static AuraRules defaults() { + Map requirements = new EnumMap<>(AuraRank.class); + for (AuraRank rank : AuraRank.values()) { + if (!rank.isMaximum()) { + requirements.put(rank, 100); + } + } + Map multipliers = new EnumMap<>(AuraRank.class); + multipliers.put(AuraRank.LOCKED, 1.0); + multipliers.put(AuraRank.I, 3.0); + multipliers.put(AuraRank.II, 2.0); + multipliers.put(AuraRank.III, 1.5); + multipliers.put(AuraRank.IV, 1.0); + multipliers.put(AuraRank.V, 0.5); + multipliers.put(AuraRank.VI, 0.0); + return new AuraRules(requirements, multipliers); + } + + public PlayerProgress advanceIfEarned(PlayerProgress progress) { + if (progress.rank().isMaximum()) { + return progress; + } + int required = requirements.get(progress.rank()); + if (progress.tierKills() < required) { + return progress; + } + return new PlayerProgress( + progress.playerId(), progress.lastKnownName(), progress.rank().next(), 0); + } + + public double damageMultiplier(AuraRank rank) { + return damageMultipliers.get(rank); + } + + public int requirementForCurrentRank(AuraRank rank) { + if (rank.isMaximum()) { + throw new IllegalArgumentException("Rank VI has no next requirement"); + } + return requirements.get(rank); + } +} diff --git a/src/main/java/games/dmg/creeperfear/aura/CreeperAuraListener.java b/src/main/java/games/dmg/creeperfear/aura/CreeperAuraListener.java new file mode 100644 index 0000000..88fa463 --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/aura/CreeperAuraListener.java @@ -0,0 +1,79 @@ +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.entity.Creeper; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +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 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); + } + + CreeperAuraListener( + Function> progressLookup, + AuraRules rules) { + this.progressLookup = progressLookup; + this.rules = rules; + } + + @EventHandler(priority = EventPriority.LOWEST) + public void onCreeperDamage(EntityDamageByEntityEvent event) { + if (event.getCause() != DamageCause.ENTITY_EXPLOSION + || !(event.getDamager() instanceof Creeper creeper) + || !(event.getEntity() instanceof Player player)) { + return; + } + PlayerProgress progress = progressLookup.apply(player.getUniqueId()).orElse(null); + if (progress == null || !progress.rank().isUnlocked()) { + return; + } + + markProtected(creeper.getUniqueId()); + AuraRank rank = progress.rank(); + if (rank.isMaximum()) { + event.setCancelled(true); + return; + } + event.setDamage(event.getDamage() * rules.damageMultiplier(rank)); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void onCreeperExplode(EntityExplodeEvent event) { + if (event.getEntity() instanceof Creeper creeper && takeProtected(creeper.getUniqueId())) { + 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; + } +} diff --git a/src/main/java/games/dmg/creeperfear/listener/PlayerSessionListener.java b/src/main/java/games/dmg/creeperfear/listener/PlayerSessionListener.java new file mode 100644 index 0000000..216a309 --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/listener/PlayerSessionListener.java @@ -0,0 +1,35 @@ +package games.dmg.creeperfear.listener; + +import games.dmg.creeperfear.progress.ProgressService; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; + +public final class PlayerSessionListener implements Listener { + private final ProgressService progressService; + private final Logger logger; + + public PlayerSessionListener(ProgressService progressService, Logger logger) { + this.progressService = progressService; + this.logger = logger; + } + + @EventHandler + public void onPlayerJoin(PlayerJoinEvent event) { + UUID playerId = event.getPlayer().getUniqueId(); + progressService.loadOnline(playerId).whenComplete((progress, failure) -> { + if (failure != null) { + logger.log(Level.SEVERE, "Could not load Creeper Aura progress for " + playerId, failure); + } + }); + } + + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) { + progressService.unload(event.getPlayer().getUniqueId()); + } +} diff --git a/src/main/java/games/dmg/creeperfear/progress/AuraRank.java b/src/main/java/games/dmg/creeperfear/progress/AuraRank.java index 1192553..e169da0 100644 --- a/src/main/java/games/dmg/creeperfear/progress/AuraRank.java +++ b/src/main/java/games/dmg/creeperfear/progress/AuraRank.java @@ -9,7 +9,18 @@ public enum AuraRank { V, VI; + public boolean isUnlocked() { + return this != LOCKED; + } + public boolean isMaximum() { return this == VI; } + + public AuraRank next() { + if (isMaximum()) { + return VI; + } + return values()[ordinal() + 1]; + } } diff --git a/src/main/java/games/dmg/creeperfear/progress/ProgressService.java b/src/main/java/games/dmg/creeperfear/progress/ProgressService.java index 7bcbb18..ec18307 100644 --- a/src/main/java/games/dmg/creeperfear/progress/ProgressService.java +++ b/src/main/java/games/dmg/creeperfear/progress/ProgressService.java @@ -1,18 +1,27 @@ package games.dmg.creeperfear.progress; +import games.dmg.creeperfear.aura.AuraRules; import java.util.Optional; import java.util.UUID; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public final class ProgressService implements AutoCloseable { private final ProgressRepository repository; + private final AuraRules auraRules; + private final ConcurrentHashMap onlineProgress = new ConcurrentHashMap<>(); private final ExecutorService executor; public ProgressService(ProgressRepository repository) { + this(repository, AuraRules.defaults()); + } + + public ProgressService(ProgressRepository repository, AuraRules auraRules) { this.repository = repository; + this.auraRules = auraRules; this.executor = Executors.newSingleThreadExecutor(runnable -> { Thread thread = new Thread(runnable, "creeper-fear-progress"); thread.setDaemon(true); @@ -21,8 +30,31 @@ public final class ProgressService implements AutoCloseable { } public CompletableFuture recordCreeperKill(UUID playerId, String playerName) { - return CompletableFuture.supplyAsync( - () -> repository.recordCreeperKill(playerId, playerName), executor); + return CompletableFuture.supplyAsync(() -> { + PlayerProgress recorded = repository.recordCreeperKill(playerId, playerName); + PlayerProgress advanced = auraRules.advanceIfEarned(recorded); + if (!advanced.equals(recorded)) { + repository.save(advanced); + } + onlineProgress.put(playerId, advanced); + return advanced; + }, executor); + } + + public CompletableFuture> loadOnline(UUID playerId) { + return CompletableFuture.supplyAsync(() -> { + Optional progress = repository.find(playerId); + progress.ifPresent(value -> onlineProgress.put(playerId, value)); + return progress; + }, executor); + } + + public void unload(UUID playerId) { + onlineProgress.remove(playerId); + } + + public Optional cached(UUID playerId) { + return Optional.ofNullable(onlineProgress.get(playerId)); } public CompletableFuture> find(UUID playerId) { @@ -30,7 +62,11 @@ public final class ProgressService implements AutoCloseable { } public CompletableFuture save(PlayerProgress progress) { - return CompletableFuture.supplyAsync(() -> repository.save(progress), executor); + return CompletableFuture.supplyAsync(() -> { + PlayerProgress saved = repository.save(progress); + onlineProgress.computeIfPresent(progress.playerId(), (ignored, existing) -> saved); + return saved; + }, executor); } @Override diff --git a/src/test/java/games/dmg/creeperfear/aura/AuraRulesTest.java b/src/test/java/games/dmg/creeperfear/aura/AuraRulesTest.java new file mode 100644 index 0000000..1aed368 --- /dev/null +++ b/src/test/java/games/dmg/creeperfear/aura/AuraRulesTest.java @@ -0,0 +1,32 @@ +package games.dmg.creeperfear.aura; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import games.dmg.creeperfear.progress.AuraRank; +import games.dmg.creeperfear.progress.PlayerProgress; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class AuraRulesTest { + private final AuraRules rules = AuraRules.defaults(); + + @Test + void unlocksOneRankAndResetsTierProgressAtOneHundredKills() { + PlayerProgress progress = new PlayerProgress(UUID.randomUUID(), "Player", AuraRank.LOCKED, 100); + + PlayerProgress advanced = rules.advanceIfEarned(progress); + + assertEquals(AuraRank.I, advanced.rank()); + assertEquals(0, advanced.tierKills()); + } + + @Test + void appliesTheDefaultDamageMultipliers() { + assertEquals(3.0, rules.damageMultiplier(AuraRank.I)); + assertEquals(2.0, rules.damageMultiplier(AuraRank.II)); + assertEquals(1.5, rules.damageMultiplier(AuraRank.III)); + assertEquals(1.0, rules.damageMultiplier(AuraRank.IV)); + assertEquals(0.5, rules.damageMultiplier(AuraRank.V)); + assertEquals(0.0, rules.damageMultiplier(AuraRank.VI)); + } +} diff --git a/src/test/java/games/dmg/creeperfear/aura/CreeperAuraListenerTest.java b/src/test/java/games/dmg/creeperfear/aura/CreeperAuraListenerTest.java new file mode 100644 index 0000000..9e6582f --- /dev/null +++ b/src/test/java/games/dmg/creeperfear/aura/CreeperAuraListenerTest.java @@ -0,0 +1,67 @@ +package games.dmg.creeperfear.aura; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +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.Optional; +import java.util.UUID; +import org.bukkit.block.Block; +import org.bukkit.entity.Creeper; +import org.bukkit.entity.Player; +import org.bukkit.event.entity.EntityDamageEvent.DamageCause; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.junit.jupiter.api.Test; + +class CreeperAuraListenerTest { + @Test + void multipliesPlayerDamageAndClearsBlocksForTheExplosion() { + 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); + 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()); + } + + @Test + void cancelsAllDamageAtRankSix() { + UUID playerId = UUID.randomUUID(); + Player player = mock(Player.class); + Creeper creeper = mock(Creeper.class); + EntityDamageByEntityEvent damage = mock(EntityDamageByEntityEvent.class); + when(player.getUniqueId()).thenReturn(playerId); + when(damage.getEntity()).thenReturn(player); + when(damage.getDamager()).thenReturn(creeper); + when(damage.getCause()).thenReturn(DamageCause.ENTITY_EXPLOSION); + PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.VI, 0); + CreeperAuraListener listener = new CreeperAuraListener(id -> Optional.of(progress), AuraRules.defaults()); + + listener.onCreeperDamage(damage); + + verify(damage).setCancelled(true); + } +} diff --git a/src/test/java/games/dmg/creeperfear/progress/RankProgressionIntegrationTest.java b/src/test/java/games/dmg/creeperfear/progress/RankProgressionIntegrationTest.java new file mode 100644 index 0000000..e4dad42 --- /dev/null +++ b/src/test/java/games/dmg/creeperfear/progress/RankProgressionIntegrationTest.java @@ -0,0 +1,36 @@ +package games.dmg.creeperfear.progress; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.file.Path; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class RankProgressionIntegrationTest { + @TempDir + Path tempDir; + + @Test + void theHundredthCurrentTierKillUnlocksOneRankAndPersistsTheReset() throws Exception { + UUID playerId = UUID.randomUUID(); + Path database = tempDir.resolve("progress.sqlite3"); + try (SqliteProgressRepository repository = new SqliteProgressRepository(database)) { + repository.save(new PlayerProgress(playerId, "Player", AuraRank.LOCKED, 99)); + } + + try (ProgressService service = new ProgressService(new SqliteProgressRepository(database))) { + PlayerProgress advanced = service.recordCreeperKill(playerId, "Player").get(2, TimeUnit.SECONDS); + + assertEquals(AuraRank.I, advanced.rank()); + assertEquals(0, advanced.tierKills()); + } + + try (SqliteProgressRepository repository = new SqliteProgressRepository(database)) { + PlayerProgress persisted = repository.find(playerId).orElseThrow(); + assertEquals(AuraRank.I, persisted.rank()); + assertEquals(0, persisted.tierKills()); + } + } +}