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
@@ -21,6 +21,17 @@ public final class KnownPlayerResolver {
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) {
if (query == null || query.isBlank()) {
return new Resolution(Status.UNKNOWN, null);
@@ -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));
@@ -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<String> OPERATIONS = List.of("status", "grant", "reset", "list");
private final StealthAdministrationService administration;
private final KnownPlayerResolver resolver;
private final Consumer<Runnable> mainThread;
@@ -32,17 +34,57 @@ public final class StealthAdminCommand implements CommandExecutor {
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
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<String> 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<String> 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<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) {
@@ -121,7 +163,7 @@ public final class StealthAdminCommand implements CommandExecutor {
}
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) {
@@ -73,6 +73,17 @@ public final class StealthAdministrationService {
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() {
return onlinePlayers.get().stream()
.filter(player -> stateManager.snapshot().player(player.getUniqueId()).concealed())
@@ -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<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
public boolean onCommand(CommandSender sender, Command command, String label, String[] arguments) {
if (!(sender instanceof Player player)) {
+1 -1
View File
@@ -12,7 +12,7 @@ commands:
permission: spigotstealth.use
stealthadmin:
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
permissions:
spigotstealth.use:
@@ -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();
@@ -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();
@@ -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();