feat(commands): add contextual tab completion
Release / release (push) Successful in 2m35s
CI / build (push) Successful in 1m21s

This commit is contained in:
dmg
2026-08-21 22:58:58 -04:00
parent 80810b3ba1
commit 7318d4afa6
11 changed files with 488 additions and 0 deletions
+12
View File
@@ -6,6 +6,18 @@ description: Chronological record of material decisions affecting the Spigot Tyr
# Spigot Tyrant Design Log # Spigot Tyrant Design Log
## 2026-08-21 — Contextual command completion completed
- Completed US-018 with registered completers for `/tyrant`, `/vigilante`, and `/tyrantadmin`, replacing Bukkit's unconditional online-player fallback.
- Suggestions now follow argument position, filter case-insensitively, expose eligible assignment and recruitment targets, identify current Followers for dismissal, and gate administration by permission.
- Administrative completion remains available to permitted console senders, while player-only commands return no console suggestions.
- Verified syntax, eligibility, membership, permissions, prefix filtering, compiler warnings, tests, and packaging with `./gradlew clean check jar`.
## 2026-08-21 — Contextual command completion started
- US-018 begins a test-first replacement of Bukkit's player-name fallback with syntax-aware Tyrant, Vigilante, and administrative command suggestions.
- Player arguments will be filtered by the applicable assignment or membership rules, while administrative suggestions remain permission-gated.
## 2026-08-21 — Single-mob Tamer custody completed ## 2026-08-21 — Single-mob Tamer custody completed
- Completed US-007 with an authoritative one-mob custody limit that rejects a second capture before inventory, entity, or state mutation and tells the Tamer to release the held mob first. - Completed US-007 with an authoritative one-mob custody limit that rejects a second capture before inventory, entity, or state mutation and tells the Tamer to release the held mob first.
+1
View File
@@ -17,3 +17,4 @@
15. [US-015: Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md) 15. [US-015: Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
16. [US-016: Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md) 16. [US-016: Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
17. [US-017: Use bound role control items](us-017-use-bound-role-control-items.md) 17. [US-017: Use bound role control items](us-017-use-bound-role-control-items.md)
18. [US-018: Complete Tyrant commands contextually](us-018-complete-commands-contextually.md)
@@ -0,0 +1,39 @@
---
type: User Story
title: "US-018: Complete Tyrant commands contextually"
description: Replace Bukkit's player-name fallback with valid context-aware command suggestions.
status: done
---
# US-018: Complete Tyrant commands contextually
As a **player or administrator**, I want contextual command suggestions so that I can discover valid Tyrant commands and arguments.
## Acceptance criteria
- [x] `/tyrant` suggests its available subcommands instead of arbitrary player names.
- [x] `/tyrant buy` suggests Tyrant unlock names.
- [x] `/tyrant assign` suggests `assassin`, `fixer`, and `tamer`, followed by eligible online players.
- [x] `/tyrant relinquish` suggests `confirm`.
- [x] `/vigilante` suggests its available subcommands.
- [x] `/vigilante invite` suggests eligible online recruitment candidates.
- [x] `/vigilante dismiss` suggests current online Followers.
- [x] `/tyrantadmin` suggestions are visible only to senders with `spigottyrant.admin`.
- [x] `/tyrantadmin start` suggests online Tyrant and Vigilante candidates.
- [x] `/tyrantadmin reset` suggests `confirm`.
- [x] Suggestions are filtered case-insensitively by the partially typed argument.
- [x] Suggestions never include syntactically invalid options for the current argument position.
- [x] Console completion works for administrative commands without exposing player-only commands as executable console actions.
- [x] Existing command execution behavior remains unchanged.
## Validation
Automated tests verify root syntax, unlocks, classes, confirmations, eligible recruits, current Followers, permission gating, online administrative candidates, argument positions, and case-insensitive prefix filtering. The complete `./gradlew clean check jar` lifecycle passes.
## Related
- [Start, pause, and administer the game](us-001-start-pause-and-administer.md)
- [Assign unlocked classes](us-004-assign-unlocked-classes.md)
- [Support the Vigilante and Followers](us-008-support-vigilante-and-followers.md)
- [Manage a reign through the Tyrant control panel](us-015-manage-tyrant-control-panel.md)
- [Manage Followers through the Vigilante control panel](us-016-manage-vigilante-control-panel.md)
@@ -136,6 +136,14 @@ public final class SpigotTyrantPlugin extends JavaPlugin {
settings.pendingSelectionTimeout(), settings.pendingSelectionTimeout(),
random random
)); ));
Objects.requireNonNull(getCommand("tyrant"), "Missing tyrant command metadata")
.setTabCompleter(new TyrantTabCompleter(stateManager, onlinePlayers));
Objects.requireNonNull(getCommand("vigilante"), "Missing vigilante metadata")
.setTabCompleter(new VigilanteTabCompleter(
stateManager, followers, onlinePlayers
));
Objects.requireNonNull(getCommand("tyrantadmin"), "Missing tyrantadmin metadata")
.setTabCompleter(new TyrantAdminTabCompleter(onlinePlayers));
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new PlayerJoinListener(stateManager, clock, tyrantPresentation), new PlayerJoinListener(stateManager, clock, tyrantPresentation),
this this
@@ -0,0 +1,19 @@
package games.dmg.spigottyrant;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
public final class TabSuggestions {
private TabSuggestions() {
}
public static List<String> matching(String prefix, Collection<String> candidates) {
String normalized = prefix == null ? "" : prefix.toLowerCase(Locale.ROOT);
return candidates.stream()
.filter(candidate -> candidate.toLowerCase(Locale.ROOT).startsWith(normalized))
.distinct()
.sorted(String.CASE_INSENSITIVE_ORDER)
.toList();
}
}
@@ -0,0 +1,60 @@
package games.dmg.spigottyrant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
public final class TyrantAdminTabCompleter implements TabCompleter {
private static final String PERMISSION = "spigottyrant.admin";
private static final List<String> SUBCOMMANDS = List.of(
"status", "start", "pause", "resume", "reset"
);
private final OnlinePlayerDirectory onlinePlayers;
public TyrantAdminTabCompleter(OnlinePlayerDirectory onlinePlayers) {
this.onlinePlayers = onlinePlayers;
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (!sender.hasPermission(PERMISSION) || arguments.length == 0) {
return List.of();
}
if (arguments.length == 1) {
return TabSuggestions.matching(arguments[0], SUBCOMMANDS);
}
String subcommand = arguments[0].toLowerCase(java.util.Locale.ROOT);
if (arguments.length == 2) {
return switch (subcommand) {
case "start" -> TabSuggestions.matching(arguments[1], onlineNames(null));
case "reset" -> TabSuggestions.matching(arguments[1], List.of("confirm"));
default -> List.of();
};
}
if (arguments.length == 3 && subcommand.equals("start")) {
return TabSuggestions.matching(arguments[2], onlineNames(arguments[1]));
}
return List.of();
}
private List<String> onlineNames(String excludedName) {
List<String> names = new ArrayList<>();
for (UUID playerId : onlinePlayers.onlinePlayerIds()) {
Player player = onlinePlayers.findById(playerId);
if (player != null && (excludedName == null
|| !player.getName().equalsIgnoreCase(excludedName))) {
names.add(player.getName());
}
}
return List.copyOf(names);
}
}
@@ -0,0 +1,84 @@
package games.dmg.spigottyrant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
public final class TyrantTabCompleter implements TabCompleter {
private static final List<String> SUBCOMMANDS = List.of(
"menu", "status", "choices", "buy", "assign", "item", "intelligence",
"optout", "optin", "relinquish"
);
private static final List<String> UNLOCKS = java.util.Arrays.stream(TyrantUnlock.values())
.map(value -> value.name().toLowerCase(java.util.Locale.ROOT))
.toList();
private static final List<String> CLASSES = List.of("assassin", "fixer", "tamer");
private final TyrantStateManager stateManager;
private final OnlinePlayerDirectory onlinePlayers;
public TyrantTabCompleter(
TyrantStateManager stateManager,
OnlinePlayerDirectory onlinePlayers
) {
this.stateManager = stateManager;
this.onlinePlayers = onlinePlayers;
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (!(sender instanceof Player player) || arguments.length == 0) {
return List.of();
}
if (arguments.length == 1) {
return TabSuggestions.matching(arguments[0], SUBCOMMANDS);
}
String subcommand = arguments[0].toLowerCase(java.util.Locale.ROOT);
if (arguments.length == 2) {
return switch (subcommand) {
case "buy" -> TabSuggestions.matching(arguments[1], UNLOCKS);
case "assign" -> TabSuggestions.matching(arguments[1], CLASSES);
case "relinquish" -> TabSuggestions.matching(
arguments[1], List.of("confirm")
);
default -> List.of();
};
}
if (arguments.length == 3 && subcommand.equals("assign")) {
return TabSuggestions.matching(arguments[2], eligibleAssignees(player));
}
return List.of();
}
private List<String> eligibleAssignees(Player tyrant) {
Map<UUID, PlayerState> states = stateManager.players();
GameState game = stateManager.game();
Set<UUID> online = onlinePlayers.onlinePlayerIds();
List<String> names = new ArrayList<>();
for (UUID playerId : online) {
if (playerId.equals(tyrant.getUniqueId())
|| game != null && game.tyrantId().filter(playerId::equals).isPresent()) {
continue;
}
PlayerState state = states.get(playerId);
if (state != null && state.optedOutUntil().isPresent()) {
continue;
}
Player candidate = onlinePlayers.findById(playerId);
if (candidate != null) {
names.add(candidate.getName());
}
}
return List.copyOf(names);
}
}
@@ -0,0 +1,96 @@
package games.dmg.spigottyrant;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player;
public final class VigilanteTabCompleter implements TabCompleter {
private static final List<String> SUBCOMMANDS = List.of(
"menu", "item", "invite", "accept", "dismiss", "leave"
);
private final TyrantStateManager stateManager;
private final FollowerService followers;
private final OnlinePlayerDirectory onlinePlayers;
public VigilanteTabCompleter(
TyrantStateManager stateManager,
FollowerService followers,
OnlinePlayerDirectory onlinePlayers
) {
this.stateManager = stateManager;
this.followers = followers;
this.onlinePlayers = onlinePlayers;
}
@Override
public List<String> onTabComplete(
CommandSender sender,
Command command,
String alias,
String[] arguments
) {
if (!(sender instanceof Player player) || arguments.length == 0) {
return List.of();
}
if (arguments.length == 1) {
return TabSuggestions.matching(arguments[0], SUBCOMMANDS);
}
if (arguments.length != 2) {
return List.of();
}
return switch (arguments[0].toLowerCase(java.util.Locale.ROOT)) {
case "invite" -> TabSuggestions.matching(
arguments[1], eligibleInvitees(player)
);
case "dismiss" -> TabSuggestions.matching(
arguments[1], currentFollowers(player)
);
default -> List.of();
};
}
private List<String> eligibleInvitees(Player vigilante) {
GameState game = stateManager.game();
Map<UUID, PlayerState> states = stateManager.players();
Set<UUID> pending = followers.invitedPlayerIds(vigilante.getUniqueId());
List<String> names = new ArrayList<>();
for (UUID playerId : onlinePlayers.onlinePlayerIds()) {
PlayerState state = states.get(playerId);
boolean assignedRole = game.tyrantId().filter(playerId::equals).isPresent()
|| game.vigilanteId().filter(playerId::equals).isPresent();
boolean ineligibleState = state != null
&& (state.optedOutUntil().isPresent() || state.followerOf().isPresent());
if (assignedRole || ineligibleState || pending.contains(playerId)) {
continue;
}
Player candidate = onlinePlayers.findById(playerId);
if (candidate != null) {
names.add(candidate.getName());
}
}
return List.copyOf(names);
}
private List<String> currentFollowers(Player vigilante) {
Map<UUID, PlayerState> states = stateManager.players();
List<String> names = new ArrayList<>();
for (UUID playerId : onlinePlayers.onlinePlayerIds()) {
PlayerState state = states.get(playerId);
if (state == null
|| state.followerOf().filter(vigilante.getUniqueId()::equals).isEmpty()) {
continue;
}
Player candidate = onlinePlayers.findById(playerId);
if (candidate != null) {
names.add(candidate.getName());
}
}
return List.copyOf(names);
}
}
@@ -0,0 +1,55 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.Set;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class TyrantAdminTabCompleterTest {
@Test
void permissionGatesAdministrativeSyntaxAndOnlineCandidates() {
UUID alphaId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID betaId = UUID.fromString("22222222-2222-2222-2222-222222222222");
Player alpha = player(alphaId, "Alpha");
Player beta = player(betaId, "Beta");
OnlinePlayerDirectory directory = mock(OnlinePlayerDirectory.class);
when(directory.onlinePlayerIds()).thenReturn(Set.of(alphaId, betaId));
when(directory.findById(alphaId)).thenReturn(alpha);
when(directory.findById(betaId)).thenReturn(beta);
TyrantAdminTabCompleter completer = new TyrantAdminTabCompleter(directory);
Command command = mock(Command.class);
CommandSender denied = mock(CommandSender.class);
CommandSender admin = mock(CommandSender.class);
when(admin.hasPermission("spigottyrant.admin")).thenReturn(true);
assertEquals(java.util.List.of(), completer.onTabComplete(
denied, command, "tyrantadmin", new String[] {""}
));
assertTrue(completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {""}
).containsAll(java.util.List.of("status", "start", "pause", "resume", "reset")));
assertEquals(java.util.List.of("Alpha", "Beta"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"start", ""}
));
assertEquals(java.util.List.of("Beta"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"start", "Alpha", ""}
));
assertEquals(java.util.List.of("confirm"), completer.onTabComplete(
admin, command, "tyrantadmin", new String[] {"reset", "c"}
));
}
private static Player player(UUID id, String name) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn(name);
return player;
}
}
@@ -0,0 +1,41 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import java.util.List;
import org.bukkit.command.Command;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class TyrantTabCompleterTest {
@Test
void rootBuyAssignAndConfirmationSuggestionsFollowSyntaxAndPrefix() {
TyrantTabCompleter completer = new TyrantTabCompleter(
mock(TyrantStateManager.class), mock(OnlinePlayerDirectory.class)
);
Player player = mock(Player.class);
Command command = mock(Command.class);
List<String> root = completer.onTabComplete(
player, command, "tyrant", new String[] {""}
);
List<String> buy = completer.onTabComplete(
player, command, "tyrant", new String[] {"buy", "ro"}
);
List<String> assign = completer.onTabComplete(
player, command, "tyrant", new String[] {"assign", "f"}
);
List<String> confirm = completer.onTabComplete(
player, command, "tyrant", new String[] {"relinquish", "c"}
);
assertTrue(root.containsAll(List.of("menu", "status", "buy", "assign", "item")));
assertFalse(root.contains("SomePlayer"));
assertEquals(List.of("roster_intelligence"), buy);
assertEquals(List.of("fixer"), assign);
assertEquals(List.of("confirm"), confirm);
}
}
@@ -0,0 +1,73 @@
package games.dmg.spigottyrant;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.time.Duration;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.bukkit.command.Command;
import org.bukkit.entity.Player;
import org.junit.jupiter.api.Test;
final class VigilanteTabCompleterTest {
@Test
void rootAndPlayerArgumentsUseMembershipEligibility() {
UUID tyrantId = UUID.fromString("11111111-1111-1111-1111-111111111111");
UUID vigilanteId = UUID.fromString("22222222-2222-2222-2222-222222222222");
UUID eligibleId = UUID.fromString("33333333-3333-3333-3333-333333333333");
UUID followerId = UUID.fromString("44444444-4444-4444-4444-444444444444");
PlayerState eligible = PlayerState.newPlayer(eligibleId, "Eligible");
PlayerState follower = follower(followerId, "Follower", vigilanteId);
TyrantStateManager manager = mock(TyrantStateManager.class);
when(manager.game()).thenReturn(new GameState(
GameLifecycle.RUNNING, Optional.of(tyrantId), Optional.of(vigilanteId),
Optional.empty(), Optional.empty(), Optional.empty(), Duration.ZERO,
0, 0, Set.of()
));
when(manager.players()).thenReturn(Map.of(eligibleId, eligible, followerId, follower));
OnlinePlayerDirectory directory = mock(OnlinePlayerDirectory.class);
when(directory.onlinePlayerIds()).thenReturn(Set.of(eligibleId, followerId, tyrantId));
Player eligiblePlayer = player(eligibleId, "Eligible");
Player followerPlayer = player(followerId, "Follower");
Player tyrantPlayer = player(tyrantId, "Tyrant");
when(directory.findById(eligibleId)).thenReturn(eligiblePlayer);
when(directory.findById(followerId)).thenReturn(followerPlayer);
when(directory.findById(tyrantId)).thenReturn(tyrantPlayer);
VigilanteTabCompleter completer = new VigilanteTabCompleter(
manager, new FollowerService(), directory
);
Player vigilante = player(vigilanteId, "Vigilante");
Command command = mock(Command.class);
assertTrue(completer.onTabComplete(
vigilante, command, "vigilante", new String[] {""}
).containsAll(java.util.List.of("menu", "item", "invite", "dismiss")));
assertEquals(java.util.List.of("Eligible"), completer.onTabComplete(
vigilante, command, "vigilante", new String[] {"invite", ""}
));
assertEquals(java.util.List.of("Follower"), completer.onTabComplete(
vigilante, command, "vigilante", new String[] {"dismiss", ""}
));
}
private static Player player(UUID id, String name) {
Player player = mock(Player.class);
when(player.getUniqueId()).thenReturn(id);
when(player.getName()).thenReturn(name);
return player;
}
private static PlayerState follower(UUID id, String name, UUID vigilanteId) {
PlayerState state = PlayerState.newPlayer(id, name);
return new PlayerState(
id, name, state.lastLogin(), state.optedOutUntil(), state.tyrantClass(),
Optional.of(vigilanteId), state.cooldownEnds(), state.readyAbilityItems(),
state.capturedMobs()
);
}
}