From c9e0a5d1216d472a9bd11914c3dc632aeb1813bd Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 8 Aug 2026 14:15:40 -0400 Subject: [PATCH] feat(feedback): show aura progression --- design/log.md | 1 + ...3-show-progression-and-rank-advancement.md | 20 ++-- .../dmg/creeperfear/CreeperFearPlugin.java | 12 ++- .../creeperfear/feedback/KillFeedback.java | 10 ++ .../creeperfear/feedback/ProgressDisplay.java | 37 +++++++ .../feedback/ProgressFeedback.java | 96 +++++++++++++++++++ .../listener/CreeperDeathListener.java | 29 +++++- src/main/resources/config.yml | 2 + .../feedback/ProgressDisplayTest.java | 39 ++++++++ 9 files changed, 233 insertions(+), 13 deletions(-) create mode 100644 src/main/java/games/dmg/creeperfear/feedback/KillFeedback.java create mode 100644 src/main/java/games/dmg/creeperfear/feedback/ProgressDisplay.java create mode 100644 src/main/java/games/dmg/creeperfear/feedback/ProgressFeedback.java create mode 100644 src/main/resources/config.yml create mode 100644 src/test/java/games/dmg/creeperfear/feedback/ProgressDisplayTest.java diff --git a/design/log.md b/design/log.md index c2b489d..369e24d 100644 --- a/design/log.md +++ b/design/log.md @@ -15,3 +15,4 @@ description: Chronological record of material changes to the Spigot Creeper Fear - 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. +- Completed US-003 with temporary configurable boss bars, current-tier progress presentation, full-screen rank-up titles, and maximum-rank hiding. diff --git a/design/user-stories/us-003-show-progression-and-rank-advancement.md b/design/user-stories/us-003-show-progression-and-rank-advancement.md index 94fa4bb..eae99e9 100644 --- a/design/user-stories/us-003-show-progression-and-rank-advancement.md +++ b/design/user-stories/us-003-show-progression-and-rank-advancement.md @@ -2,7 +2,7 @@ type: User Story title: "US-003: Show progression and rank advancement" description: Give players brief current-tier progress displays and prominent rank-up notifications. -status: backlog +status: done --- # 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 -- [ ] 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. -- [ ] 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. -- [ ] The boss bar is hidden automatically when its display period expires. -- [ ] A rank VI player no longer sees a progress boss bar. -- [ ] Each newly attained rank displays a full-screen title naming the rank. -- [ ] 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] After each qualifying creeper kill, a temporary boss bar shows the player's current state and progress toward the next rank. +- [x] A locked player sees current-tier progress toward Creeper Aura I. +- [x] A ranked player sees their current Roman-numeral rank, current-tier progress, and the next rank requirement. +- [x] The display duration is configurable and defaults to a short period measured in seconds. +- [x] The boss bar is hidden automatically when its display period expires. +- [x] A rank VI player no longer sees a progress boss bar. +- [x] Each newly attained rank displays a full-screen title naming the rank. +- [x] Joining the server does not replay a previously acknowledged rank-up title. +- [x] Administrative changes update an online player's boss bar if it is currently visible. ## Related diff --git a/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java b/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java index 7fb9a53..e6eeda0 100644 --- a/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java +++ b/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java @@ -2,6 +2,8 @@ package games.dmg.creeperfear; import games.dmg.creeperfear.aura.AuraRules; 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.PlayerSessionListener; import games.dmg.creeperfear.progress.ProgressService; @@ -12,15 +14,19 @@ import org.bukkit.plugin.java.JavaPlugin; public final class CreeperFearPlugin extends JavaPlugin { private ProgressService progressService; + private ProgressFeedback progressFeedback; @Override public void onEnable() { try { + saveDefaultConfig(); Path databasePath = getDataFolder().toPath().resolve("player-progress.sqlite3"); AuraRules auraRules = AuraRules.defaults(); 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( - new CreeperDeathListener(progressService, getLogger()), this); + new CreeperDeathListener(progressService, progressFeedback, getLogger()), this); getServer().getPluginManager().registerEvents( new PlayerSessionListener(progressService, getLogger()), this); getServer().getPluginManager().registerEvents( @@ -40,6 +46,10 @@ public final class CreeperFearPlugin extends JavaPlugin { @Override public void onDisable() { + if (progressFeedback != null) { + progressFeedback.close(); + progressFeedback = null; + } if (progressService == null) { return; } diff --git a/src/main/java/games/dmg/creeperfear/feedback/KillFeedback.java b/src/main/java/games/dmg/creeperfear/feedback/KillFeedback.java new file mode 100644 index 0000000..5107f6f --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/feedback/KillFeedback.java @@ -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); +} diff --git a/src/main/java/games/dmg/creeperfear/feedback/ProgressDisplay.java b/src/main/java/games/dmg/creeperfear/feedback/ProgressDisplay.java new file mode 100644 index 0000000..ac336aa --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/feedback/ProgressDisplay.java @@ -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 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) { + } +} diff --git a/src/main/java/games/dmg/creeperfear/feedback/ProgressFeedback.java b/src/main/java/games/dmg/creeperfear/feedback/ProgressFeedback.java new file mode 100644 index 0000000..8eaf7c7 --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/feedback/ProgressFeedback.java @@ -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 bars = new HashMap<>(); + private final Map 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(); + } +} diff --git a/src/main/java/games/dmg/creeperfear/listener/CreeperDeathListener.java b/src/main/java/games/dmg/creeperfear/listener/CreeperDeathListener.java index da2be6b..67412ef 100644 --- a/src/main/java/games/dmg/creeperfear/listener/CreeperDeathListener.java +++ b/src/main/java/games/dmg/creeperfear/listener/CreeperDeathListener.java @@ -1,5 +1,7 @@ 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; @@ -7,6 +9,7 @@ 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; @@ -19,6 +22,8 @@ public final class CreeperDeathListener implements Listener { private static final int RECENT_DEATH_LIMIT = 4096; private final BiFunction> killRecorder; + private final Function rankLookup; + private final KillFeedback feedback; private final Logger logger; private final Map recentDeaths = new LinkedHashMap<>(128, 0.75f, true) { @Override @@ -27,14 +32,31 @@ public final class CreeperDeathListener implements Listener { } }; - public CreeperDeathListener(ProgressService progressService, Logger logger) { - this(progressService::recordCreeperKill, logger); + public CreeperDeathListener( + ProgressService progressService, + KillFeedback feedback, + Logger logger) { + this( + progressService::recordCreeperKill, + playerId -> progressService.cached(playerId).map(PlayerProgress::rank).orElse(AuraRank.LOCKED), + feedback, + logger); } CreeperDeathListener( BiFunction> killRecorder, Logger logger) { + this(killRecorder, ignored -> AuraRank.LOCKED, (player, rank, progress) -> { }, logger); + } + + CreeperDeathListener( + BiFunction> killRecorder, + Function rankLookup, + KillFeedback feedback, + Logger logger) { this.killRecorder = killRecorder; + this.rankLookup = rankLookup; + this.feedback = feedback; this.logger = logger; } @@ -50,12 +72,15 @@ public final class CreeperDeathListener implements Listener { UUID playerId = player.getUniqueId(); String playerName = player.getName(); + AuraRank previousRank = rankLookup.apply(playerId); killRecorder.apply(playerId, playerName).whenComplete((progress, failure) -> { if (failure != null) { logger.log(Level.SEVERE, "Could not persist creeper progress for " + playerName + " (" + playerId + ")", failure); + return; } + feedback.show(player, previousRank, progress); }); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..faccf9e --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,2 @@ +feedback: + progress-bar-seconds: 5 diff --git a/src/test/java/games/dmg/creeperfear/feedback/ProgressDisplayTest.java b/src/test/java/games/dmg/creeperfear/feedback/ProgressDisplayTest.java new file mode 100644 index 0000000..37f2a1e --- /dev/null +++ b/src/test/java/games/dmg/creeperfear/feedback/ProgressDisplayTest.java @@ -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()); + } +}