feat(progress): show unlock status and boss bar

This commit is contained in:
dmg
2026-08-11 17:22:52 -04:00
parent 486780c049
commit 9600520e14
10 changed files with 274 additions and 15 deletions
+7
View File
@@ -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 -> { });