From 90851a0d874c2619a02d908900a313c2cf0d3a70 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 15 Aug 2026 00:03:09 -0400 Subject: [PATCH] feat(commands): show player stealth progress --- design/log.md | 2 + .../us-003-check-personal-progress.md | 20 +++--- .../spigotstealth/SpigotStealthPlugin.java | 3 + .../dmg/spigotstealth/StealthCommand.java | 51 ++++++++++++++ .../StealthProgressFormatter.java | 32 +++++++++ .../dmg/spigotstealth/StealthCommandTest.java | 69 +++++++++++++++++++ .../StealthProgressFormatterTest.java | 15 ++++ 7 files changed, 184 insertions(+), 8 deletions(-) create mode 100644 src/main/java/games/dmg/spigotstealth/StealthCommand.java create mode 100644 src/main/java/games/dmg/spigotstealth/StealthProgressFormatter.java create mode 100644 src/test/java/games/dmg/spigotstealth/StealthCommandTest.java create mode 100644 src/test/java/games/dmg/spigotstealth/StealthProgressFormatterTest.java diff --git a/design/log.md b/design/log.md index aaa0193..510115f 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,8 @@ ## 2026-08-14 +- **Completion**: Completed US-003 with `/stealth progress`, live unsaved interval inclusion, configured target and remaining output, concise duration formatting, and unlocked usage guidance; verified the full Gradle build. +- **Implementation**: Began US-003 with player-facing progress command and human-readable duration tests. - **Completion**: Completed US-001 with direct potion-drink effect attribution, monotonic elapsed-time accumulation, safe refresh and stop transitions, durable UUID progress, exact-once unlocking, and title plus chat presentation; verified the full Gradle build. - **Implementation**: Began US-001 with monotonic-clock progression tests and potion-cause event attribution. - **Completion**: Completed US-005 with validated eight-hour defaults and configurable messages, UUID-keyed immutable state, RFC 3339 timing metadata, safe invalid-record defaults, unknown-field preservation, atomic YAML replacement, periodic saves, and serialized off-thread persistence; verified the full Gradle build. diff --git a/design/user-stories/us-003-check-personal-progress.md b/design/user-stories/us-003-check-personal-progress.md index 93bd135..93293ee 100644 --- a/design/user-stories/us-003-check-personal-progress.md +++ b/design/user-stories/us-003-check-personal-progress.md @@ -2,7 +2,7 @@ type: User Story title: "US-003: Check personal stealth progress" description: Show a player their accumulated qualifying invisibility time and stealth unlock status. -status: backlog +status: done --- # US-003: Check personal stealth progress @@ -11,13 +11,17 @@ As a **player**, I want to check my stealth progress so that I know how close I ## Acceptance criteria -- [ ] `/stealth progress` reports the player's accumulated qualifying invisibility time. -- [ ] Before unlock, the command reports the configured target and remaining duration. -- [ ] While a qualifying effect is active, the report includes elapsed time not yet written during the current tracking interval. -- [ ] After unlock, the command clearly reports that stealth is unlocked and explains how to prepare a concealed login. -- [ ] Durations are presented in a concise, human-readable form. -- [ ] Repeated command use does not change progression or concealment state. -- [ ] The command has clear usage metadata and an appropriate player permission. +- [x] `/stealth progress` reports the player's accumulated qualifying invisibility time. +- [x] Before unlock, the command reports the configured target and remaining duration. +- [x] While a qualifying effect is active, the report includes elapsed time not yet written during the current tracking interval. +- [x] After unlock, the command clearly reports that stealth is unlocked and explains how to prepare a concealed login. +- [x] Durations are presented in a concise, human-readable form. +- [x] Repeated command use does not change progression or concealment state. +- [x] The command has clear usage metadata and an appropriate player permission. + +## Validation + +Automated tests verify concise duration formatting, live in-flight progress, configured target and remaining output, command non-mutation, and unlocked usage guidance. Command metadata and the complete `./gradlew clean check jar` lifecycle pass. ## Related diff --git a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java index b3fc94b..df621a0 100644 --- a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java +++ b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java @@ -2,6 +2,7 @@ package games.dmg.spigotstealth; import java.nio.file.Path; import java.time.Clock; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; import org.bukkit.plugin.java.JavaPlugin; @@ -58,6 +59,8 @@ public final class SpigotStealthPlugin extends JavaPlugin { manager, settings.unlockThreshold(), System::nanoTime, notifier); getServer().getPluginManager().registerEvents( new InvisibilityEffectListener(progression, Clock.systemUTC()), this); + Objects.requireNonNull(getCommand("stealth"), "stealth command") + .setExecutor(new StealthCommand(manager, progression, settings)); getServer().getScheduler().runTaskTimer(this, ignored -> { for (java.util.UUID playerId : progression.activePlayerIds()) { logSaveFailure(progression.checkpoint(playerId)); diff --git a/src/main/java/games/dmg/spigotstealth/StealthCommand.java b/src/main/java/games/dmg/spigotstealth/StealthCommand.java new file mode 100644 index 0000000..aa0383e --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/StealthCommand.java @@ -0,0 +1,51 @@ +package games.dmg.spigotstealth; + +import java.util.Locale; +import java.util.Objects; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +/** Player-facing progress command. */ +public final class StealthCommand implements CommandExecutor { + private final StealthStateManager stateManager; + private final QualifyingInvisibilityService progression; + private final StealthSettings settings; + + public StealthCommand( + StealthStateManager stateManager, + QualifyingInvisibilityService progression, + StealthSettings settings) { + this.stateManager = Objects.requireNonNull(stateManager, "stateManager"); + this.progression = Objects.requireNonNull(progression, "progression"); + this.settings = Objects.requireNonNull(settings, "settings"); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { + if (!(sender instanceof Player player)) { + sender.sendMessage("Only players can check personal stealth progress."); + return true; + } + if (arguments.length != 1 || !"progress".equals(arguments[0].toLowerCase(Locale.ROOT))) { + sender.sendMessage("Usage: /" + label + " progress"); + return true; + } + PlayerStealthState state = stateManager.snapshot().player(player.getUniqueId()); + long accumulated = progression.currentAccumulatedMillis(player.getUniqueId()); + if (state.unlocked()) { + player.sendMessage("Stealth is unlocked."); + player.sendMessage("Drink an invisibility potion and log out while invisible to conceal your next login."); + return true; + } + long target = settings.unlockThreshold().toMillis(); + long remaining = Math.max(0L, target - accumulated); + String message = settings.progressMessage() + .replace("{progress}", StealthProgressFormatter.duration(accumulated)) + .replace("{target}", StealthProgressFormatter.duration(target)) + .replace("{remaining}", StealthProgressFormatter.duration(remaining)); + player.sendMessage(message); + return true; + } +} diff --git a/src/main/java/games/dmg/spigotstealth/StealthProgressFormatter.java b/src/main/java/games/dmg/spigotstealth/StealthProgressFormatter.java new file mode 100644 index 0000000..cb1453b --- /dev/null +++ b/src/main/java/games/dmg/spigotstealth/StealthProgressFormatter.java @@ -0,0 +1,32 @@ +package games.dmg.spigotstealth; + +/** Human-readable duration and progress presentation. */ +public final class StealthProgressFormatter { + private StealthProgressFormatter() { } + + public static String duration(long milliseconds) { + long totalSeconds = Math.max(0L, milliseconds) / 1000L; + long hours = totalSeconds / 3600L; + long minutes = (totalSeconds % 3600L) / 60L; + long seconds = totalSeconds % 60L; + StringBuilder formatted = new StringBuilder(); + if (hours > 0L) { + formatted.append(hours).append('h'); + } + if (minutes > 0L) { + appendSpace(formatted); + formatted.append(minutes).append('m'); + } + if (seconds > 0L || formatted.length() == 0) { + appendSpace(formatted); + formatted.append(seconds).append('s'); + } + return formatted.toString(); + } + + private static void appendSpace(StringBuilder value) { + if (value.length() > 0) { + value.append(' '); + } + } +} diff --git a/src/test/java/games/dmg/spigotstealth/StealthCommandTest.java b/src/test/java/games/dmg/spigotstealth/StealthCommandTest.java new file mode 100644 index 0000000..59fd966 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/StealthCommandTest.java @@ -0,0 +1,69 @@ +package games.dmg.spigotstealth; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicLong; +import org.bukkit.command.Command; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class StealthCommandTest { + @Test + void reportsLiveProgressTargetAndRemainingTimeWithoutMutatingState() { + UUID playerId = UUID.randomUUID(); + AtomicLong nanos = new AtomicLong(); + try (StealthStateManager manager = manager()) { + manager.update(state -> state.withPlayer(state.player(playerId).withAccumulatedMillis(Duration.ofHours(2).toMillis()))).join(); + QualifyingInvisibilityService progression = new QualifyingInvisibilityService( + manager, Duration.ofHours(8), nanos::get, ignored -> { }); + progression.begin(playerId, "Alex", Instant.EPOCH).join(); + nanos.addAndGet(Duration.ofMinutes(5).toNanos()); + Player player = player(playerId); + StealthCommand command = new StealthCommand(manager, progression, StealthSettings.from(Map.of())); + + assertTrue(command.onCommand(player, mock(Command.class), "stealth", new String[] {"progress"})); + verify(player).sendMessage(contains("2h 5m / 8h")); + verify(player).sendMessage(contains("5h 55m remaining")); + org.junit.jupiter.api.Assertions.assertEquals(Duration.ofHours(2).toMillis(), manager.snapshot().player(playerId).accumulatedMillis()); + } + } + + @Test + void unlockedPlayerReceivesUsageGuidance() { + UUID playerId = UUID.randomUUID(); + try (StealthStateManager manager = manager()) { + PlayerStealthState unlocked = new PlayerStealthState(playerId, "Alex", Duration.ofHours(8).toMillis(), true, false, false, null, Map.of()); + manager.update(state -> state.withPlayer(unlocked)).join(); + QualifyingInvisibilityService progression = new QualifyingInvisibilityService( + manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); + Player player = player(playerId); + StealthCommand command = new StealthCommand(manager, progression, StealthSettings.from(Map.of())); + + command.onCommand(player, mock(Command.class), "stealth", new String[] {"progress"}); + verify(player).sendMessage(contains("unlocked")); + verify(player).sendMessage(contains("log out while invisible")); + } + } + + private static Player player(UUID playerId) { + Player player = mock(Player.class); + when(player.getUniqueId()).thenReturn(playerId); + return player; + } + + private static StealthStateManager manager() { + StealthStateRepository repository = new StealthStateRepository() { + @Override public PersistentStealthState load() { return new PersistentStealthState(Map.of(), Map.of()); } + @Override public void save(PersistentStealthState state) { } + }; + return new StealthStateManager(repository, new PersistentStealthState(Map.of(), Map.of())); + } +} diff --git a/src/test/java/games/dmg/spigotstealth/StealthProgressFormatterTest.java b/src/test/java/games/dmg/spigotstealth/StealthProgressFormatterTest.java new file mode 100644 index 0000000..84d2d04 --- /dev/null +++ b/src/test/java/games/dmg/spigotstealth/StealthProgressFormatterTest.java @@ -0,0 +1,15 @@ +package games.dmg.spigotstealth; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class StealthProgressFormatterTest { + @Test + void presentsConciseHumanReadableDurations() { + assertEquals("2h 5m 9s", StealthProgressFormatter.duration(Duration.ofHours(2).plusMinutes(5).plusSeconds(9).toMillis())); + assertEquals("45s", StealthProgressFormatter.duration(45_900L)); + assertEquals("0s", StealthProgressFormatter.duration(0L)); + } +}