feat(commands): add permission-aware tab completion
Release / release (push) Successful in 2m30s
CI / build (push) Successful in 1m18s

This commit is contained in:
dmg
2026-08-10 22:22:08 -04:00
parent 1d580220c3
commit b4a8094874
9 changed files with 229 additions and 10 deletions
+2
View File
@@ -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
+1 -1
View File
@@ -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
+4
View File
@@ -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.
+1
View File
@@ -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)
@@ -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)
@@ -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,
@@ -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<String> ALL_RANKS = List.of("locked", "I", "II", "III", "IV", "V", "VI");
private static final List<String> CONFIGURABLE_RANKS = ALL_RANKS.subList(1, ALL_RANKS.size());
private final Server server;
public CreeperAuraTabCompleter(Server server) {
this.server = server;
}
@Override
public List<String> 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<String> playerNames = server.getOnlinePlayers().stream()
.map(player -> player.getName())
.toList();
return matching(playerNames, args[1]);
}
if (args.length != 1) return List.of();
List<String> 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<String> matching(List<String> candidates, String input) {
String prefix = input.toLowerCase(Locale.ROOT);
return candidates.stream()
.filter(candidate -> candidate.toLowerCase(Locale.ROOT).startsWith(prefix))
.toList();
}
}
+1 -1
View File
@@ -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: /<command> <progress [player]|set <player> <progress>|add <player> <progress>|rank <player> <rank>|threshold <rank> <points>|reload>
permissions:
creeperfear.progress:
@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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[] {""}));
}
}