feat(feedback): show aura progression
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
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<UUID, String, CompletableFuture<PlayerProgress>> killRecorder;
|
||||
private final Function<UUID, AuraRank> rankLookup;
|
||||
private final KillFeedback feedback;
|
||||
private final Logger logger;
|
||||
private final Map<UUID, Boolean> 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<UUID, String, CompletableFuture<PlayerProgress>> killRecorder,
|
||||
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.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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user