feat(progress): count creeper explosion encounters

This commit is contained in:
dmg
2026-08-08 14:18:45 -04:00
parent c9e0a5d121
commit d1535bbf26
6 changed files with 143 additions and 0 deletions
+2
View File
@@ -30,6 +30,8 @@ Player progress is loaded into an online cache before synchronous aura decisions
A creeper death is attributed to a player when the player directly dealt the final damage or is attributable through a projectile or owned tameable. The progression service deduplicates a creeper death and records one point.
A self-destructing creeper also awards one point to every player its explosion would have hit. Explosion awards are deduplicated independently for each creeper and player, count before armor or aura mitigation, and use the same feedback as credited kills.
For explosions, player damage events establish whether an unlocked player would have been hit before armor mitigation. Each protected player receives their own rank multiplier. If any aura activates, the corresponding creeper explosion's affected block list is cleared for everyone. Rank VI cancels the player's damage event entirely.
## Commands and configuration
+1
View File
@@ -16,3 +16,4 @@ description: Chronological record of material changes to the Spigot Creeper Fear
- 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.
- Completed US-003 with temporary configurable boss bars, current-tier progress presentation, full-screen rank-up titles, and maximum-rank hiding.
- Extended US-001 so a self-destructing creeper awards deduplicated progress and normal feedback to every player its explosion hits.
@@ -14,6 +14,9 @@ As a **player**, I want my qualifying creeper kills recorded so that my Creeper
- [x] A creeper kill attributable to a player adds one point to that player's current-tier progress.
- [x] Direct melee kills and indirect kills attributable to the player, including projectiles and the player's tamed wolves, count.
- [x] A single creeper death cannot award progress more than once.
- [x] A creeper explosion awards one point to every player the explosion would have hit, including when armor or aura immunity reduces final damage to zero.
- [x] Each affected player receives at most one point from a given creeper explosion, while multiple affected players can each receive a point.
- [x] Explosion-earned progress produces the same progress and rank-up feedback as a credited kill.
- [x] Progress is stored using the player's UUID rather than their mutable name.
- [x] The player's current rank and current-tier progress are persisted separately.
- [x] Lifetime creeper-kill totals are not retained.
@@ -5,6 +5,7 @@ import games.dmg.creeperfear.aura.CreeperAuraListener;
import games.dmg.creeperfear.feedback.ProgressDisplay;
import games.dmg.creeperfear.feedback.ProgressFeedback;
import games.dmg.creeperfear.listener.CreeperDeathListener;
import games.dmg.creeperfear.listener.CreeperExplosionProgressListener;
import games.dmg.creeperfear.listener.PlayerSessionListener;
import games.dmg.creeperfear.progress.ProgressService;
import games.dmg.creeperfear.progress.SqliteProgressRepository;
@@ -27,6 +28,8 @@ public final class CreeperFearPlugin extends JavaPlugin {
progressFeedback = new ProgressFeedback(this, new ProgressDisplay(auraRules), displayTicks);
getServer().getPluginManager().registerEvents(
new CreeperDeathListener(progressService, progressFeedback, getLogger()), this);
getServer().getPluginManager().registerEvents(
new CreeperExplosionProgressListener(progressService, progressFeedback, getLogger()), this);
getServer().getPluginManager().registerEvents(
new PlayerSessionListener(progressService, getLogger()), this);
getServer().getPluginManager().registerEvents(
@@ -0,0 +1,85 @@
package games.dmg.creeperfear.listener;
import games.dmg.creeperfear.feedback.KillFeedback;
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.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.logging.Level;
import java.util.logging.Logger;
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;
public final class CreeperExplosionProgressListener implements Listener {
private static final int RECENT_AWARD_LIMIT = 4096;
private final BiFunction<UUID, String, CompletableFuture<PlayerProgress>> recorder;
private final Function<UUID, AuraRank> rankLookup;
private final KillFeedback feedback;
private final Logger logger;
private final Map<ExplosionAward, Boolean> recentAwards = new LinkedHashMap<>(128, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<ExplosionAward, Boolean> eldest) {
return size() > RECENT_AWARD_LIMIT;
}
};
public CreeperExplosionProgressListener(
ProgressService progressService, KillFeedback feedback, Logger logger) {
this(
progressService::recordCreeperKill,
playerId -> progressService.cached(playerId).map(PlayerProgress::rank).orElse(AuraRank.LOCKED),
feedback,
logger);
}
CreeperExplosionProgressListener(
BiFunction<UUID, String, CompletableFuture<PlayerProgress>> recorder,
Function<UUID, AuraRank> rankLookup,
KillFeedback feedback,
Logger logger) {
this.recorder = recorder;
this.rankLookup = rankLookup;
this.feedback = feedback;
this.logger = logger;
}
@EventHandler(priority = EventPriority.LOWEST)
public void onCreeperExplosionDamage(EntityDamageByEntityEvent event) {
if (event.getCause() != DamageCause.ENTITY_EXPLOSION
|| !(event.getDamager() instanceof Creeper creeper)
|| !(event.getEntity() instanceof Player player)) {
return;
}
UUID playerId = player.getUniqueId();
if (!markNew(creeper.getUniqueId(), playerId)) {
return;
}
AuraRank previousRank = rankLookup.apply(playerId);
recorder.apply(playerId, player.getName()).whenComplete((progress, failure) -> {
if (failure != null) {
logger.log(Level.SEVERE,
"Could not persist creeper explosion progress for " + playerId, failure);
return;
}
feedback.show(player, previousRank, progress);
});
}
private synchronized boolean markNew(UUID creeperId, UUID playerId) {
return recentAwards.putIfAbsent(new ExplosionAward(creeperId, playerId), Boolean.TRUE) == null;
}
private record ExplosionAward(UUID creeperId, UUID playerId) {
}
}
@@ -0,0 +1,49 @@
package games.dmg.creeperfear.listener;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import games.dmg.creeperfear.feedback.KillFeedback;
import games.dmg.creeperfear.progress.AuraRank;
import games.dmg.creeperfear.progress.PlayerProgress;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.function.BiFunction;
import java.util.logging.Logger;
import org.bukkit.entity.Creeper;
import org.bukkit.entity.Player;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.event.entity.EntityDamageEvent.DamageCause;
import org.junit.jupiter.api.Test;
class CreeperExplosionProgressListenerTest {
@Test
void awardsEachHitPlayerOnlyOnceAndShowsNormalKillFeedback() {
UUID creeperId = UUID.randomUUID();
UUID playerId = UUID.randomUUID();
Creeper creeper = mock(Creeper.class);
Player player = mock(Player.class);
EntityDamageByEntityEvent event = mock(EntityDamageByEntityEvent.class);
@SuppressWarnings("unchecked")
BiFunction<UUID, String, CompletableFuture<PlayerProgress>> recorder = mock(BiFunction.class);
KillFeedback feedback = mock(KillFeedback.class);
PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.I, 0);
when(event.getCause()).thenReturn(DamageCause.ENTITY_EXPLOSION);
when(event.getDamager()).thenReturn(creeper);
when(event.getEntity()).thenReturn(player);
when(creeper.getUniqueId()).thenReturn(creeperId);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Player");
when(recorder.apply(playerId, "Player")).thenReturn(CompletableFuture.completedFuture(progress));
CreeperExplosionProgressListener listener = new CreeperExplosionProgressListener(
recorder, ignored -> AuraRank.LOCKED, feedback, Logger.getAnonymousLogger());
listener.onCreeperExplosionDamage(event);
listener.onCreeperExplosionDamage(event);
verify(recorder, times(1)).apply(playerId, "Player");
verify(feedback).show(player, AuraRank.LOCKED, progress);
}
}