diff --git a/design/log.md b/design/log.md index 83bba0b..f36f021 100644 --- a/design/log.md +++ b/design/log.md @@ -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`. diff --git a/design/user-stories/us-002-view-tree-progress.md b/design/user-stories/us-002-view-tree-progress.md index cfebe84..ed0641d 100644 --- a/design/user-stories/us-002-view-tree-progress.md +++ b/design/user-stories/us-002-view-tree-progress.md @@ -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 diff --git a/src/main/java/games/dmg/treefeller/BossBarFactory.java b/src/main/java/games/dmg/treefeller/BossBarFactory.java new file mode 100644 index 0000000..24a5e34 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/BossBarFactory.java @@ -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); +} diff --git a/src/main/java/games/dmg/treefeller/DelayedTaskScheduler.java b/src/main/java/games/dmg/treefeller/DelayedTaskScheduler.java new file mode 100644 index 0000000..3bd81ed --- /dev/null +++ b/src/main/java/games/dmg/treefeller/DelayedTaskScheduler.java @@ -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); +} diff --git a/src/main/java/games/dmg/treefeller/ProgressBossBarObserver.java b/src/main/java/games/dmg/treefeller/ProgressBossBarObserver.java new file mode 100644 index 0000000..fbb899a --- /dev/null +++ b/src/main/java/games/dmg/treefeller/ProgressBossBarObserver.java @@ -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 settings; + private final BossBarFactory bars; + private final DelayedTaskScheduler scheduler; + private final Map sessions = new HashMap<>(); + + public ProgressBossBarObserver( + Supplier 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 keys(Map source) { + return java.util.List.copyOf(source.keySet()); + } + } +} diff --git a/src/main/java/games/dmg/treefeller/ScheduledHandle.java b/src/main/java/games/dmg/treefeller/ScheduledHandle.java new file mode 100644 index 0000000..9efa589 --- /dev/null +++ b/src/main/java/games/dmg/treefeller/ScheduledHandle.java @@ -0,0 +1,7 @@ +package games.dmg.treefeller; + +/** Cancellation boundary for a delayed UI task. */ +@FunctionalInterface +public interface ScheduledHandle { + void cancel(); +} diff --git a/src/main/java/games/dmg/treefeller/TreeFellerCommand.java b/src/main/java/games/dmg/treefeller/TreeFellerCommand.java index f1cd205..b33c786 100644 --- a/src/main/java/games/dmg/treefeller/TreeFellerCommand.java +++ b/src/main/java/games/dmg/treefeller/TreeFellerCommand.java @@ -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 failureHandler; private final Function messages; + private final ToIntFunction thresholds; public TreeFellerCommand(PlayerStateStore states, Consumer failureHandler) { - this(states, failureHandler, TreeFellerCommand::defaultMessage); + this(states, failureHandler, TreeFellerCommand::defaultMessage, ignored -> 100); } public TreeFellerCommand( PlayerStateStore states, Consumer failureHandler, Function messages) { + this(states, failureHandler, messages, ignored -> 100); + } + + public TreeFellerCommand( + PlayerStateStore states, + Consumer failureHandler, + Function messages, + ToIntFunction 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 "); } diff --git a/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java b/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java index 97cedb5..6aa7611 100644 --- a/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java +++ b/src/main/java/games/dmg/treefeller/TreeFellerPlugin.java @@ -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; } diff --git a/src/test/java/games/dmg/treefeller/ProgressBossBarObserverTest.java b/src/test/java/games/dmg/treefeller/ProgressBossBarObserverTest.java new file mode 100644 index 0000000..6b7e8a2 --- /dev/null +++ b/src/test/java/games/dmg/treefeller/ProgressBossBarObserverTest.java @@ -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 timeout = new AtomicReference<>(); + AtomicReference 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)); + } + } +} diff --git a/src/test/java/games/dmg/treefeller/TreeFellerCommandTest.java b/src/test/java/games/dmg/treefeller/TreeFellerCommandTest.java index 29cac85..00bb3be 100644 --- a/src/test/java/games/dmg/treefeller/TreeFellerCommandTest.java +++ b/src/test/java/games/dmg/treefeller/TreeFellerCommandTest.java @@ -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 -> { });