feat(progress): show unlock status and boss bar
This commit is contained in:
@@ -49,3 +49,10 @@
|
||||
- Added player-only usage and positional completion for `enabled`, `unlocked`, `undo`, and boolean values without exposing the administrative command tree.
|
||||
- Registered configurable player messages and kept `treefeller.command` separate from `treefeller.admin`.
|
||||
- Verified state preservation, reporting, autocomplete, metadata, and the complete build with `./gradlew clean check jar`.
|
||||
|
||||
### US-002 progress visibility completed
|
||||
|
||||
- Added `/treefeller unlocked` with every species' durable unlocked state or current count and live threshold.
|
||||
- Added one configurable boss bar per player with species and numeric progress, live threshold evaluation, timeout replacement, and five-second default cleanup.
|
||||
- Suppressed progress presentation for all ineligible events and removed it immediately when a species unlocks.
|
||||
- Verified command output, player-only completion, boss-bar presentation and timeout, cleanup, and the complete build with `./gradlew clean check jar`.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
type: User Story
|
||||
title: "US-002: View tree progress"
|
||||
description: Show players which species are unlocked and their progress toward the remaining unlocks.
|
||||
status: backlog
|
||||
status: done
|
||||
---
|
||||
|
||||
# US-002: View tree progress
|
||||
@@ -11,16 +11,16 @@ As a **player**, I want clear unlock and progress information so that I know whi
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `/treefeller unlocked` lists every supported species in a readable locked or unlocked state.
|
||||
- [ ] Each locked species includes the player's current qualifying-block count and active threshold.
|
||||
- [ ] Each unlocked species is clearly distinguished and is not presented as needing further progress.
|
||||
- [ ] Mining a qualifying block for a locked species displays or updates a boss bar with the species name and numeric progress toward its active threshold.
|
||||
- [ ] The boss bar reflects a changed threshold the next time qualifying progress is recorded.
|
||||
- [ ] The boss bar disappears after no qualifying block has been mined for five seconds by default.
|
||||
- [ ] Mining another qualifying block before timeout restarts the configured visibility period.
|
||||
- [ ] Boss-bar visibility duration, text, color, and style are configurable.
|
||||
- [ ] Progress feedback is not displayed for cancelled, ineligible, automatically felled, or already-unlocked blocks.
|
||||
- [ ] `/treefeller unlocked` and its autocomplete expose no administrative functionality.
|
||||
- [x] `/treefeller unlocked` lists every supported species in a readable locked or unlocked state.
|
||||
- [x] Each locked species includes the player's current qualifying-block count and active threshold.
|
||||
- [x] Each unlocked species is clearly distinguished and is not presented as needing further progress.
|
||||
- [x] Mining a qualifying block for a locked species displays or updates a boss bar with the species name and numeric progress toward its active threshold.
|
||||
- [x] The boss bar reflects a changed threshold the next time qualifying progress is recorded.
|
||||
- [x] The boss bar disappears after no qualifying block has been mined for five seconds by default.
|
||||
- [x] Mining another qualifying block before timeout restarts the configured visibility period.
|
||||
- [x] Boss-bar visibility duration, text, color, and style are configurable.
|
||||
- [x] Progress feedback is not displayed for cancelled, ineligible, automatically felled, or already-unlocked blocks.
|
||||
- [x] `/treefeller unlocked` and its autocomplete expose no administrative functionality.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import org.bukkit.boss.BossBar;
|
||||
|
||||
/** Creates a boss bar using current presentation settings. */
|
||||
@FunctionalInterface
|
||||
public interface BossBarFactory {
|
||||
BossBar create(TreeFellerSettings settings);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
/** Schedules a task after a number of server ticks. */
|
||||
@FunctionalInterface
|
||||
public interface DelayedTaskScheduler {
|
||||
ScheduledHandle schedule(Runnable task, long delayTicks);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
import org.bukkit.boss.BossBar;
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
/** Maintains one resetting, temporary species-progress boss bar per player. */
|
||||
public final class ProgressBossBarObserver implements ProgressObserver, AutoCloseable {
|
||||
private final Supplier<TreeFellerSettings> settings;
|
||||
private final BossBarFactory bars;
|
||||
private final DelayedTaskScheduler scheduler;
|
||||
private final Map<UUID, Session> sessions = new HashMap<>();
|
||||
|
||||
public ProgressBossBarObserver(
|
||||
Supplier<TreeFellerSettings> settings,
|
||||
BossBarFactory bars,
|
||||
DelayedTaskScheduler scheduler) {
|
||||
this.settings = settings;
|
||||
this.bars = bars;
|
||||
this.scheduler = scheduler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onProgress(Player player, ProgressUpdate update) {
|
||||
if (update.state().isUnlocked(update.species())) {
|
||||
hide(player.getUniqueId());
|
||||
return;
|
||||
}
|
||||
TreeFellerSettings current = settings.get();
|
||||
Session session = sessions.computeIfAbsent(
|
||||
player.getUniqueId(), ignored -> new Session(bars.create(current)));
|
||||
if (session.timeout != null) {
|
||||
session.timeout.cancel();
|
||||
}
|
||||
BossBar bar = session.bar;
|
||||
bar.setColor(current.bossBarColor());
|
||||
bar.setStyle(current.bossBarStyle());
|
||||
bar.setTitle(format(current.bossBarText(), update));
|
||||
double progress = Math.min(1.0D, (double) update.progress() / update.threshold());
|
||||
bar.setProgress(progress);
|
||||
bar.addPlayer(player);
|
||||
session.player = player;
|
||||
bar.setVisible(true);
|
||||
session.timeout = scheduler.schedule(
|
||||
() -> hideIfCurrent(player.getUniqueId(), session),
|
||||
current.bossBarTimeoutSeconds() * 20L);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
for (UUID playerId : ListCopy.keys(sessions)) {
|
||||
hide(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
private void hideIfCurrent(UUID playerId, Session expected) {
|
||||
if (sessions.get(playerId) == expected) {
|
||||
hide(playerId);
|
||||
}
|
||||
}
|
||||
|
||||
private void hide(UUID playerId) {
|
||||
Session removed = sessions.remove(playerId);
|
||||
if (removed == null) {
|
||||
return;
|
||||
}
|
||||
if (removed.timeout != null) {
|
||||
removed.timeout.cancel();
|
||||
}
|
||||
if (removed.player != null) {
|
||||
removed.bar.removePlayer(removed.player);
|
||||
}
|
||||
removed.bar.setVisible(false);
|
||||
}
|
||||
|
||||
private String format(String template, ProgressUpdate update) {
|
||||
return template
|
||||
.replace("{species}", update.species().displayName())
|
||||
.replace("{progress}", Long.toString(update.progress()))
|
||||
.replace("{threshold}", Integer.toString(update.threshold()))
|
||||
.replace('&', '\u00a7');
|
||||
}
|
||||
|
||||
private static final class Session {
|
||||
private final BossBar bar;
|
||||
private Player player;
|
||||
private ScheduledHandle timeout;
|
||||
|
||||
private Session(BossBar bar) {
|
||||
this.bar = bar;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ListCopy {
|
||||
private ListCopy() {
|
||||
}
|
||||
|
||||
private static java.util.List<UUID> keys(Map<UUID, Session> source) {
|
||||
return java.util.List.copyOf(source.keySet());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
/** Cancellation boundary for a delayed UI task. */
|
||||
@FunctionalInterface
|
||||
public interface ScheduledHandle {
|
||||
void cancel();
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.ToIntFunction;
|
||||
import org.bukkit.command.Command;
|
||||
import org.bukkit.command.CommandExecutor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
@@ -20,18 +21,28 @@ public final class TreeFellerCommand implements CommandExecutor, TabCompleter {
|
||||
private final PlayerStateStore states;
|
||||
private final Consumer<Exception> failureHandler;
|
||||
private final Function<String, String> messages;
|
||||
private final ToIntFunction<TreeSpecies> thresholds;
|
||||
|
||||
public TreeFellerCommand(PlayerStateStore states, Consumer<Exception> failureHandler) {
|
||||
this(states, failureHandler, TreeFellerCommand::defaultMessage);
|
||||
this(states, failureHandler, TreeFellerCommand::defaultMessage, ignored -> 100);
|
||||
}
|
||||
|
||||
public TreeFellerCommand(
|
||||
PlayerStateStore states,
|
||||
Consumer<Exception> failureHandler,
|
||||
Function<String, String> messages) {
|
||||
this(states, failureHandler, messages, ignored -> 100);
|
||||
}
|
||||
|
||||
public TreeFellerCommand(
|
||||
PlayerStateStore states,
|
||||
Consumer<Exception> failureHandler,
|
||||
Function<String, String> messages,
|
||||
ToIntFunction<TreeSpecies> thresholds) {
|
||||
this.states = states;
|
||||
this.failureHandler = failureHandler;
|
||||
this.messages = messages;
|
||||
this.thresholds = thresholds;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -45,12 +56,20 @@ public final class TreeFellerCommand implements CommandExecutor, TabCompleter {
|
||||
sendUsage(player);
|
||||
return true;
|
||||
}
|
||||
PlayerTreeFellerState state = stateFor(player);
|
||||
if (arguments[0].equalsIgnoreCase("unlocked")) {
|
||||
if (arguments.length != 1) {
|
||||
player.sendMessage("Usage: /treefeller unlocked");
|
||||
return true;
|
||||
}
|
||||
showUnlocks(player, state);
|
||||
return true;
|
||||
}
|
||||
if (!arguments[0].equalsIgnoreCase("enabled")) {
|
||||
sendUsage(player);
|
||||
return true;
|
||||
}
|
||||
|
||||
PlayerTreeFellerState state = stateFor(player);
|
||||
if (arguments.length == 1) {
|
||||
player.sendMessage("Tree Feller is " + (state.enabled() ? "enabled" : "disabled") + ".");
|
||||
if (state.locked()) {
|
||||
@@ -102,6 +121,18 @@ public final class TreeFellerCommand implements CommandExecutor, TabCompleter {
|
||||
.observeName(player.getName());
|
||||
}
|
||||
|
||||
private void showUnlocks(Player player, PlayerTreeFellerState state) {
|
||||
player.sendMessage("Tree Feller species:");
|
||||
for (TreeSpecies species : TreeSpecies.values()) {
|
||||
if (state.isUnlocked(species)) {
|
||||
player.sendMessage("- " + species.displayName() + ": unlocked");
|
||||
} else {
|
||||
player.sendMessage("- " + species.displayName() + ": locked ("
|
||||
+ state.progress(species) + "/" + thresholds.applyAsInt(species) + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void sendUsage(Player player) {
|
||||
player.sendMessage("Usage: /treefeller <enabled|unlocked|undo>");
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ public final class TreeFellerPlugin extends JavaPlugin {
|
||||
private YamlPlayerStateRepository playerStateRepository;
|
||||
private AutomaticBreakRegistry automaticBreakRegistry;
|
||||
private TreeDetector treeDetector;
|
||||
private ProgressBossBarObserver progressBossBarObserver;
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
@@ -25,12 +26,18 @@ public final class TreeFellerPlugin extends JavaPlugin {
|
||||
automaticBreakRegistry = new AutomaticBreakRegistry();
|
||||
treeDetector = new BukkitTreeDetector(new TreeStructureScanner(
|
||||
settings.maxSearchBlocks(), settings.maxSearchDistance()));
|
||||
progressBossBarObserver = new ProgressBossBarObserver(
|
||||
settingsService::current,
|
||||
current -> getServer().createBossBar(
|
||||
"", current.bossBarColor(), current.bossBarStyle()),
|
||||
(task, delayTicks) -> getServer().getScheduler()
|
||||
.runTaskLater(this, task, delayTicks)::cancel);
|
||||
TreeProgressListener progressListener = new TreeProgressListener(
|
||||
treeDetector,
|
||||
playerStateRepository,
|
||||
species -> settingsService.current().threshold(species),
|
||||
automaticBreakRegistry,
|
||||
(player, update) -> { },
|
||||
progressBossBarObserver,
|
||||
exception -> getLogger().log(
|
||||
Level.SEVERE, "Unable to persist Tree Feller progress", exception));
|
||||
getServer().getPluginManager().registerEvents(progressListener, this);
|
||||
@@ -39,7 +46,8 @@ public final class TreeFellerPlugin extends JavaPlugin {
|
||||
playerStateRepository,
|
||||
exception -> getLogger().log(
|
||||
Level.SEVERE, "Unable to persist Tree Feller preference", exception),
|
||||
key -> settingsService.current().message(key));
|
||||
key -> settingsService.current().message(key),
|
||||
species -> settingsService.current().threshold(species));
|
||||
PluginCommand command = Objects.requireNonNull(
|
||||
getCommand("treefeller"), "treefeller command missing from plugin.yml");
|
||||
command.setExecutor(playerCommand);
|
||||
@@ -50,6 +58,13 @@ public final class TreeFellerPlugin extends JavaPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
if (progressBossBarObserver != null) {
|
||||
progressBossBarObserver.close();
|
||||
}
|
||||
}
|
||||
|
||||
public TreeFellerSettingsService settingsService() {
|
||||
return settingsService;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package games.dmg.treefeller;
|
||||
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.bukkit.boss.BossBar;
|
||||
import org.bukkit.configuration.file.YamlConfiguration;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ProgressBossBarObserverTest {
|
||||
@Test
|
||||
void displaysNumericProgressAndHidesItAfterTheConfiguredIdlePeriod() throws Exception {
|
||||
TreeFellerSettings settings = defaults();
|
||||
BossBar bar = mock(BossBar.class);
|
||||
Player player = mock(Player.class);
|
||||
UUID playerId = UUID.randomUUID();
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
AtomicReference<Runnable> timeout = new AtomicReference<>();
|
||||
AtomicReference<Long> delay = new AtomicReference<>();
|
||||
ProgressBossBarObserver observer = new ProgressBossBarObserver(
|
||||
() -> settings,
|
||||
ignored -> bar,
|
||||
(task, ticks) -> {
|
||||
timeout.set(task);
|
||||
delay.set(ticks);
|
||||
return () -> { };
|
||||
});
|
||||
PlayerTreeFellerState state = PlayerTreeFellerState.initial(playerId, "Player")
|
||||
.withProgress(TreeSpecies.OAK, 25);
|
||||
|
||||
observer.onProgress(player, new ProgressUpdate(
|
||||
state, TreeSpecies.OAK, 25, 100, false));
|
||||
|
||||
verify(bar).setTitle("§aOak: 25/100");
|
||||
verify(bar).setProgress(0.25D);
|
||||
verify(bar).addPlayer(player);
|
||||
verify(bar).setVisible(true);
|
||||
org.junit.jupiter.api.Assertions.assertEquals(100L, delay.get());
|
||||
timeout.get().run();
|
||||
verify(bar).removePlayer(player);
|
||||
verify(bar).setVisible(false);
|
||||
}
|
||||
|
||||
private TreeFellerSettings defaults() throws Exception {
|
||||
try (InputStreamReader reader = new InputStreamReader(
|
||||
getClass().getClassLoader().getResourceAsStream("config.yml"),
|
||||
StandardCharsets.UTF_8)) {
|
||||
return TreeFellerSettings.load(YamlConfiguration.loadConfiguration(reader));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -53,6 +54,26 @@ class TreeFellerCommandTest {
|
||||
assertEquals(0, states.saveCount);
|
||||
}
|
||||
|
||||
@Test
|
||||
void listsEverySpeciesWithCurrentProgressOrUnlockedState() {
|
||||
UUID playerId = UUID.randomUUID();
|
||||
Player player = mock(Player.class);
|
||||
when(player.getUniqueId()).thenReturn(playerId);
|
||||
when(player.getName()).thenReturn("Player");
|
||||
InMemoryStateStore states = new InMemoryStateStore();
|
||||
states.state = PlayerTreeFellerState.initial(playerId, "Player")
|
||||
.withProgress(TreeSpecies.OAK, 12)
|
||||
.withUnlocked(TreeSpecies.BIRCH, true);
|
||||
TreeFellerCommand handler = new TreeFellerCommand(
|
||||
states, ignored -> { }, ignored -> "message", ignored -> 100);
|
||||
|
||||
handler.onCommand(player, mock(Command.class), "treefeller", new String[] {"unlocked"});
|
||||
|
||||
verify(player).sendMessage(contains("Oak: locked (12/100)"));
|
||||
verify(player).sendMessage(contains("Birch: unlocked"));
|
||||
verify(player, atLeastOnce()).sendMessage(contains("Mushroom"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void completesOnlyPlayerCommandSyntaxByArgumentPosition() {
|
||||
TreeFellerCommand handler = new TreeFellerCommand(new InMemoryStateStore(), ignored -> { });
|
||||
|
||||
Reference in New Issue
Block a user