feat(progress): track persistent creeper kills

This commit is contained in:
dmg
2026-08-08 14:07:35 -04:00
parent f9ad2d9461
commit 0bde7a6ae2
30 changed files with 1142 additions and 38 deletions
@@ -0,0 +1,40 @@
package games.dmg.creeperfear;
import games.dmg.creeperfear.listener.CreeperDeathListener;
import games.dmg.creeperfear.progress.ProgressService;
import games.dmg.creeperfear.progress.SqliteProgressRepository;
import java.nio.file.Path;
import java.util.logging.Level;
import org.bukkit.plugin.java.JavaPlugin;
public final class CreeperFearPlugin extends JavaPlugin {
private ProgressService progressService;
@Override
public void onEnable() {
try {
Path databasePath = getDataFolder().toPath().resolve("player-progress.sqlite3");
progressService = new ProgressService(new SqliteProgressRepository(databasePath));
getServer().getPluginManager().registerEvents(
new CreeperDeathListener(progressService, getLogger()), this);
getLogger().info("Creeper Fear enabled");
} catch (RuntimeException exception) {
getLogger().log(Level.SEVERE, "Creeper Fear could not initialize its progress storage", exception);
throw exception;
}
}
@Override
public void onDisable() {
if (progressService == null) {
return;
}
try {
progressService.close();
} catch (RuntimeException exception) {
getLogger().log(Level.SEVERE, "Creeper Fear could not close its progress storage cleanly", exception);
} finally {
progressService = null;
}
}
}
@@ -0,0 +1,65 @@
package games.dmg.creeperfear.listener;
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.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.Listener;
import org.bukkit.event.entity.EntityDeathEvent;
public final class CreeperDeathListener implements Listener {
private static final int RECENT_DEATH_LIMIT = 4096;
private final BiFunction<UUID, String, CompletableFuture<PlayerProgress>> killRecorder;
private final Logger logger;
private final Map<UUID, Boolean> recentDeaths = new LinkedHashMap<>(128, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<UUID, Boolean> eldest) {
return size() > RECENT_DEATH_LIMIT;
}
};
public CreeperDeathListener(ProgressService progressService, Logger logger) {
this(progressService::recordCreeperKill, logger);
}
CreeperDeathListener(
BiFunction<UUID, String, CompletableFuture<PlayerProgress>> killRecorder,
Logger logger) {
this.killRecorder = killRecorder;
this.logger = logger;
}
@EventHandler
public void onEntityDeath(EntityDeathEvent event) {
if (!(event.getEntity() instanceof Creeper creeper)) {
return;
}
Player player = CreeperKillAttributor.findPlayer(creeper).orElse(null);
if (player == null || !markNewDeath(creeper.getUniqueId())) {
return;
}
UUID playerId = player.getUniqueId();
String playerName = player.getName();
killRecorder.apply(playerId, playerName).whenComplete((progress, failure) -> {
if (failure != null) {
logger.log(Level.SEVERE,
"Could not persist creeper progress for " + playerName + " (" + playerId + ")",
failure);
}
});
}
private synchronized boolean markNewDeath(UUID creeperId) {
return recentDeaths.putIfAbsent(creeperId, Boolean.TRUE) == null;
}
}
@@ -0,0 +1,44 @@
package games.dmg.creeperfear.listener;
import java.util.Optional;
import org.bukkit.entity.Creeper;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.entity.Tameable;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.bukkit.projectiles.ProjectileSource;
public final class CreeperKillAttributor {
private CreeperKillAttributor() {
}
public static Optional<Player> findPlayer(Creeper creeper) {
Player bukkitKiller = creeper.getKiller();
if (bukkitKiller != null) {
return Optional.of(bukkitKiller);
}
if (!(creeper.getLastDamageCause() instanceof EntityDamageByEntityEvent damageEvent)) {
return Optional.empty();
}
return playerResponsibleFor(damageEvent.getDamager());
}
private static Optional<Player> playerResponsibleFor(Entity damager) {
if (damager instanceof Player player) {
return Optional.of(player);
}
if (damager instanceof Projectile projectile) {
ProjectileSource shooter = projectile.getShooter();
if (shooter instanceof Player player) {
return Optional.of(player);
}
}
if (damager instanceof Tameable tameable
&& tameable.isTamed()
&& tameable.getOwner() instanceof Player player) {
return Optional.of(player);
}
return Optional.empty();
}
}
@@ -0,0 +1,15 @@
package games.dmg.creeperfear.progress;
public enum AuraRank {
LOCKED,
I,
II,
III,
IV,
V,
VI;
public boolean isMaximum() {
return this == VI;
}
}
@@ -0,0 +1,21 @@
package games.dmg.creeperfear.progress;
import java.util.Objects;
import java.util.UUID;
public record PlayerProgress(UUID playerId, String lastKnownName, AuraRank rank, int tierKills) {
public PlayerProgress {
Objects.requireNonNull(playerId, "playerId");
Objects.requireNonNull(lastKnownName, "lastKnownName");
Objects.requireNonNull(rank, "rank");
if (lastKnownName.isBlank()) {
throw new IllegalArgumentException("lastKnownName must not be blank");
}
if (tierKills < 0) {
throw new IllegalArgumentException("tierKills must not be negative");
}
if (rank.isMaximum() && tierKills != 0) {
throw new IllegalArgumentException("rank VI cannot retain tier progress");
}
}
}
@@ -0,0 +1,15 @@
package games.dmg.creeperfear.progress;
import java.util.Optional;
import java.util.UUID;
public interface ProgressRepository extends AutoCloseable {
PlayerProgress recordCreeperKill(UUID playerId, String playerName);
Optional<PlayerProgress> find(UUID playerId);
PlayerProgress save(PlayerProgress progress);
@Override
void close();
}
@@ -0,0 +1,50 @@
package games.dmg.creeperfear.progress;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
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 ExecutorService executor;
public ProgressService(ProgressRepository repository) {
this.repository = repository;
this.executor = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "creeper-fear-progress");
thread.setDaemon(true);
return thread;
});
}
public CompletableFuture<PlayerProgress> recordCreeperKill(UUID playerId, String playerName) {
return CompletableFuture.supplyAsync(
() -> repository.recordCreeperKill(playerId, playerName), executor);
}
public CompletableFuture<Optional<PlayerProgress>> find(UUID playerId) {
return CompletableFuture.supplyAsync(() -> repository.find(playerId), executor);
}
public CompletableFuture<PlayerProgress> save(PlayerProgress progress) {
return CompletableFuture.supplyAsync(() -> repository.save(progress), executor);
}
@Override
public void close() {
executor.shutdown();
try {
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException exception) {
executor.shutdownNow();
Thread.currentThread().interrupt();
} finally {
repository.close();
}
}
}
@@ -0,0 +1,7 @@
package games.dmg.creeperfear.progress;
public final class ProgressStorageException extends RuntimeException {
public ProgressStorageException(String message, Throwable cause) {
super(message, cause);
}
}
@@ -0,0 +1,169 @@
package games.dmg.creeperfear.progress;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
import java.util.Optional;
import java.util.UUID;
public final class SqliteProgressRepository implements ProgressRepository {
private final Connection connection;
public SqliteProgressRepository(Path databasePath) {
try {
Path parent = databasePath.toAbsolutePath().getParent();
if (parent != null) {
Files.createDirectories(parent);
}
connection = DriverManager.getConnection("jdbc:sqlite:" + databasePath.toAbsolutePath());
initialize();
} catch (IOException | SQLException exception) {
throw new ProgressStorageException("Could not initialize player progress database", exception);
}
}
private void initialize() throws SQLException {
try (Statement statement = connection.createStatement()) {
statement.execute("PRAGMA journal_mode = WAL");
statement.execute("PRAGMA synchronous = NORMAL");
statement.execute("""
CREATE TABLE IF NOT EXISTS player_progress (
player_uuid TEXT PRIMARY KEY NOT NULL,
last_known_name TEXT NOT NULL,
rank TEXT NOT NULL CHECK (rank IN ('LOCKED', 'I', 'II', 'III', 'IV', 'V', 'VI')),
tier_kills INTEGER NOT NULL CHECK (tier_kills >= 0),
updated_at INTEGER NOT NULL
)
""");
}
}
@Override
public synchronized PlayerProgress recordCreeperKill(UUID playerId, String playerName) {
String sql = """
INSERT INTO player_progress(player_uuid, last_known_name, rank, tier_kills, updated_at)
VALUES (?, ?, 'LOCKED', 1, ?)
ON CONFLICT(player_uuid) DO UPDATE SET
last_known_name = excluded.last_known_name,
tier_kills = CASE
WHEN player_progress.rank = 'VI' THEN 0
ELSE player_progress.tier_kills + 1
END,
updated_at = excluded.updated_at
""";
try {
connection.setAutoCommit(false);
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, playerId.toString());
statement.setString(2, playerName);
statement.setLong(3, System.currentTimeMillis());
statement.executeUpdate();
}
PlayerProgress progress = findRequired(playerId);
connection.commit();
return progress;
} catch (SQLException | RuntimeException exception) {
rollbackAfterFailure(exception);
throw storageFailure("Could not record creeper kill for " + playerId, exception);
} finally {
restoreAutoCommit();
}
}
@Override
public synchronized Optional<PlayerProgress> find(UUID playerId) {
try {
return findInternal(playerId);
} catch (SQLException | RuntimeException exception) {
throw storageFailure("Could not load progress for " + playerId, exception);
}
}
@Override
public synchronized PlayerProgress save(PlayerProgress progress) {
String sql = """
INSERT INTO player_progress(player_uuid, last_known_name, rank, tier_kills, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(player_uuid) DO UPDATE SET
last_known_name = excluded.last_known_name,
rank = excluded.rank,
tier_kills = excluded.tier_kills,
updated_at = excluded.updated_at
""";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, progress.playerId().toString());
statement.setString(2, progress.lastKnownName());
statement.setString(3, progress.rank().name());
statement.setInt(4, progress.tierKills());
statement.setLong(5, System.currentTimeMillis());
statement.executeUpdate();
return progress;
} catch (SQLException exception) {
throw storageFailure("Could not save progress for " + progress.playerId(), exception);
}
}
private PlayerProgress findRequired(UUID playerId) throws SQLException {
return findInternal(playerId).orElseThrow(
() -> new ProgressStorageException("Progress disappeared for " + playerId, null));
}
private Optional<PlayerProgress> findInternal(UUID playerId) throws SQLException {
String sql = """
SELECT player_uuid, last_known_name, rank, tier_kills
FROM player_progress
WHERE player_uuid = ?
""";
try (PreparedStatement statement = connection.prepareStatement(sql)) {
statement.setString(1, playerId.toString());
try (ResultSet result = statement.executeQuery()) {
if (!result.next()) {
return Optional.empty();
}
return Optional.of(new PlayerProgress(
UUID.fromString(result.getString("player_uuid")),
result.getString("last_known_name"),
AuraRank.valueOf(result.getString("rank")),
result.getInt("tier_kills")));
}
}
}
private void rollbackAfterFailure(Throwable original) {
try {
connection.rollback();
} catch (SQLException rollbackFailure) {
original.addSuppressed(rollbackFailure);
}
}
private void restoreAutoCommit() {
try {
connection.setAutoCommit(true);
} catch (SQLException exception) {
throw new ProgressStorageException("Could not restore database transaction state", exception);
}
}
private ProgressStorageException storageFailure(String message, Throwable cause) {
if (cause instanceof ProgressStorageException storageException) {
return storageException;
}
return new ProgressStorageException(message, cause);
}
@Override
public synchronized void close() {
try {
connection.close();
} catch (SQLException exception) {
throw new ProgressStorageException("Could not close player progress database", exception);
}
}
}
+6
View File
@@ -0,0 +1,6 @@
name: CreeperFear
version: '${version}'
main: games.dmg.creeperfear.CreeperFearPlugin
api-version: '26.2'
author: dmg.games
description: Unlock Creeper Aura ranks by defeating creepers.
@@ -0,0 +1,43 @@
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.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.EntityDeathEvent;
import org.junit.jupiter.api.Test;
class CreeperDeathListenerTest {
@Test
void recordsOnePointWhenTheSameDeathIsObservedMoreThanOnce() {
UUID creeperId = UUID.randomUUID();
UUID playerId = UUID.randomUUID();
Creeper creeper = mock(Creeper.class);
Player player = mock(Player.class);
EntityDeathEvent event = mock(EntityDeathEvent.class);
@SuppressWarnings("unchecked")
BiFunction<UUID, String, CompletableFuture<PlayerProgress>> recorder = mock(BiFunction.class);
PlayerProgress progress = new PlayerProgress(playerId, "Player", AuraRank.LOCKED, 1);
when(event.getEntity()).thenReturn(creeper);
when(creeper.getUniqueId()).thenReturn(creeperId);
when(creeper.getKiller()).thenReturn(player);
when(player.getUniqueId()).thenReturn(playerId);
when(player.getName()).thenReturn("Player");
when(recorder.apply(playerId, "Player")).thenReturn(CompletableFuture.completedFuture(progress));
CreeperDeathListener listener = new CreeperDeathListener(recorder, Logger.getAnonymousLogger());
listener.onEntityDeath(event);
listener.onEntityDeath(event);
verify(recorder, times(1)).apply(playerId, "Player");
}
}
@@ -0,0 +1,58 @@
package games.dmg.creeperfear.listener;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import org.bukkit.entity.Creeper;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.entity.Tameable;
import org.bukkit.event.entity.EntityDamageByEntityEvent;
import org.junit.jupiter.api.Test;
class CreeperKillAttributorTest {
@Test
void attributesDirectPlayerKills() {
Creeper creeper = mock(Creeper.class);
Player player = mock(Player.class);
when(creeper.getKiller()).thenReturn(player);
assertEquals(player, CreeperKillAttributor.findPlayer(creeper).orElseThrow());
}
@Test
void attributesProjectileKillsToTheShooter() {
Creeper creeper = mock(Creeper.class);
Projectile projectile = mock(Projectile.class);
Player player = mock(Player.class);
EntityDamageByEntityEvent damage = mock(EntityDamageByEntityEvent.class);
when(creeper.getLastDamageCause()).thenReturn(damage);
when(damage.getDamager()).thenReturn(projectile);
when(projectile.getShooter()).thenReturn(player);
assertEquals(player, CreeperKillAttributor.findPlayer(creeper).orElseThrow());
}
@Test
void attributesTamedEntityKillsToTheOwner() {
Creeper creeper = mock(Creeper.class);
Tameable tameable = mock(Tameable.class);
Player player = mock(Player.class);
EntityDamageByEntityEvent damage = mock(EntityDamageByEntityEvent.class);
when(creeper.getLastDamageCause()).thenReturn(damage);
when(damage.getDamager()).thenReturn(tameable);
when(tameable.isTamed()).thenReturn(true);
when(tameable.getOwner()).thenReturn(player);
assertEquals(player, CreeperKillAttributor.findPlayer(creeper).orElseThrow());
}
@Test
void ignoresKillsWithoutAPlayerAttribution() {
Creeper creeper = mock(Creeper.class);
assertTrue(CreeperKillAttributor.findPlayer(creeper).isEmpty());
}
}
@@ -0,0 +1,64 @@
package games.dmg.creeperfear.progress;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.Test;
class ProgressServiceTest {
@Test
void recordsKillsAwayFromTheCallingThread() throws Exception {
BlockingRepository repository = new BlockingRepository();
Thread caller = Thread.currentThread();
try (ProgressService service = new ProgressService(repository)) {
var result = service.recordCreeperKill(UUID.randomUUID(), "Player");
assertTrue(repository.started.await(2, TimeUnit.SECONDS));
assertFalse(result.isDone());
assertNotEquals(caller, repository.worker.get());
repository.release.countDown();
result.get(2, TimeUnit.SECONDS);
}
}
private static final class BlockingRepository implements ProgressRepository {
private final CountDownLatch started = new CountDownLatch(1);
private final CountDownLatch release = new CountDownLatch(1);
private final AtomicReference<Thread> worker = new AtomicReference<>();
@Override
public PlayerProgress recordCreeperKill(UUID playerId, String playerName) {
worker.set(Thread.currentThread());
started.countDown();
try {
assertTrue(release.await(2, TimeUnit.SECONDS));
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException(exception);
}
return new PlayerProgress(playerId, playerName, AuraRank.LOCKED, 1);
}
@Override
public Optional<PlayerProgress> find(UUID playerId) {
return Optional.empty();
}
@Override
public PlayerProgress save(PlayerProgress progress) {
return progress;
}
@Override
public void close() {
}
}
}
@@ -0,0 +1,53 @@
package games.dmg.creeperfear.progress;
import static org.junit.jupiter.api.Assertions.assertEquals;
import java.nio.file.Path;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class SqliteProgressRepositoryTest {
@TempDir
Path tempDir;
@Test
void recordsCurrentTierProgressAndPersistsItAcrossRestart() {
UUID playerId = UUID.randomUUID();
Path database = tempDir.resolve("progress.sqlite3");
try (SqliteProgressRepository repository = new SqliteProgressRepository(database)) {
PlayerProgress first = repository.recordCreeperKill(playerId, "FirstName");
PlayerProgress second = repository.recordCreeperKill(playerId, "NewName");
assertEquals(AuraRank.LOCKED, first.rank());
assertEquals(1, first.tierKills());
assertEquals(2, second.tierKills());
assertEquals("NewName", second.lastKnownName());
}
try (SqliteProgressRepository repository = new SqliteProgressRepository(database)) {
PlayerProgress persisted = repository.find(playerId).orElseThrow();
assertEquals(AuraRank.LOCKED, persisted.rank());
assertEquals(2, persisted.tierKills());
assertEquals("NewName", persisted.lastKnownName());
}
}
@Test
void storesRankSeparatelyAndDoesNotAdvanceProgressAtRankSix() {
UUID playerId = UUID.randomUUID();
Path database = tempDir.resolve("maximum-rank.sqlite3");
try (SqliteProgressRepository repository = new SqliteProgressRepository(database)) {
repository.save(new PlayerProgress(playerId, "Player", AuraRank.VI, 0));
PlayerProgress progress = repository.recordCreeperKill(playerId, "RenamedPlayer");
assertEquals(AuraRank.VI, progress.rank());
assertEquals(0, progress.tierKills());
assertEquals("RenamedPlayer", progress.lastKnownName());
}
}
}