diff --git a/design/log.md b/design/log.md index 0e2c60e..79be9fc 100644 --- a/design/log.md +++ b/design/log.md @@ -6,6 +6,18 @@ description: Chronological record of material decisions affecting the Spigot Tyr # 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 - 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. diff --git a/design/user-stories/index.md b/design/user-stories/index.md index 4cc618d..09756b5 100644 --- a/design/user-stories/index.md +++ b/design/user-stories/index.md @@ -17,3 +17,4 @@ 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) 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) diff --git a/design/user-stories/us-018-complete-commands-contextually.md b/design/user-stories/us-018-complete-commands-contextually.md new file mode 100644 index 0000000..57487fb --- /dev/null +++ b/design/user-stories/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) diff --git a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java index 4e009ff..08c5623 100644 --- a/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java +++ b/src/main/java/games/dmg/spigottyrant/SpigotTyrantPlugin.java @@ -136,6 +136,14 @@ public final class SpigotTyrantPlugin extends JavaPlugin { settings.pendingSelectionTimeout(), 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( new PlayerJoinListener(stateManager, clock, tyrantPresentation), this diff --git a/src/main/java/games/dmg/spigottyrant/TabSuggestions.java b/src/main/java/games/dmg/spigottyrant/TabSuggestions.java new file mode 100644 index 0000000..e3cc11f --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TabSuggestions.java @@ -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 matching(String prefix, Collection 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(); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/TyrantAdminTabCompleter.java b/src/main/java/games/dmg/spigottyrant/TyrantAdminTabCompleter.java new file mode 100644 index 0000000..fa08511 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantAdminTabCompleter.java @@ -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 SUBCOMMANDS = List.of( + "status", "start", "pause", "resume", "reset" + ); + private final OnlinePlayerDirectory onlinePlayers; + + public TyrantAdminTabCompleter(OnlinePlayerDirectory onlinePlayers) { + this.onlinePlayers = onlinePlayers; + } + + @Override + public List 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 onlineNames(String excludedName) { + List 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); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/TyrantTabCompleter.java b/src/main/java/games/dmg/spigottyrant/TyrantTabCompleter.java new file mode 100644 index 0000000..f7490c6 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/TyrantTabCompleter.java @@ -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 SUBCOMMANDS = List.of( + "menu", "status", "choices", "buy", "assign", "item", "intelligence", + "optout", "optin", "relinquish" + ); + private static final List UNLOCKS = java.util.Arrays.stream(TyrantUnlock.values()) + .map(value -> value.name().toLowerCase(java.util.Locale.ROOT)) + .toList(); + private static final List 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 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 eligibleAssignees(Player tyrant) { + Map states = stateManager.players(); + GameState game = stateManager.game(); + Set online = onlinePlayers.onlinePlayerIds(); + List 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); + } +} diff --git a/src/main/java/games/dmg/spigottyrant/VigilanteTabCompleter.java b/src/main/java/games/dmg/spigottyrant/VigilanteTabCompleter.java new file mode 100644 index 0000000..e043252 --- /dev/null +++ b/src/main/java/games/dmg/spigottyrant/VigilanteTabCompleter.java @@ -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 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 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 eligibleInvitees(Player vigilante) { + GameState game = stateManager.game(); + Map states = stateManager.players(); + Set pending = followers.invitedPlayerIds(vigilante.getUniqueId()); + List 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 currentFollowers(Player vigilante) { + Map states = stateManager.players(); + List 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); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/TyrantAdminTabCompleterTest.java b/src/test/java/games/dmg/spigottyrant/TyrantAdminTabCompleterTest.java new file mode 100644 index 0000000..48d9fd5 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/TyrantAdminTabCompleterTest.java @@ -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; + } +} diff --git a/src/test/java/games/dmg/spigottyrant/TyrantTabCompleterTest.java b/src/test/java/games/dmg/spigottyrant/TyrantTabCompleterTest.java new file mode 100644 index 0000000..0e3814f --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/TyrantTabCompleterTest.java @@ -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 root = completer.onTabComplete( + player, command, "tyrant", new String[] {""} + ); + List buy = completer.onTabComplete( + player, command, "tyrant", new String[] {"buy", "ro"} + ); + List assign = completer.onTabComplete( + player, command, "tyrant", new String[] {"assign", "f"} + ); + List 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); + } +} diff --git a/src/test/java/games/dmg/spigottyrant/VigilanteTabCompleterTest.java b/src/test/java/games/dmg/spigottyrant/VigilanteTabCompleterTest.java new file mode 100644 index 0000000..9d4a509 --- /dev/null +++ b/src/test/java/games/dmg/spigottyrant/VigilanteTabCompleterTest.java @@ -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() + ); + } +}