From b4a8094874f64c1e2c5cba530eba9c1dd21bd3f7 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Mon, 10 Aug 2026 22:22:08 -0400 Subject: [PATCH] feat(commands): add permission-aware tab completion --- README.md | 2 + design/architecture.md | 2 +- design/log.md | 4 + design/user-stories/index.md | 1 + .../us-008-autocomplete-commands.md | 31 ++++++ .../dmg/creeperfear/CreeperFearPlugin.java | 20 ++-- .../command/CreeperAuraTabCompleter.java | 79 +++++++++++++++ src/main/resources/plugin.yml | 2 +- .../command/CreeperAuraTabCompleterTest.java | 98 +++++++++++++++++++ 9 files changed, 229 insertions(+), 10 deletions(-) create mode 100644 design/user-stories/us-008-autocomplete-commands.md create mode 100644 src/main/java/games/dmg/creeperfear/command/CreeperAuraTabCompleter.java create mode 100644 src/test/java/games/dmg/creeperfear/command/CreeperAuraTabCompleterTest.java diff --git a/README.md b/README.md index ec251b9..dc77f06 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,8 @@ Restart the server after copying the JAR. The plugin creates its configuration a The personal progress permission defaults to everyone. Administrative permissions default to server operators. Administrative changes are recorded in the server log. +Commands provide permission-aware tab completion for subcommands, online player names, and valid ranks. Known offline players remain valid command targets but are not suggested. + ## Configuration ```yaml diff --git a/design/architecture.md b/design/architecture.md index a1e8587..40b18a2 100644 --- a/design/architecture.md +++ b/design/architecture.md @@ -36,7 +36,7 @@ For explosions, player damage events establish whether an unlocked player would ## Commands and configuration -`/creeperaura` exposes player progress and permission-protected offline administration. Rank requirements, multipliers, feedback duration, and messages are loaded from YAML. Valid command-based changes are written back to YAML and survive restart. +`/creeperaura` exposes player progress and permission-protected offline administration. Permission-aware tab completion suggests available subcommands, online players, and valid ranks without querying SQLite on the server thread. Rank requirements, multipliers, feedback duration, and messages are loaded from YAML. Valid command-based changes are written back to YAML and survive restart. ## Verification diff --git a/design/log.md b/design/log.md index 4f12629..a330b4c 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,10 @@ description: Chronological record of material changes to the Spigot Creeper Fear # Design Log +## 2026-08-10 + +- Completed US-008 with permission-aware command, online-player, and rank tab completion. + ## 2026-08-08 - Established the OKF v0.1 design bundle. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index bf1870f..a1f4ebe 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -13,3 +13,4 @@ description: Catalog of user stories for the Spigot Creeper Fear plugin. - [US-005: Administer player progression](us-005-administer-player-progression.md) - [US-006: Configure aura progression](us-006-configure-aura-progression.md) - [US-007: Build and release the plugin](us-007-build-and-release-plugin.md) +- [US-008: Autocomplete commands](us-008-autocomplete-commands.md) diff --git a/design/user-stories/us-008-autocomplete-commands.md b/design/user-stories/us-008-autocomplete-commands.md new file mode 100644 index 0000000..bf75575 --- /dev/null +++ b/design/user-stories/us-008-autocomplete-commands.md @@ -0,0 +1,31 @@ +--- +type: User Story +title: "US-008: Autocomplete commands" +description: Help players and administrators discover valid Creeper Aura command arguments with permission-aware tab completion. +status: done +--- + +# US-008: Autocomplete commands + +As a **command sender**, I want Creeper Aura commands to offer relevant tab completions so that I can enter valid commands quickly and accurately. + +## Acceptance criteria + +- [x] `/creeperaura` suggests only subcommands allowed by the sender's permissions. +- [x] `progress`, `set`, `add`, and `rank` suggest matching online player names where appropriate. +- [x] `rank` suggests `locked`, `I`, `II`, `III`, `IV`, `V`, and `VI`. +- [x] `threshold` suggests configurable ranks `I` through `VI`. +- [x] Suggestions are filtered case-insensitively by the current input. +- [x] Unauthorized administrative subcommands and arguments are not suggested. +- [x] Completion works for both players and the server console. +- [x] Automated tests cover completion behavior. +- [x] Command metadata and project documentation describe autocomplete support. + +Offline stored players remain valid command targets but are not suggested, avoiding synchronous database access during completion. + +## Related + +- [Check personal progress](us-004-check-personal-progress.md) +- [Administer player progression](us-005-administer-player-progression.md) +- [Configure aura progression](us-006-configure-aura-progression.md) +- [User-story catalog](index.md) diff --git a/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java b/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java index 06da45e..ec32824 100644 --- a/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java +++ b/src/main/java/games/dmg/creeperfear/CreeperFearPlugin.java @@ -3,6 +3,7 @@ package games.dmg.creeperfear; import games.dmg.creeperfear.aura.AuraRules; import games.dmg.creeperfear.aura.CreeperAuraListener; import games.dmg.creeperfear.command.CreeperAuraCommand; +import games.dmg.creeperfear.command.CreeperAuraTabCompleter; import games.dmg.creeperfear.command.ProgressMessages; import games.dmg.creeperfear.command.ProgressMutations; import games.dmg.creeperfear.config.AuraConfigLoader; @@ -17,6 +18,7 @@ import games.dmg.creeperfear.progress.SqliteProgressRepository; import java.nio.file.Path; import java.util.Objects; import java.util.logging.Level; +import org.bukkit.command.PluginCommand; import org.bukkit.plugin.java.JavaPlugin; public final class CreeperFearPlugin extends JavaPlugin { @@ -42,14 +44,16 @@ public final class CreeperFearPlugin extends JavaPlugin { new PlayerSessionListener(progressService, getLogger()), this); getServer().getPluginManager().registerEvents( new CreeperAuraListener(progressService, auraRules), this); - Objects.requireNonNull(getCommand("creeperaura"), "creeperaura command") - .setExecutor(new CreeperAuraCommand( - this, - progressService, - new ProgressMessages(auraRules), - new ProgressMutations(), - progressFeedback, - configurationManager)); + PluginCommand creeperAuraCommand = Objects.requireNonNull( + getCommand("creeperaura"), "creeperaura command"); + creeperAuraCommand.setExecutor(new CreeperAuraCommand( + this, + progressService, + new ProgressMessages(auraRules), + new ProgressMutations(), + progressFeedback, + configurationManager)); + creeperAuraCommand.setTabCompleter(new CreeperAuraTabCompleter(getServer())); getServer().getOnlinePlayers().forEach(player -> progressService.loadOnline(player.getUniqueId()) .exceptionally(failure -> { getLogger().log(Level.SEVERE, diff --git a/src/main/java/games/dmg/creeperfear/command/CreeperAuraTabCompleter.java b/src/main/java/games/dmg/creeperfear/command/CreeperAuraTabCompleter.java new file mode 100644 index 0000000..de46939 --- /dev/null +++ b/src/main/java/games/dmg/creeperfear/command/CreeperAuraTabCompleter.java @@ -0,0 +1,79 @@ +package games.dmg.creeperfear.command; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import org.bukkit.Server; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.jetbrains.annotations.NotNull; + +public final class CreeperAuraTabCompleter implements TabCompleter { + private static final List ALL_RANKS = List.of("locked", "I", "II", "III", "IV", "V", "VI"); + private static final List CONFIGURABLE_RANKS = ALL_RANKS.subList(1, ALL_RANKS.size()); + + private final Server server; + + public CreeperAuraTabCompleter(Server server) { + this.server = server; + } + + @Override + public List onTabComplete( + @NotNull CommandSender sender, + @NotNull Command command, + @NotNull String label, + @NotNull String[] args) { + if (args.length == 3 + && args[0].equalsIgnoreCase("rank") + && sender.hasPermission("creeperfear.admin.modify")) { + return matching(ALL_RANKS, args[2]); + } + if (args.length == 2 + && args[0].equalsIgnoreCase("threshold") + && sender.hasPermission("creeperfear.admin.configure")) { + return matching(CONFIGURABLE_RANKS, args[1]); + } + if (args.length == 2 && completesPlayer(sender, args[0])) { + List playerNames = server.getOnlinePlayers().stream() + .map(player -> player.getName()) + .toList(); + return matching(playerNames, args[1]); + } + if (args.length != 1) return List.of(); + + List suggestions = new ArrayList<>(); + if (sender.hasPermission("creeperfear.progress") + || sender.hasPermission("creeperfear.admin.inspect")) { + suggestions.add("progress"); + } + if (sender.hasPermission("creeperfear.admin.modify")) { + suggestions.add("set"); + suggestions.add("add"); + suggestions.add("rank"); + } + if (sender.hasPermission("creeperfear.admin.configure")) { + suggestions.add("threshold"); + suggestions.add("reload"); + } + return matching(suggestions, args[0]); + } + + private boolean completesPlayer(CommandSender sender, String subcommand) { + if (subcommand.equalsIgnoreCase("progress")) { + return sender.hasPermission("creeperfear.admin.inspect"); + } + return (subcommand.equalsIgnoreCase("set") + || subcommand.equalsIgnoreCase("add") + || subcommand.equalsIgnoreCase("rank")) + && sender.hasPermission("creeperfear.admin.modify"); + } + + private List matching(List candidates, String input) { + String prefix = input.toLowerCase(Locale.ROOT); + return candidates.stream() + .filter(candidate -> candidate.toLowerCase(Locale.ROOT).startsWith(prefix)) + .toList(); + } +} diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 5724645..6e823df 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -6,7 +6,7 @@ author: dmg.games description: Unlock Creeper Aura ranks by defeating creepers. commands: creeperaura: - description: Check and administer Creeper Aura progression. + description: Check and administer Creeper Aura progression with permission-aware tab completion. usage: / |add |rank |threshold |reload> permissions: creeperfear.progress: diff --git a/src/test/java/games/dmg/creeperfear/command/CreeperAuraTabCompleterTest.java b/src/test/java/games/dmg/creeperfear/command/CreeperAuraTabCompleterTest.java new file mode 100644 index 0000000..0d9db8d --- /dev/null +++ b/src/test/java/games/dmg/creeperfear/command/CreeperAuraTabCompleterTest.java @@ -0,0 +1,98 @@ +package games.dmg.creeperfear.command; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.List; +import org.bukkit.Server; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.ConsoleCommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +class CreeperAuraTabCompleterTest { + private final Server server = mock(Server.class); + private final Command command = mock(Command.class); + private final CommandSender sender = mock(CommandSender.class); + private final CreeperAuraTabCompleter completer = new CreeperAuraTabCompleter(server); + + @Test + void suggestsOnlySubcommandsAllowedByTheSendersPermissions() { + when(sender.hasPermission("creeperfear.progress")).thenReturn(true); + when(sender.hasPermission("creeperfear.admin.modify")).thenReturn(true); + + List suggestions = completer.onTabComplete(sender, command, "creeperaura", new String[] {""}); + + assertEquals(List.of("progress", "set", "add", "rank"), suggestions); + } + + @Test + void filtersSubcommandsCaseInsensitivelyByPartialInput() { + when(sender.hasPermission("creeperfear.admin.configure")).thenReturn(true); + + List suggestions = completer.onTabComplete(sender, command, "creeperaura", new String[] {"TH"}); + + assertEquals(List.of("threshold"), suggestions); + } + + @Test + void suggestsMatchingOnlinePlayersForPermittedPlayerArguments() { + Player alice = mock(Player.class); + Player bob = mock(Player.class); + when(alice.getName()).thenReturn("Alice"); + when(bob.getName()).thenReturn("Bob"); + doReturn(List.of(alice, bob)).when(server).getOnlinePlayers(); + when(sender.hasPermission("creeperfear.admin.modify")).thenReturn(true); + + List suggestions = completer.onTabComplete( + sender, command, "creeperaura", new String[] {"rank", "aL"}); + + assertEquals(List.of("Alice"), suggestions); + } + + @Test + void suggestsAllRanksForTheRankCommand() { + when(sender.hasPermission("creeperfear.admin.modify")).thenReturn(true); + + List suggestions = completer.onTabComplete( + sender, command, "creeperaura", new String[] {"rank", "Alice", ""}); + + assertEquals(List.of("locked", "I", "II", "III", "IV", "V", "VI"), suggestions); + } + + @Test + void suggestsOnlyConfigurableRanksForThresholds() { + when(sender.hasPermission("creeperfear.admin.configure")).thenReturn(true); + + List suggestions = completer.onTabComplete( + sender, command, "creeperaura", new String[] {"threshold", ""}); + + assertEquals(List.of("I", "II", "III", "IV", "V", "VI"), suggestions); + } + + @Test + void doesNotSuggestAdministrativeArgumentsWithoutPermission() { + assertEquals(List.of(), completer.onTabComplete( + sender, command, "creeperaura", new String[] {"progress", ""})); + assertEquals(List.of(), completer.onTabComplete( + sender, command, "creeperaura", new String[] {"rank", "Alice", ""})); + assertEquals(List.of(), completer.onTabComplete( + sender, command, "creeperaura", new String[] {"threshold", ""})); + } + + @Test + void completesCommandsForPlayersAndTheServerConsole() { + Player player = mock(Player.class); + ConsoleCommandSender console = mock(ConsoleCommandSender.class); + when(player.hasPermission("creeperfear.progress")).thenReturn(true); + when(console.hasPermission("creeperfear.progress")).thenReturn(true); + + assertEquals(List.of("progress"), completer.onTabComplete( + player, command, "creeperaura", new String[] {""})); + assertEquals(List.of("progress"), completer.onTabComplete( + console, command, "creeperaura", new String[] {""})); + } +}