feat(feedback): show aura progression

This commit is contained in:
dmg
2026-08-08 14:15:40 -04:00
parent e35facd446
commit c9e0a5d121
9 changed files with 233 additions and 13 deletions
+1
View File
@@ -15,3 +15,4 @@ description: Chronological record of material changes to the Spigot Creeper Fear
- Added the initial plugin architecture. - 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-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-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.
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-003: Show progression and rank advancement" title: "US-003: Show progression and rank advancement"
description: Give players brief current-tier progress displays and prominent rank-up notifications. description: Give players brief current-tier progress displays and prominent rank-up notifications.
status: backlog status: done
--- ---
# US-003: Show progression and rank advancement # US-003: Show progression and rank advancement
@@ -11,15 +11,15 @@ As a **player**, I want visible progress and rank-up notifications so that I und
## Acceptance criteria ## Acceptance criteria
- [ ] After each qualifying creeper kill, a temporary boss bar shows the player's current state and progress toward the next rank. - [x] After each qualifying creeper kill, a temporary boss bar shows the player's current state and progress toward the next rank.
- [ ] A locked player sees current-tier progress toward Creeper Aura I. - [x] A locked player sees current-tier progress toward Creeper Aura I.
- [ ] A ranked player sees their current Roman-numeral rank, current-tier progress, and the next rank requirement. - [x] A ranked player sees their current Roman-numeral rank, current-tier progress, and the next rank requirement.
- [ ] The display duration is configurable and defaults to a short period measured in seconds. - [x] The display duration is configurable and defaults to a short period measured in seconds.
- [ ] The boss bar is hidden automatically when its display period expires. - [x] The boss bar is hidden automatically when its display period expires.
- [ ] A rank VI player no longer sees a progress boss bar. - [x] A rank VI player no longer sees a progress boss bar.
- [ ] Each newly attained rank displays a full-screen title naming the rank. - [x] Each newly attained rank displays a full-screen title naming the rank.
- [ ] Joining the server does not replay a previously acknowledged rank-up title. - [x] Joining the server does not replay a previously acknowledged rank-up title.
- [ ] Administrative changes update an online player's boss bar if it is currently visible. - [x] Administrative changes update an online player's boss bar if it is currently visible.
## Related ## Related
@@ -2,6 +2,8 @@ package games.dmg.creeperfear;
import games.dmg.creeperfear.aura.AuraRules; import games.dmg.creeperfear.aura.AuraRules;
import games.dmg.creeperfear.aura.CreeperAuraListener; 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.CreeperDeathListener;
import games.dmg.creeperfear.listener.PlayerSessionListener; import games.dmg.creeperfear.listener.PlayerSessionListener;
import games.dmg.creeperfear.progress.ProgressService; import games.dmg.creeperfear.progress.ProgressService;
@@ -12,15 +14,19 @@ import org.bukkit.plugin.java.JavaPlugin;
public final class CreeperFearPlugin extends JavaPlugin { public final class CreeperFearPlugin extends JavaPlugin {
private ProgressService progressService; private ProgressService progressService;
private ProgressFeedback progressFeedback;
@Override @Override
public void onEnable() { public void onEnable() {
try { try {
saveDefaultConfig();
Path databasePath = getDataFolder().toPath().resolve("player-progress.sqlite3"); Path databasePath = getDataFolder().toPath().resolve("player-progress.sqlite3");
AuraRules auraRules = AuraRules.defaults(); AuraRules auraRules = AuraRules.defaults();
progressService = new ProgressService(new SqliteProgressRepository(databasePath), auraRules); progressService = new ProgressService(new SqliteProgressRepository(databasePath), auraRules);
long displayTicks = Math.max(1L, getConfig().getLong("feedback.progress-bar-seconds", 5L)) * 20L;
progressFeedback = new ProgressFeedback(this, new ProgressDisplay(auraRules), displayTicks);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new CreeperDeathListener(progressService, getLogger()), this); new CreeperDeathListener(progressService, progressFeedback, getLogger()), this);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new PlayerSessionListener(progressService, getLogger()), this); new PlayerSessionListener(progressService, getLogger()), this);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
@@ -40,6 +46,10 @@ public final class CreeperFearPlugin extends JavaPlugin {
@Override @Override
public void onDisable() { public void onDisable() {
if (progressFeedback != null) {
progressFeedback.close();
progressFeedback = null;
}
if (progressService == null) { if (progressService == null) {
return; return;
} }
@@ -0,0 +1,10 @@
package games.dmg.creeperfear.feedback;
import games.dmg.creeperfear.progress.AuraRank;
import games.dmg.creeperfear.progress.PlayerProgress;
import org.bukkit.entity.Player;
@FunctionalInterface
public interface KillFeedback {
void show(Player player, AuraRank previousRank, PlayerProgress progress);
}
@@ -0,0 +1,37 @@
package games.dmg.creeperfear.feedback;
import games.dmg.creeperfear.aura.AuraRules;
import games.dmg.creeperfear.progress.AuraRank;
import games.dmg.creeperfear.progress.PlayerProgress;
import java.util.Optional;
public final class ProgressDisplay {
private final AuraRules rules;
public ProgressDisplay(AuraRules rules) {
this.rules = rules;
}
public State forProgress(PlayerProgress progress) {
if (progress.rank().isMaximum()) {
return new State("", 1.0, false);
}
int requirement = rules.requirementForCurrentRank(progress.rank());
String rank = progress.rank() == AuraRank.LOCKED
? "Creeper Aura: Locked"
: "Creeper Aura " + progress.rank().name();
String text = rank + "" + progress.tierKills() + " / " + requirement;
double fraction = Math.min(1.0, (double) progress.tierKills() / requirement);
return new State(text, fraction, true);
}
public Optional<String> rankUpTitle(AuraRank previous, AuraRank current) {
if (previous == current || current == AuraRank.LOCKED) {
return Optional.empty();
}
return Optional.of("Creeper Aura " + current.name());
}
public record State(String text, double fraction, boolean visible) {
}
}
@@ -0,0 +1,96 @@
package games.dmg.creeperfear.feedback;
import games.dmg.creeperfear.progress.AuraRank;
import games.dmg.creeperfear.progress.PlayerProgress;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import org.bukkit.Bukkit;
import org.bukkit.boss.BarColor;
import org.bukkit.boss.BarStyle;
import org.bukkit.boss.BossBar;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitTask;
public final class ProgressFeedback implements KillFeedback, AutoCloseable {
private final JavaPlugin plugin;
private final ProgressDisplay display;
private final long displayTicks;
private final Map<UUID, BossBar> bars = new HashMap<>();
private final Map<UUID, BukkitTask> hideTasks = new HashMap<>();
public ProgressFeedback(JavaPlugin plugin, ProgressDisplay display, long displayTicks) {
this.plugin = plugin;
this.display = display;
this.displayTicks = displayTicks;
}
@Override
public void show(Player player, AuraRank previousRank, PlayerProgress progress) {
Bukkit.getScheduler().runTask(plugin, () -> showOnServerThread(player, previousRank, progress));
}
private void showOnServerThread(Player player, AuraRank previousRank, PlayerProgress progress) {
display.rankUpTitle(previousRank, progress.rank()).ifPresent(title ->
player.sendTitle(title, "Unlocked!", 10, 70, 20));
ProgressDisplay.State state = display.forProgress(progress);
if (!state.visible() || !player.isOnline()) {
hide(player.getUniqueId());
return;
}
UUID playerId = player.getUniqueId();
BossBar bar = bars.computeIfAbsent(playerId, ignored -> {
BossBar created = Bukkit.createBossBar(state.text(), BarColor.GREEN, BarStyle.SOLID);
created.addPlayer(player);
return created;
});
bar.setTitle(state.text());
bar.setProgress(state.fraction());
bar.setVisible(true);
BukkitTask prior = hideTasks.remove(playerId);
if (prior != null) {
prior.cancel();
}
hideTasks.put(playerId, Bukkit.getScheduler().runTaskLater(
plugin, () -> hide(playerId), displayTicks));
}
public void refreshIfVisible(Player player, PlayerProgress progress) {
Bukkit.getScheduler().runTask(plugin, () -> {
BossBar bar = bars.get(player.getUniqueId());
if (bar == null) {
return;
}
ProgressDisplay.State state = display.forProgress(progress);
if (!state.visible()) {
hide(player.getUniqueId());
return;
}
bar.setTitle(state.text());
bar.setProgress(state.fraction());
});
}
public void hide(UUID playerId) {
BukkitTask task = hideTasks.remove(playerId);
if (task != null) {
task.cancel();
}
BossBar bar = bars.remove(playerId);
if (bar != null) {
bar.removeAll();
}
}
@Override
public void close() {
hideTasks.values().forEach(BukkitTask::cancel);
hideTasks.clear();
bars.values().forEach(BossBar::removeAll);
bars.clear();
}
}
@@ -1,5 +1,7 @@
package games.dmg.creeperfear.listener; 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.PlayerProgress;
import games.dmg.creeperfear.progress.ProgressService; import games.dmg.creeperfear.progress.ProgressService;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -7,6 +9,7 @@ import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.function.BiFunction; import java.util.function.BiFunction;
import java.util.function.Function;
import java.util.logging.Level; import java.util.logging.Level;
import java.util.logging.Logger; import java.util.logging.Logger;
import org.bukkit.entity.Creeper; import org.bukkit.entity.Creeper;
@@ -19,6 +22,8 @@ public final class CreeperDeathListener implements Listener {
private static final int RECENT_DEATH_LIMIT = 4096; private static final int RECENT_DEATH_LIMIT = 4096;
private final BiFunction<UUID, String, CompletableFuture<PlayerProgress>> killRecorder; private final BiFunction<UUID, String, CompletableFuture<PlayerProgress>> killRecorder;
private final Function<UUID, AuraRank> rankLookup;
private final KillFeedback feedback;
private final Logger logger; private final Logger logger;
private final Map<UUID, Boolean> recentDeaths = new LinkedHashMap<>(128, 0.75f, true) { private final Map<UUID, Boolean> recentDeaths = new LinkedHashMap<>(128, 0.75f, true) {
@Override @Override
@@ -27,14 +32,31 @@ public final class CreeperDeathListener implements Listener {
} }
}; };
public CreeperDeathListener(ProgressService progressService, Logger logger) { public CreeperDeathListener(
this(progressService::recordCreeperKill, logger); ProgressService progressService,
KillFeedback feedback,
Logger logger) {
this(
progressService::recordCreeperKill,
playerId -> progressService.cached(playerId).map(PlayerProgress::rank).orElse(AuraRank.LOCKED),
feedback,
logger);
} }
CreeperDeathListener( CreeperDeathListener(
BiFunction<UUID, String, CompletableFuture<PlayerProgress>> killRecorder, BiFunction<UUID, String, CompletableFuture<PlayerProgress>> killRecorder,
Logger logger) { Logger logger) {
this(killRecorder, ignored -> AuraRank.LOCKED, (player, rank, progress) -> { }, logger);
}
CreeperDeathListener(
BiFunction<UUID, String, CompletableFuture<PlayerProgress>> killRecorder,
Function<UUID, AuraRank> rankLookup,
KillFeedback feedback,
Logger logger) {
this.killRecorder = killRecorder; this.killRecorder = killRecorder;
this.rankLookup = rankLookup;
this.feedback = feedback;
this.logger = logger; this.logger = logger;
} }
@@ -50,12 +72,15 @@ public final class CreeperDeathListener implements Listener {
UUID playerId = player.getUniqueId(); UUID playerId = player.getUniqueId();
String playerName = player.getName(); String playerName = player.getName();
AuraRank previousRank = rankLookup.apply(playerId);
killRecorder.apply(playerId, playerName).whenComplete((progress, failure) -> { killRecorder.apply(playerId, playerName).whenComplete((progress, failure) -> {
if (failure != null) { if (failure != null) {
logger.log(Level.SEVERE, logger.log(Level.SEVERE,
"Could not persist creeper progress for " + playerName + " (" + playerId + ")", "Could not persist creeper progress for " + playerName + " (" + playerId + ")",
failure); failure);
return;
} }
feedback.show(player, previousRank, progress);
}); });
} }
+2
View File
@@ -0,0 +1,2 @@
feedback:
progress-bar-seconds: 5
@@ -0,0 +1,39 @@
package games.dmg.creeperfear.feedback;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import games.dmg.creeperfear.aura.AuraRules;
import games.dmg.creeperfear.progress.AuraRank;
import games.dmg.creeperfear.progress.PlayerProgress;
import java.util.UUID;
import org.junit.jupiter.api.Test;
class ProgressDisplayTest {
private final ProgressDisplay display = new ProgressDisplay(AuraRules.defaults());
@Test
void describesLockedCurrentTierProgress() {
PlayerProgress progress = new PlayerProgress(UUID.randomUUID(), "Player", AuraRank.LOCKED, 42);
ProgressDisplay.State state = display.forProgress(progress);
assertEquals("Creeper Aura: Locked — 42 / 100", state.text());
assertEquals(0.42, state.fraction(), 0.0001);
assertTrue(state.visible());
}
@Test
void hidesTheProgressBarAtMaximumRank() {
PlayerProgress progress = new PlayerProgress(UUID.randomUUID(), "Player", AuraRank.VI, 0);
assertFalse(display.forProgress(progress).visible());
}
@Test
void identifiesNewlyUnlockedRanks() {
assertEquals("Creeper Aura III", display.rankUpTitle(AuraRank.II, AuraRank.III).orElseThrow());
assertTrue(display.rankUpTitle(AuraRank.III, AuraRank.III).isEmpty());
}
}