From b0da1508b6939ce7efea1334bb4d97cda622b326 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 4 Sep 2026 23:37:20 -0400 Subject: [PATCH] feat(commands): add contextual completion and unlock listing --- design/log.md | 4 ++ .../us-003-check-personal-progress.md | 4 +- .../us-004-administer-player-stealth.md | 8 ++- .../spigotstealth/KnownPlayerResolver.java | 11 ++++ .../spigotstealth/SpigotStealthPlugin.java | 24 ++++--- .../spigotstealth/StealthAdminCommand.java | 56 ++++++++++++++-- .../StealthAdministrationService.java | 11 ++++ .../dmg/spigotstealth/StealthCommand.java | 13 +++- src/main/resources/plugin.yml | 2 +- .../StealthAdminCommandTest.java | 65 +++++++++++++++++++ .../StealthAdministrationServiceTest.java | 23 +++++++ .../dmg/spigotstealth/StealthCommandTest.java | 19 ++++++ 12 files changed, 220 insertions(+), 20 deletions(-) diff --git a/design/log.md b/design/log.md index 54e6cfa..655f7a6 100644 --- a/design/log.md +++ b/design/log.md @@ -1,5 +1,9 @@ # Spigot Stealth Design Log +## 2026-09-04 + +- **Completion**: Extended US-003 and US-004 with contextual, prefix-filtered command completion that suppresses generic player suggestions, plus an administrative list of all known online and offline unlocked players; verified the complete Gradle build and OKF bundle. + ## 2026-08-14 - **Completion**: Completed US-004 with exact known online and offline targeting, complete status, idempotent grants, confirmed resets, active concealment discovery, operator-default permissions, asynchronous persisted replies, and console audits; 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 93293ee..55d46b7 100644 --- a/design/user-stories/us-003-check-personal-progress.md +++ b/design/user-stories/us-003-check-personal-progress.md @@ -18,10 +18,12 @@ As a **player**, I want to check my stealth progress so that I know how close I - [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. +- [x] `/stealth` tab-completes `progress`, filtered by the entered prefix. +- [x] Unsupported argument positions return no suggestions instead of Bukkit's generic player list. ## 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. +Automated tests verify concise duration formatting, live in-flight progress, configured target and remaining output, command non-mutation, unlocked usage guidance, prefix-filtered completion, and suppression of irrelevant suggestions. Command metadata and the complete `./gradlew clean check jar` lifecycle pass. ## Related diff --git a/design/user-stories/us-004-administer-player-stealth.md b/design/user-stories/us-004-administer-player-stealth.md index 564618b..e1c47c1 100644 --- a/design/user-stories/us-004-administer-player-stealth.md +++ b/design/user-stories/us-004-administer-player-stealth.md @@ -24,10 +24,16 @@ As a **server administrator**, I want to inspect and correct player stealth stat - [x] Administrative inspection and modification require an operator-default administrative permission. - [x] State-changing operations persist before success is reported and are safe under retries. - [x] Grant and reset actions record the administrator, target UUID, and action in the server log without blocking the server tick thread. +- [x] `/stealthadmin` tab-completes `status`, `grant`, `reset`, and `list`, filtered by the entered prefix. +- [x] Target positions for `status`, `grant`, and `reset` suggest known player names, and reset's final argument suggests `confirm`. +- [x] Administrative completions require the administrative permission, and irrelevant positions return no suggestions instead of Bukkit's generic player list. +- [x] `/stealthadmin list unlocked` lists every known online or offline player with stealth unlocked. +- [x] The unlocked list is sorted case-insensitively, identifies nameless records by UUID, and clearly reports when it is empty. +- [x] Existing `/stealthadmin list` behavior continues to list currently concealed online players. ## Validation -Automated tests verify exact offline name and UUID resolution, ambiguous and unknown rejection without record creation, complete status output, idempotent grants and notification, complete resets with presentation cleanup, online concealed-player filtering, confirmation and permission gates, persisted-before-success replies, and audit records. The complete `./gradlew clean check jar` lifecycle passes. +Automated tests verify exact offline name and UUID resolution, ambiguous and unknown rejection without record creation, complete status output, idempotent grants and notification, complete resets with presentation cleanup, online concealed-player filtering, online and offline unlocked-player listing, contextual permission-gated completion, confirmation and permission gates, persisted-before-success replies, and audit records. The complete `./gradlew clean check jar` lifecycle passes. ## Related diff --git a/src/main/java/games/dmg/spigotstealth/KnownPlayerResolver.java b/src/main/java/games/dmg/spigotstealth/KnownPlayerResolver.java index c84e3fd..bcd3935 100644 --- a/src/main/java/games/dmg/spigotstealth/KnownPlayerResolver.java +++ b/src/main/java/games/dmg/spigotstealth/KnownPlayerResolver.java @@ -21,6 +21,17 @@ public final class KnownPlayerResolver { this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers"); } + public java.util.List completeNames(String prefix) { + String normalized = prefix == null ? "" : prefix.toLowerCase(Locale.ROOT); + return knownPlayers().values().stream() + .map(KnownPlayer::name) + .filter(Objects::nonNull) + .distinct() + .filter(name -> name.toLowerCase(Locale.ROOT).startsWith(normalized)) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } + public Resolution resolve(String query) { if (query == null || query.isBlank()) { return new Resolution(Status.UNKNOWN, null); diff --git a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java index 84d1888..f90adc5 100644 --- a/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java +++ b/src/main/java/games/dmg/spigotstealth/SpigotStealthPlugin.java @@ -79,18 +79,24 @@ public final class SpigotStealthPlugin extends JavaPlugin { new InvisibilityEffectListener(progression, Clock.systemUTC()), this); getServer().getPluginManager().registerEvents( new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this); - Objects.requireNonNull(getCommand("stealth"), "stealth command") - .setExecutor(new StealthCommand(manager, progression, settings)); + org.bukkit.command.PluginCommand stealthPluginCommand = + Objects.requireNonNull(getCommand("stealth"), "stealth command"); + StealthCommand stealthCommand = new StealthCommand(manager, progression, settings); + stealthPluginCommand.setExecutor(stealthCommand); + stealthPluginCommand.setTabCompleter(stealthCommand); StealthAdministrationService administration = new StealthAdministrationService( manager, progression, identityPresentation, notifier, getServer()::getOnlinePlayers); KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, getServer()::getOnlinePlayers); - Objects.requireNonNull(getCommand("stealthadmin"), "stealthadmin command") - .setExecutor(new StealthAdminCommand( - administration, - resolver, - mainThread, - getLogger()::info, - settings.unlockThreshold())); + org.bukkit.command.PluginCommand stealthAdminPluginCommand = + Objects.requireNonNull(getCommand("stealthadmin"), "stealthadmin command"); + StealthAdminCommand stealthAdminCommand = new StealthAdminCommand( + administration, + resolver, + mainThread, + getLogger()::info, + settings.unlockThreshold()); + stealthAdminPluginCommand.setExecutor(stealthAdminCommand); + stealthAdminPluginCommand.setTabCompleter(stealthAdminCommand); 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/StealthAdminCommand.java b/src/main/java/games/dmg/spigotstealth/StealthAdminCommand.java index f264add..506f662 100644 --- a/src/main/java/games/dmg/spigotstealth/StealthAdminCommand.java +++ b/src/main/java/games/dmg/spigotstealth/StealthAdminCommand.java @@ -9,10 +9,12 @@ import java.util.function.Consumer; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; /** Permission-gated administrative command for online and known offline players. */ -public final class StealthAdminCommand implements CommandExecutor { +public final class StealthAdminCommand implements CommandExecutor, TabCompleter { private static final String PERMISSION = "spigotstealth.admin"; + private static final List OPERATIONS = List.of("status", "grant", "reset", "list"); private final StealthAdministrationService administration; private final KnownPlayerResolver resolver; private final Consumer mainThread; @@ -32,17 +34,57 @@ public final class StealthAdminCommand implements CommandExecutor { this.unlockThreshold = Objects.requireNonNull(unlockThreshold, "unlockThreshold"); } + @Override + public List onTabComplete( + CommandSender sender, Command command, String label, String[] arguments) { + if (!sender.hasPermission(PERMISSION)) { + return List.of(); + } + if (arguments.length == 1) { + return matching(OPERATIONS, arguments[0]); + } + String operation = arguments[0].toLowerCase(Locale.ROOT); + if (arguments.length == 2) { + if (List.of("status", "grant", "reset").contains(operation)) { + return resolver.completeNames(arguments[1]); + } + if ("list".equals(operation)) { + return matching(List.of("unlocked"), arguments[1]); + } + } + if (arguments.length == 3 && "reset".equals(operation)) { + return matching(List.of("confirm"), arguments[2]); + } + return List.of(); + } + + private static List matching(List candidates, String prefix) { + String normalized = prefix.toLowerCase(Locale.ROOT); + return candidates.stream().filter(candidate -> candidate.startsWith(normalized)).toList(); + } + @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { if (!sender.hasPermission(PERMISSION)) { sender.sendMessage("You do not have permission to administer Spigot Stealth."); return true; } - if (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0])) { - List names = administration.concealedOnlineNames(); - sender.sendMessage(names.isEmpty() - ? "No players are currently concealed." - : "Currently concealed: " + String.join(", ", names)); + if (arguments.length > 0 && "list".equalsIgnoreCase(arguments[0])) { + if (arguments.length == 1) { + List names = administration.concealedOnlineNames(); + sender.sendMessage(names.isEmpty() + ? "No players are currently concealed." + : "Currently concealed: " + String.join(", ", names)); + return true; + } + if (arguments.length == 2 && "unlocked".equalsIgnoreCase(arguments[1])) { + List names = administration.unlockedPlayerNames(); + sender.sendMessage(names.isEmpty() + ? "No players have unlocked stealth." + : "Stealth unlocked: " + String.join(", ", names)); + return true; + } + sendUsage(sender, label); return true; } if (arguments.length < 2) { @@ -121,7 +163,7 @@ public final class StealthAdminCommand implements CommandExecutor { } private static void sendUsage(CommandSender sender, String label) { - sender.sendMessage("Usage: /" + label + " |grant |reset confirm|list>"); + sender.sendMessage("Usage: /" + label + " |grant |reset confirm|list [unlocked]>"); } private static String displayName(KnownPlayerResolver.KnownPlayer player) { diff --git a/src/main/java/games/dmg/spigotstealth/StealthAdministrationService.java b/src/main/java/games/dmg/spigotstealth/StealthAdministrationService.java index 2cbe2e9..46d0a6f 100644 --- a/src/main/java/games/dmg/spigotstealth/StealthAdministrationService.java +++ b/src/main/java/games/dmg/spigotstealth/StealthAdministrationService.java @@ -73,6 +73,17 @@ public final class StealthAdministrationService { return CompletableFuture.allOf(stopped, reset); } + public List unlockedPlayerNames() { + return stateManager.snapshot().players().entrySet().stream() + .filter(entry -> entry.getValue().unlocked()) + .map(entry -> { + String name = entry.getValue().lastKnownName(); + return name == null || name.isBlank() ? entry.getKey().toString() : name; + }) + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(); + } + public List concealedOnlineNames() { return onlinePlayers.get().stream() .filter(player -> stateManager.snapshot().player(player.getUniqueId()).concealed()) diff --git a/src/main/java/games/dmg/spigotstealth/StealthCommand.java b/src/main/java/games/dmg/spigotstealth/StealthCommand.java index aa0383e..6d97d9b 100644 --- a/src/main/java/games/dmg/spigotstealth/StealthCommand.java +++ b/src/main/java/games/dmg/spigotstealth/StealthCommand.java @@ -1,14 +1,16 @@ package games.dmg.spigotstealth; +import java.util.List; 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.command.TabCompleter; import org.bukkit.entity.Player; /** Player-facing progress command. */ -public final class StealthCommand implements CommandExecutor { +public final class StealthCommand implements CommandExecutor, TabCompleter { private final StealthStateManager stateManager; private final QualifyingInvisibilityService progression; private final StealthSettings settings; @@ -22,6 +24,15 @@ public final class StealthCommand implements CommandExecutor { this.settings = Objects.requireNonNull(settings, "settings"); } + @Override + public List onTabComplete( + CommandSender sender, Command command, String label, String[] arguments) { + if (arguments.length == 1 && "progress".startsWith(arguments[0].toLowerCase(Locale.ROOT))) { + return List.of("progress"); + } + return List.of(); + } + @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { if (!(sender instanceof Player player)) { diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index d8fe4c1..d6f7a7a 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -12,7 +12,7 @@ commands: permission: spigotstealth.use stealthadmin: description: Inspect and administer Spigot Stealth. - usage: /stealthadmin + usage: /stealthadmin |grant |reset confirm|list [unlocked]> permission: spigotstealth.admin permissions: spigotstealth.use: diff --git a/src/test/java/games/dmg/spigotstealth/StealthAdminCommandTest.java b/src/test/java/games/dmg/spigotstealth/StealthAdminCommandTest.java index fde2989..d3cd2df 100644 --- a/src/test/java/games/dmg/spigotstealth/StealthAdminCommandTest.java +++ b/src/test/java/games/dmg/spigotstealth/StealthAdminCommandTest.java @@ -1,5 +1,6 @@ package games.dmg.spigotstealth; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.contains; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -16,6 +17,51 @@ import org.bukkit.command.CommandSender; import org.junit.jupiter.api.Test; class StealthAdminCommandTest { + @Test + void completesAdministrativeOperationsByPrefix() { + UUID playerId = UUID.randomUUID(); + try (Fixture fixture = fixture(playerId)) { + assertEquals( + List.of("grant"), + fixture.command.onTabComplete( + sender(true), mock(Command.class), "stealthadmin", new String[] {"gr"})); + assertEquals( + List.of("status", "grant", "reset", "list"), + fixture.command.onTabComplete( + sender(true), mock(Command.class), "stealthadmin", new String[] {""})); + } + } + + @Test + void completesContextualArgumentsAndSuppressesIrrelevantSuggestions() { + UUID playerId = UUID.randomUUID(); + try (Fixture fixture = fixture(playerId)) { + Command command = mock(Command.class); + CommandSender administrator = sender(true); + + assertEquals( + List.of("Alex"), + fixture.command.onTabComplete( + administrator, command, "stealthadmin", new String[] {"status", "al"})); + assertEquals( + List.of("confirm"), + fixture.command.onTabComplete( + administrator, command, "stealthadmin", new String[] {"reset", "Alex", "co"})); + assertEquals( + List.of("unlocked"), + fixture.command.onTabComplete( + administrator, command, "stealthadmin", new String[] {"list", "un"})); + assertEquals( + List.of(), + fixture.command.onTabComplete( + administrator, command, "stealthadmin", new String[] {"status", "Alex", ""})); + assertEquals( + List.of(), + fixture.command.onTabComplete( + sender(false), command, "stealthadmin", new String[] {""})); + } + } + @Test void statusInspectsKnownOfflinePlayerAndReportsAllState() { UUID playerId = UUID.randomUUID(); @@ -28,6 +74,25 @@ class StealthAdminCommandTest { } } + @Test + void listsUnlockedPlayersAndClearlyReportsWhenThereAreNone() { + UUID playerId = UUID.randomUUID(); + try (Fixture fixture = fixture(playerId)) { + Command command = mock(Command.class); + CommandSender emptySender = sender(true); + fixture.command.onCommand( + emptySender, command, "stealthadmin", new String[] {"list", "unlocked"}); + verify(emptySender).sendMessage("No players have unlocked stealth."); + + fixture.manager.update(state -> state.withPlayer( + state.player(playerId).withProgress(5000L, true))).join(); + CommandSender populatedSender = sender(true); + fixture.command.onCommand( + populatedSender, command, "stealthadmin", new String[] {"list", "unlocked"}); + verify(populatedSender).sendMessage("Stealth unlocked: Alex"); + } + } + @Test void grantPersistsThenReportsAndAuditsAction() { UUID playerId = UUID.randomUUID(); diff --git a/src/test/java/games/dmg/spigotstealth/StealthAdministrationServiceTest.java b/src/test/java/games/dmg/spigotstealth/StealthAdministrationServiceTest.java index f78b97c..604f18c 100644 --- a/src/test/java/games/dmg/spigotstealth/StealthAdministrationServiceTest.java +++ b/src/test/java/games/dmg/spigotstealth/StealthAdministrationServiceTest.java @@ -58,6 +58,29 @@ class StealthAdministrationServiceTest { } } + @Test + void listsAllUnlockedPlayersByNameOrUuidInCaseInsensitiveOrder() { + UUID namelessId = UUID.randomUUID(); + UUID lockedId = UUID.randomUUID(); + UUID zoeId = UUID.randomUUID(); + UUID alexId = UUID.randomUUID(); + try (StealthStateManager manager = manager()) { + manager.update(state -> state + .withPlayer(PlayerStealthState.empty(namelessId).withProgress(0L, true)) + .withPlayer(PlayerStealthState.empty(lockedId).withLastKnownName("Locked")) + .withPlayer(PlayerStealthState.empty(zoeId).withLastKnownName("zoe").withProgress(1L, true)) + .withPlayer(PlayerStealthState.empty(alexId).withLastKnownName("Alex").withProgress(2L, true))) + .join(); + StealthAdministrationService administration = service(manager, List::of, ignored -> { }); + + assertEquals( + List.of("Alex", namelessId.toString(), "zoe").stream() + .sorted(String.CASE_INSENSITIVE_ORDER) + .toList(), + administration.unlockedPlayerNames()); + } + } + @Test void listsOnlyCurrentlyOnlineConcealedPlayers() { UUID concealedId = UUID.randomUUID(); diff --git a/src/test/java/games/dmg/spigotstealth/StealthCommandTest.java b/src/test/java/games/dmg/spigotstealth/StealthCommandTest.java index 59fd966..9681d22 100644 --- a/src/test/java/games/dmg/spigotstealth/StealthCommandTest.java +++ b/src/test/java/games/dmg/spigotstealth/StealthCommandTest.java @@ -8,6 +8,7 @@ import static org.mockito.Mockito.when; import java.time.Duration; import java.time.Instant; +import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.atomic.AtomicLong; @@ -16,6 +17,24 @@ import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; class StealthCommandTest { + @Test + void completesProgressByPrefixWithoutSuggestingPlayersElsewhere() { + try (StealthStateManager manager = manager()) { + QualifyingInvisibilityService progression = new QualifyingInvisibilityService( + manager, Duration.ofHours(8), System::nanoTime, ignored -> { }); + StealthCommand command = new StealthCommand(manager, progression, StealthSettings.from(Map.of())); + Command bukkitCommand = mock(Command.class); + Player player = player(UUID.randomUUID()); + + org.junit.jupiter.api.Assertions.assertEquals( + List.of("progress"), + command.onTabComplete(player, bukkitCommand, "stealth", new String[] {"pr"})); + org.junit.jupiter.api.Assertions.assertEquals( + List.of(), + command.onTabComplete(player, bukkitCommand, "stealth", new String[] {"progress", ""})); + } + } + @Test void reportsLiveProgressTargetAndRemainingTimeWithoutMutatingState() { UUID playerId = UUID.randomUUID();