feat(commands): add contextual completion and unlock listing
Release / release (push) Successful in 3m46s
CI / build (push) Successful in 57s

This commit is contained in:
dmg
2026-09-04 23:37:20 -04:00
parent 2bcba5071f
commit b0da1508b6
12 changed files with 220 additions and 20 deletions
+4
View File
@@ -1,5 +1,9 @@
# Spigot Stealth Design Log # 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 ## 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. - **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.
@@ -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] Durations are presented in a concise, human-readable form.
- [x] Repeated command use does not change progression or concealment state. - [x] Repeated command use does not change progression or concealment state.
- [x] The command has clear usage metadata and an appropriate player permission. - [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 ## 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 ## Related
@@ -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] 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] 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] 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 ## 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 ## Related
@@ -21,6 +21,17 @@ public final class KnownPlayerResolver {
this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers"); this.onlinePlayers = Objects.requireNonNull(onlinePlayers, "onlinePlayers");
} }
public java.util.List<String> 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) { public Resolution resolve(String query) {
if (query == null || query.isBlank()) { if (query == null || query.isBlank()) {
return new Resolution(Status.UNKNOWN, null); return new Resolution(Status.UNKNOWN, null);
@@ -79,18 +79,24 @@ public final class SpigotStealthPlugin extends JavaPlugin {
new InvisibilityEffectListener(progression, Clock.systemUTC()), this); new InvisibilityEffectListener(progression, Clock.systemUTC()), this);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this); new StealthSessionListener(sessions, identityPresentation, settings.concealedMessage()), this);
Objects.requireNonNull(getCommand("stealth"), "stealth command") org.bukkit.command.PluginCommand stealthPluginCommand =
.setExecutor(new StealthCommand(manager, progression, settings)); Objects.requireNonNull(getCommand("stealth"), "stealth command");
StealthCommand stealthCommand = new StealthCommand(manager, progression, settings);
stealthPluginCommand.setExecutor(stealthCommand);
stealthPluginCommand.setTabCompleter(stealthCommand);
StealthAdministrationService administration = new StealthAdministrationService( StealthAdministrationService administration = new StealthAdministrationService(
manager, progression, identityPresentation, notifier, getServer()::getOnlinePlayers); manager, progression, identityPresentation, notifier, getServer()::getOnlinePlayers);
KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, getServer()::getOnlinePlayers); KnownPlayerResolver resolver = new KnownPlayerResolver(manager::snapshot, getServer()::getOnlinePlayers);
Objects.requireNonNull(getCommand("stealthadmin"), "stealthadmin command") org.bukkit.command.PluginCommand stealthAdminPluginCommand =
.setExecutor(new StealthAdminCommand( Objects.requireNonNull(getCommand("stealthadmin"), "stealthadmin command");
StealthAdminCommand stealthAdminCommand = new StealthAdminCommand(
administration, administration,
resolver, resolver,
mainThread, mainThread,
getLogger()::info, getLogger()::info,
settings.unlockThreshold())); settings.unlockThreshold());
stealthAdminPluginCommand.setExecutor(stealthAdminCommand);
stealthAdminPluginCommand.setTabCompleter(stealthAdminCommand);
getServer().getScheduler().runTaskTimer(this, ignored -> { getServer().getScheduler().runTaskTimer(this, ignored -> {
for (java.util.UUID playerId : progression.activePlayerIds()) { for (java.util.UUID playerId : progression.activePlayerIds()) {
logSaveFailure(progression.checkpoint(playerId)); logSaveFailure(progression.checkpoint(playerId));
@@ -9,10 +9,12 @@ import java.util.function.Consumer;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
/** Permission-gated administrative command for online and known offline players. */ /** 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 String PERMISSION = "spigotstealth.admin";
private static final List<String> OPERATIONS = List.of("status", "grant", "reset", "list");
private final StealthAdministrationService administration; private final StealthAdministrationService administration;
private final KnownPlayerResolver resolver; private final KnownPlayerResolver resolver;
private final Consumer<Runnable> mainThread; private final Consumer<Runnable> mainThread;
@@ -32,19 +34,59 @@ public final class StealthAdminCommand implements CommandExecutor {
this.unlockThreshold = Objects.requireNonNull(unlockThreshold, "unlockThreshold"); this.unlockThreshold = Objects.requireNonNull(unlockThreshold, "unlockThreshold");
} }
@Override
public List<String> 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<String> matching(List<String> candidates, String prefix) {
String normalized = prefix.toLowerCase(Locale.ROOT);
return candidates.stream().filter(candidate -> candidate.startsWith(normalized)).toList();
}
@Override @Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!sender.hasPermission(PERMISSION)) { if (!sender.hasPermission(PERMISSION)) {
sender.sendMessage("You do not have permission to administer Spigot Stealth."); sender.sendMessage("You do not have permission to administer Spigot Stealth.");
return true; return true;
} }
if (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0])) { if (arguments.length > 0 && "list".equalsIgnoreCase(arguments[0])) {
if (arguments.length == 1) {
List<String> names = administration.concealedOnlineNames(); List<String> names = administration.concealedOnlineNames();
sender.sendMessage(names.isEmpty() sender.sendMessage(names.isEmpty()
? "No players are currently concealed." ? "No players are currently concealed."
: "Currently concealed: " + String.join(", ", names)); : "Currently concealed: " + String.join(", ", names));
return true; return true;
} }
if (arguments.length == 2 && "unlocked".equalsIgnoreCase(arguments[1])) {
List<String> 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) { if (arguments.length < 2) {
sendUsage(sender, label); sendUsage(sender, label);
return true; return true;
@@ -121,7 +163,7 @@ public final class StealthAdminCommand implements CommandExecutor {
} }
private static void sendUsage(CommandSender sender, String label) { private static void sendUsage(CommandSender sender, String label) {
sender.sendMessage("Usage: /" + label + " <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list>"); sender.sendMessage("Usage: /" + label + " <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]>");
} }
private static String displayName(KnownPlayerResolver.KnownPlayer player) { private static String displayName(KnownPlayerResolver.KnownPlayer player) {
@@ -73,6 +73,17 @@ public final class StealthAdministrationService {
return CompletableFuture.allOf(stopped, reset); return CompletableFuture.allOf(stopped, reset);
} }
public List<String> 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<String> concealedOnlineNames() { public List<String> concealedOnlineNames() {
return onlinePlayers.get().stream() return onlinePlayers.get().stream()
.filter(player -> stateManager.snapshot().player(player.getUniqueId()).concealed()) .filter(player -> stateManager.snapshot().player(player.getUniqueId()).concealed())
@@ -1,14 +1,16 @@
package games.dmg.spigotstealth; package games.dmg.spigotstealth;
import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
/** Player-facing progress command. */ /** Player-facing progress command. */
public final class StealthCommand implements CommandExecutor { public final class StealthCommand implements CommandExecutor, TabCompleter {
private final StealthStateManager stateManager; private final StealthStateManager stateManager;
private final QualifyingInvisibilityService progression; private final QualifyingInvisibilityService progression;
private final StealthSettings settings; private final StealthSettings settings;
@@ -22,6 +24,15 @@ public final class StealthCommand implements CommandExecutor {
this.settings = Objects.requireNonNull(settings, "settings"); this.settings = Objects.requireNonNull(settings, "settings");
} }
@Override
public List<String> 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 @Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) { public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) { if (!(sender instanceof Player player)) {
+1 -1
View File
@@ -12,7 +12,7 @@ commands:
permission: spigotstealth.use permission: spigotstealth.use
stealthadmin: stealthadmin:
description: Inspect and administer Spigot Stealth. description: Inspect and administer Spigot Stealth.
usage: /stealthadmin <status|grant|reset|list> usage: /stealthadmin <status <player|uuid>|grant <player|uuid>|reset <player|uuid> confirm|list [unlocked]>
permission: spigotstealth.admin permission: spigotstealth.admin
permissions: permissions:
spigotstealth.use: spigotstealth.use:
@@ -1,5 +1,6 @@
package games.dmg.spigotstealth; package games.dmg.spigotstealth;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -16,6 +17,51 @@ import org.bukkit.command.CommandSender;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class StealthAdminCommandTest { 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 @Test
void statusInspectsKnownOfflinePlayerAndReportsAllState() { void statusInspectsKnownOfflinePlayerAndReportsAllState() {
UUID playerId = UUID.randomUUID(); 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 @Test
void grantPersistsThenReportsAndAuditsAction() { void grantPersistsThenReportsAndAuditsAction() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();
@@ -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 @Test
void listsOnlyCurrentlyOnlineConcealedPlayers() { void listsOnlyCurrentlyOnlineConcealedPlayers() {
UUID concealedId = UUID.randomUUID(); UUID concealedId = UUID.randomUUID();
@@ -8,6 +8,7 @@ import static org.mockito.Mockito.when;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicLong;
@@ -16,6 +17,24 @@ import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
class StealthCommandTest { 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 @Test
void reportsLiveProgressTargetAndRemainingTimeWithoutMutatingState() { void reportsLiveProgressTargetAndRemainingTimeWithoutMutatingState() {
UUID playerId = UUID.randomUUID(); UUID playerId = UUID.randomUUID();