diff --git a/README.md b/README.md index 5b2c330..37b1b05 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,9 @@ Administrators can register persistent shared quest boards by targeting a block The plugin JAR is written to `build/libs/`. -## Planned player commands +## Player commands -Player commands are disabled by default and can be enabled by an administrator. +Player commands are disabled by default and can be enabled persistently by an administrator. When disabled, players are directed to use a physical quest board. ```text /quests @@ -34,7 +34,7 @@ Player commands are disabled by default and can be enabled by an administrator. /quests claim ``` -## Planned administration +## Administration The `spigotquestboard.admin` permission is granted to server operators by default. diff --git a/knowledge/log.md b/knowledge/log.md index 4788792..4eb54e3 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -69,3 +69,10 @@ description: Chronological record of material decisions affecting Spigot Quest B - Inventory overflow drops at the claimant's feet with ownership protection, and failed claim acknowledgement rolls inventory and drops back before retry. - Added persisted online and next-login expiry notifications. - Verified 73 tests and the plugin JAR with `./gradlew clean check jar`. + +## 2026-09-05 — Administrative player-command control + +- Disabled all player `/quests` command forms and autocomplete by default while preserving equivalent physical-board actions. +- Added persistent `/questadmin commands enable|disable` control with permission-aware autocomplete and failure-safe updates. +- Kept administrative board creation available independently of the player-command setting. +- Verified 82 tests and the plugin JAR with `./gradlew clean check jar`. diff --git a/knowledge/user-stories/us-008-control-player-quest-commands.md b/knowledge/user-stories/us-008-control-player-quest-commands.md index 25e2875..a2b58bf 100644 --- a/knowledge/user-stories/us-008-control-player-quest-commands.md +++ b/knowledge/user-stories/us-008-control-player-quest-commands.md @@ -2,7 +2,7 @@ type: User Story title: "US-008: Control player quest commands" description: Let administrators require physical-board interaction by controlling access to player quest commands. -status: backlog +status: done --- # US-008: Control player quest commands @@ -11,14 +11,14 @@ As an **administrator**, I want to enable or disable player quest commands so th ## Acceptance criteria -- [ ] Player `/quests` commands are disabled by default. -- [ ] An authorized administrator can use `/questadmin commands enable|disable` with contextual autocomplete. -- [ ] The command setting persists across server restarts. -- [ ] When disabled, `/quests`, `list`, `create`, `complete`, `cancel`, and `claim` reject execution with a clear message directing the player to a quest board. -- [ ] Disabling commands does not prevent any equivalent action through a registered board. -- [ ] `/questadmin` remains available to authorized administrators regardless of the player-command setting. -- [ ] Unauthorized users cannot change the setting. -- [ ] Automated tests verify the default, persistence, authorization, every gated subcommand, and autocomplete. +- [x] Player `/quests` commands are disabled by default. +- [x] An authorized administrator can use `/questadmin commands enable|disable` with contextual autocomplete. +- [x] The command setting persists across server restarts. +- [x] When disabled, `/quests`, `list`, `create`, `complete`, `cancel`, and `claim` reject execution with a clear message directing the player to a quest board. +- [x] Disabling commands does not prevent any equivalent action through a registered board. +- [x] `/questadmin` remains available to authorized administrators regardless of the player-command setting. +- [x] Unauthorized users cannot change the setting. +- [x] Automated tests verify the default, persistence, authorization, every gated subcommand, and autocomplete. ## Related diff --git a/src/main/java/games/dmg/spigotquestboard/PlayerCommandSettings.java b/src/main/java/games/dmg/spigotquestboard/PlayerCommandSettings.java new file mode 100644 index 0000000..26c0dec --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/PlayerCommandSettings.java @@ -0,0 +1,23 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; +import java.util.Objects; + +final class PlayerCommandSettings { + private final PlayerCommandSettingsRepository repository; + private boolean enabled; + + PlayerCommandSettings(PlayerCommandSettingsRepository repository) throws IOException { + this.repository = Objects.requireNonNull(repository, "repository"); + enabled = repository.loadEnabled(); + } + + boolean enabled() { + return enabled; + } + + void setEnabled(boolean enabled) throws IOException { + repository.saveEnabled(enabled); + this.enabled = enabled; + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/PlayerCommandSettingsRepository.java b/src/main/java/games/dmg/spigotquestboard/PlayerCommandSettingsRepository.java new file mode 100644 index 0000000..9488748 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/PlayerCommandSettingsRepository.java @@ -0,0 +1,8 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; + +interface PlayerCommandSettingsRepository { + boolean loadEnabled() throws IOException; + void saveEnabled(boolean enabled) throws IOException; +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestAdminCommand.java b/src/main/java/games/dmg/spigotquestboard/QuestAdminCommand.java index 8e054a6..cffb199 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestAdminCommand.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestAdminCommand.java @@ -1,19 +1,24 @@ package games.dmg.spigotquestboard; import java.io.IOException; +import java.util.List; +import java.util.Locale; import java.util.Objects; import org.bukkit.block.Block; 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; -final class QuestAdminCommand implements CommandExecutor { +final class QuestAdminCommand implements CommandExecutor, TabCompleter { private static final String PERMISSION = "spigotquestboard.admin"; private final BoardRegistry registry; + private final PlayerCommandSettings playerCommands; - QuestAdminCommand(BoardRegistry registry) { + QuestAdminCommand(BoardRegistry registry, PlayerCommandSettings playerCommands) { this.registry = Objects.requireNonNull(registry, "registry"); + this.playerCommands = Objects.requireNonNull(playerCommands, "playerCommands"); } @Override @@ -24,18 +29,50 @@ final class QuestAdminCommand implements CommandExecutor { sender.sendMessage("You do not have permission to administer quest boards."); return true; } + if (arguments.length == 2 && "commands".equalsIgnoreCase(arguments[0])) { + return updatePlayerCommands(sender, arguments[1]); + } if (arguments.length != 1 || !"createboard".equalsIgnoreCase(arguments[0])) { - sender.sendMessage("Usage: /questadmin createboard"); + sender.sendMessage( + "Usage: /questadmin createboard | /questadmin commands enable|disable" + ); return true; } + createBoard(sender); + return true; + } + + private boolean updatePlayerCommands(CommandSender sender, String action) { + final boolean enabled; + if ("enable".equalsIgnoreCase(action)) { + enabled = true; + } else if ("disable".equalsIgnoreCase(action)) { + enabled = false; + } else { + sender.sendMessage("Usage: /questadmin commands enable|disable"); + return true; + } + try { + playerCommands.setEnabled(enabled); + sender.sendMessage("Player quest commands " + (enabled ? "enabled." : "disabled.")); + } catch (IOException exception) { + sender.sendMessage( + "Player command setting could not be saved. Player quest commands remain " + + (playerCommands.enabled() ? "enabled." : "disabled.") + ); + } + return true; + } + + private void createBoard(CommandSender sender) { if (!(sender instanceof Player player)) { sender.sendMessage("A player must target the quest board block."); - return true; + return; } Block target = player.getTargetBlockExact(5); if (target == null) { sender.sendMessage("Target a physical block within five blocks."); - return true; + return; } try { BoardRegistrationResult result = registry.register(RegisteredBoard.from(target)); @@ -45,6 +82,28 @@ final class QuestAdminCommand implements CommandExecutor { } catch (IOException exception) { sender.sendMessage("The quest board could not be saved. No board was created."); } - return true; + } + + @Override + public List onTabComplete( + CommandSender sender, Command command, String alias, String[] arguments + ) { + if (!sender.hasPermission(PERMISSION)) { + return List.of(); + } + if (arguments.length == 1) { + return startsWith(List.of("createboard", "commands"), arguments[0]); + } + if (arguments.length == 2 && "commands".equalsIgnoreCase(arguments[0])) { + return startsWith(List.of("enable", "disable"), arguments[1]); + } + return List.of(); + } + + private static List startsWith(List candidates, String prefix) { + String normalized = prefix.toLowerCase(Locale.ROOT); + return candidates.stream() + .filter(candidate -> candidate.startsWith(normalized)) + .toList(); } } diff --git a/src/main/java/games/dmg/spigotquestboard/QuestCommand.java b/src/main/java/games/dmg/spigotquestboard/QuestCommand.java index c8c5310..0a832e1 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestCommand.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestCommand.java @@ -20,6 +20,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter { private final QuestCancellationGateway canceller; private final QuestClaimGateway claimant; private final Clock clock; + private final PlayerCommandSettings playerCommands; QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) { this(creator, browser, null, null, null, clock); @@ -51,6 +52,18 @@ final class QuestCommand implements CommandExecutor, TabCompleter { QuestCancellationGateway canceller, QuestClaimGateway claimant, Clock clock + ) { + this(creator, browser, completer, canceller, claimant, clock, null); + } + + QuestCommand( + QuestCreationGateway creator, + QuestBrowser browser, + QuestCompletionGateway completer, + QuestCancellationGateway canceller, + QuestClaimGateway claimant, + Clock clock, + PlayerCommandSettings playerCommands ) { this.creator = Objects.requireNonNull(creator, "creator"); this.browser = Objects.requireNonNull(browser, "browser"); @@ -58,12 +71,19 @@ final class QuestCommand implements CommandExecutor, TabCompleter { this.canceller = canceller; this.claimant = claimant; this.clock = Objects.requireNonNull(clock, "clock"); + this.playerCommands = playerCommands; } @Override public boolean onCommand( CommandSender sender, Command command, String label, String[] arguments ) { + if (!playerCommandsEnabled()) { + sender.sendMessage( + "Player quest commands are disabled. Use a physical quest board instead." + ); + return true; + } if (arguments.length == 0 || (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0]))) { Instant now = clock.instant(); @@ -158,7 +178,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter { public List onTabComplete( CommandSender sender, Command command, String alias, String[] arguments ) { - if (!(sender instanceof Player)) { + if (!playerCommandsEnabled() || !(sender instanceof Player)) { return List.of(); } if (arguments.length == 1) { @@ -184,6 +204,10 @@ final class QuestCommand implements CommandExecutor, TabCompleter { return List.of(); } + private boolean playerCommandsEnabled() { + return playerCommands == null || playerCommands.enabled(); + } + private static List startsWith(List candidates, String prefix) { String normalized = prefix.toLowerCase(Locale.ROOT); return candidates.stream() diff --git a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java index b02466d..2263f38 100644 --- a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java +++ b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java @@ -12,6 +12,7 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin { public void onEnable() { final BoardRegistry boards; final QuestService quests; + final PlayerCommandSettings playerCommands; try { boards = new BoardRegistry(new YamlBoardRepository( getDataFolder().toPath().resolve("boards.yml") @@ -19,15 +20,17 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin { quests = new QuestService(new YamlQuestRepository( getDataFolder().toPath().resolve("quests.yml") )); + playerCommands = new PlayerCommandSettings( + new YamlPlayerCommandSettingsRepository( + getDataFolder().toPath().resolve("settings.yml") + ) + ); } catch (IOException exception) { - getLogger().log(Level.SEVERE, "Could not load quest board state", exception); + getLogger().log(Level.SEVERE, "Could not load plugin state", exception); getServer().getPluginManager().disablePlugin(this); return; } - QuestAdminCommand admin = new QuestAdminCommand(boards); - command("questadmin").setExecutor(admin); - QuestCreationGateway creator = new QuestCreationController( quests, new BukkitBlockMaterialCatalog(), new BukkitHeldRewardInventory() ); @@ -42,11 +45,13 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin { QuestClaimGateway claimant = new QuestClaimController( quests, new BukkitQuestClaimInventory() ); - QuestCommand questCommand = new QuestCommand( - creator, quests, completer, canceller, claimant, clock + CommandHandlers handlers = commandHandlers( + boards, playerCommands, creator, quests, completer, canceller, claimant, clock ); - command("quests").setExecutor(questCommand); - command("quests").setTabCompleter(questCommand); + command("questadmin").setExecutor(handlers.admin()); + command("questadmin").setTabCompleter(handlers.admin()); + command("quests").setExecutor(handlers.quests()); + command("quests").setTabCompleter(handlers.quests()); getServer().getPluginManager().registerEvents(notifier, this); getServer().getPluginManager().registerEvents( new QuestBoardInteractionListener( @@ -68,6 +73,26 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin { ); } + static CommandHandlers commandHandlers( + BoardRegistry boards, + PlayerCommandSettings playerCommands, + QuestCreationGateway creator, + QuestBrowser browser, + QuestCompletionGateway completer, + QuestCancellationGateway canceller, + QuestClaimGateway claimant, + Clock clock + ) { + return new CommandHandlers( + new QuestAdminCommand(boards, playerCommands), + new QuestCommand( + creator, browser, completer, canceller, claimant, clock, playerCommands + ) + ); + } + + record CommandHandlers(QuestAdminCommand admin, QuestCommand quests) { } + private PluginCommand command(String name) { return Objects.requireNonNull(getCommand(name), "Missing command metadata for " + name); } diff --git a/src/main/java/games/dmg/spigotquestboard/YamlPlayerCommandSettingsRepository.java b/src/main/java/games/dmg/spigotquestboard/YamlPlayerCommandSettingsRepository.java new file mode 100644 index 0000000..4727be3 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/YamlPlayerCommandSettingsRepository.java @@ -0,0 +1,53 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Objects; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +final class YamlPlayerCommandSettingsRepository implements PlayerCommandSettingsRepository { + private static final String ENABLED_PATH = "player-commands.enabled"; + private final Path path; + + YamlPlayerCommandSettingsRepository(Path path) { + this.path = Objects.requireNonNull(path, "path"); + } + + @Override + public boolean loadEnabled() throws IOException { + if (!Files.exists(path)) { + return false; + } + YamlConfiguration yaml = new YamlConfiguration(); + try { + yaml.load(path.toFile()); + } catch (InvalidConfigurationException exception) { + throw new IOException("Invalid player command settings", exception); + } + return yaml.getBoolean(ENABLED_PATH, false); + } + + @Override + public void saveEnabled(boolean enabled) throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.set(ENABLED_PATH, enabled); + Path parent = path.toAbsolutePath().getParent(); + Files.createDirectories(parent); + Path temporary = Files.createTempFile(parent, path.getFileName().toString(), ".tmp"); + try { + yaml.save(temporary.toFile()); + try { + Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING, + StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException exception) { + Files.move(temporary, path, StandardCopyOption.REPLACE_EXISTING); + } + } finally { + Files.deleteIfExists(temporary); + } + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/PlayerCommandSettingsTest.java b/src/test/java/games/dmg/spigotquestboard/PlayerCommandSettingsTest.java new file mode 100644 index 0000000..1cd296d --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/PlayerCommandSettingsTest.java @@ -0,0 +1,52 @@ +package games.dmg.spigotquestboard; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class PlayerCommandSettingsTest { + @TempDir Path temporaryDirectory; + + @Test + void defaultsDisabledAndPersistsEnabledState() throws Exception { + Path path = temporaryDirectory.resolve("settings.yml"); + PlayerCommandSettings settings = new PlayerCommandSettings( + new YamlPlayerCommandSettingsRepository(path) + ); + + assertFalse(settings.enabled()); + settings.setEnabled(true); + + PlayerCommandSettings reloaded = new PlayerCommandSettings( + new YamlPlayerCommandSettingsRepository(path) + ); + assertTrue(reloaded.enabled()); + + reloaded.setEnabled(false); + + assertFalse(new PlayerCommandSettings( + new YamlPlayerCommandSettingsRepository(path) + ).enabled()); + } + + @Test + void persistenceFailureLeavesPriorState() throws Exception { + PlayerCommandSettings settings = new PlayerCommandSettings( + new PlayerCommandSettingsRepository() { + @Override public boolean loadEnabled() { return false; } + @Override public void saveEnabled(boolean enabled) throws IOException { + throw new IOException("disk full"); + } + } + ); + + assertThrows(IOException.class, () -> settings.setEnabled(true)); + + assertFalse(settings.enabled()); + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/PluginCommandWiringTest.java b/src/test/java/games/dmg/spigotquestboard/PluginCommandWiringTest.java new file mode 100644 index 0000000..4b57b82 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/PluginCommandWiringTest.java @@ -0,0 +1,56 @@ +package games.dmg.spigotquestboard; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.util.List; +import java.util.Set; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +final class PluginCommandWiringTest { + @Test + void adminAndPlayerCommandsShareThePersistedSetting() throws Exception { + PlayerCommandSettings settings = new PlayerCommandSettings( + new PlayerCommandSettingsRepository() { + private boolean enabled; + @Override public boolean loadEnabled() { return enabled; } + @Override public void saveEnabled(boolean enabled) { this.enabled = enabled; } + } + ); + BoardRepository boards = new BoardRepository() { + @Override public BoardState load() { return new BoardState(Set.of()); } + @Override public void save(BoardState state) { } + }; + QuestBrowser browser = mock(QuestBrowser.class); + when(browser.activeQuests(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); + SpigotQuestBoardPlugin.CommandHandlers handlers = + SpigotQuestBoardPlugin.commandHandlers( + new BoardRegistry(boards), settings, mock(QuestCreationGateway.class), browser, + null, null, null, Clock.systemUTC() + ); + Player player = mock(Player.class); + CommandSender admin = mock(CommandSender.class); + when(admin.hasPermission("spigotquestboard.admin")).thenReturn(true); + Command command = mock(Command.class); + + assertTrue(handlers.quests().onCommand( + player, command, "quests", new String[] {"list"} + )); + verify(player).sendMessage( + "Player quest commands are disabled. Use a physical quest board instead." + ); + + handlers.admin().onCommand( + admin, command, "questadmin", new String[] {"commands", "enable"} + ); + handlers.quests().onCommand(player, command, "quests", new String[] {"list"}); + + verify(browser).activeQuests(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/QuestAdminCommandTest.java b/src/test/java/games/dmg/spigotquestboard/QuestAdminCommandTest.java index 09ea8a4..b042694 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestAdminCommandTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestAdminCommandTest.java @@ -1,5 +1,6 @@ package games.dmg.spigotquestboard; +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; @@ -7,6 +8,8 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.io.IOException; +import java.util.List; import java.util.Set; import java.util.UUID; import org.bukkit.World; @@ -20,7 +23,10 @@ final class QuestAdminCommandTest { @Test void unauthorizedSenderCannotCreateBoard() throws Exception { MemoryBoardRepository repository = new MemoryBoardRepository(); - QuestAdminCommand executor = new QuestAdminCommand(new BoardRegistry(repository)); + QuestAdminCommand executor = new QuestAdminCommand( + new BoardRegistry(repository), + new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository()) + ); Player player = mock(Player.class); when(player.hasPermission("spigotquestboard.admin")).thenReturn(false); @@ -30,10 +36,111 @@ final class QuestAdminCommandTest { assertTrue(repository.state.boards().isEmpty()); } + @Test + void authorizedSenderCanEnableAndDisablePlayerCommands() throws Exception { + MemoryPlayerCommandSettingsRepository settingsRepository = + new MemoryPlayerCommandSettingsRepository(); + PlayerCommandSettings settings = new PlayerCommandSettings(settingsRepository); + QuestAdminCommand executor = new QuestAdminCommand( + new BoardRegistry(new MemoryBoardRepository()), settings + ); + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigotquestboard.admin")).thenReturn(true); + + assertTrue(executor.onCommand( + sender, mock(Command.class), "questadmin", new String[] {"commands", "enable"} + )); + assertTrue(settings.enabled()); + assertTrue(executor.onCommand( + sender, mock(Command.class), "questadmin", new String[] {"commands", "disable"} + )); + + assertFalse(settings.enabled()); + verify(sender).sendMessage("Player quest commands enabled."); + verify(sender).sendMessage("Player quest commands disabled."); + } + + @Test + void unauthorizedSenderCannotChangePlayerCommands() throws Exception { + MemoryPlayerCommandSettingsRepository repository = + new MemoryPlayerCommandSettingsRepository(); + PlayerCommandSettings settings = new PlayerCommandSettings(repository); + QuestAdminCommand executor = new QuestAdminCommand( + new BoardRegistry(new MemoryBoardRepository()), settings + ); + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigotquestboard.admin")).thenReturn(false); + + executor.onCommand( + sender, mock(Command.class), "questadmin", new String[] {"commands", "enable"} + ); + + assertFalse(settings.enabled()); + assertEquals(0, repository.saves); + } + + @Test + void settingsPersistenceFailureIsReportedAndLeavesPriorState() throws Exception { + PlayerCommandSettings settings = new PlayerCommandSettings( + new PlayerCommandSettingsRepository() { + @Override public boolean loadEnabled() { return false; } + @Override public void saveEnabled(boolean enabled) throws IOException { + throw new IOException("disk full"); + } + } + ); + QuestAdminCommand executor = new QuestAdminCommand( + new BoardRegistry(new MemoryBoardRepository()), settings + ); + CommandSender sender = mock(CommandSender.class); + when(sender.hasPermission("spigotquestboard.admin")).thenReturn(true); + + executor.onCommand( + sender, mock(Command.class), "questadmin", new String[] {"commands", "enable"} + ); + + assertFalse(settings.enabled()); + verify(sender).sendMessage( + "Player command setting could not be saved. Player quest commands remain disabled." + ); + } + + @Test + void autocompleteIsPermissionAwareAndContextual() throws Exception { + QuestAdminCommand executor = new QuestAdminCommand( + new BoardRegistry(new MemoryBoardRepository()), + new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository()) + ); + CommandSender authorized = mock(CommandSender.class); + when(authorized.hasPermission("spigotquestboard.admin")).thenReturn(true); + CommandSender unauthorized = mock(CommandSender.class); + when(unauthorized.hasPermission("spigotquestboard.admin")).thenReturn(false); + Command command = mock(Command.class); + + assertEquals(List.of("commands"), executor.onTabComplete( + authorized, command, "questadmin", new String[] {"com"} + )); + assertEquals(List.of("enable"), executor.onTabComplete( + authorized, command, "questadmin", new String[] {"commands", "en"} + )); + assertEquals(List.of("disable"), executor.onTabComplete( + authorized, command, "questadmin", new String[] {"commands", "di"} + )); + assertTrue(executor.onTabComplete( + authorized, command, "questadmin", new String[] {"createboard", ""} + ).isEmpty()); + assertTrue(executor.onTabComplete( + unauthorized, command, "questadmin", new String[] {""} + ).isEmpty()); + } + @Test void registersTargetedPhysicalBlock() throws Exception { MemoryBoardRepository repository = new MemoryBoardRepository(); - QuestAdminCommand executor = new QuestAdminCommand(new BoardRegistry(repository)); + QuestAdminCommand executor = new QuestAdminCommand( + new BoardRegistry(repository), + new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository()) + ); Player player = mock(Player.class); Block block = mock(Block.class); World world = mock(World.class); @@ -57,7 +164,10 @@ final class QuestAdminCommandTest { @Test void consoleAndMissingTargetDoNotChangeState() throws Exception { MemoryBoardRepository repository = new MemoryBoardRepository(); - QuestAdminCommand executor = new QuestAdminCommand(new BoardRegistry(repository)); + QuestAdminCommand executor = new QuestAdminCommand( + new BoardRegistry(repository), + new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository()) + ); CommandSender console = mock(CommandSender.class); when(console.hasPermission("spigotquestboard.admin")).thenReturn(true); assertTrue(executor.onCommand(console, mock(Command.class), "questadmin", new String[] {"createboard"})); @@ -70,6 +180,18 @@ final class QuestAdminCommandTest { assertTrue(repository.state.boards().isEmpty()); } + private static final class MemoryPlayerCommandSettingsRepository + implements PlayerCommandSettingsRepository { + private boolean enabled; + private int saves; + + @Override public boolean loadEnabled() { return enabled; } + @Override public void saveEnabled(boolean enabled) { + this.enabled = enabled; + saves++; + } + } + private static final class MemoryBoardRepository implements BoardRepository { private BoardState state = new BoardState(Set.of()); @Override public BoardState load() { return state; } diff --git a/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java b/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java index a28b2b8..9c4e5b8 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestBoardDialogUiTest.java @@ -16,6 +16,42 @@ import org.junit.jupiter.api.Test; final class QuestBoardDialogUiTest { private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z"); + @Test + void disabledPlayerCommandsDoNotGateBoardUiGateways() throws Exception { + PlayerCommandSettings settings = new PlayerCommandSettings( + new PlayerCommandSettingsRepository() { + @Override public boolean loadEnabled() { return false; } + @Override public void saveEnabled(boolean enabled) { } + } + ); + RecordingCreator creator = new RecordingCreator(false); + RecordingCompleter completer = new RecordingCompleter(); + RecordingCanceller canceller = new RecordingCanceller(); + java.util.concurrent.atomic.AtomicReference claimantPlayer = + new java.util.concurrent.atomic.AtomicReference<>(); + QuestClaimGateway claimant = player -> { + claimantPlayer.set(player); + return new ClaimCollectionResult(0, 0); + }; + QuestBoardDialogUi ui = new QuestBoardDialogUi( + creator, now -> List.of(creator.quest(NOW)), completer, canceller, claimant, + Clock.fixed(NOW, ZoneOffset.UTC) + ); + Player player = mock(Player.class); + + ui.listingText(); + ui.submit(player, "stone", "1"); + ui.submitCompletion(player, "complete-id"); + ui.submitCancellation(player, "cancel-id"); + ui.submitClaim(player); + + org.junit.jupiter.api.Assertions.assertFalse(settings.enabled()); + assertEquals("stone", creator.material); + assertEquals(player, completer.player); + assertEquals(player, canceller.player); + assertEquals(player, claimantPlayer.get()); + } + @Test void dialogSubmissionUsesEquivalentCreationFlow() { RecordingCreator creator = new RecordingCreator(false); diff --git a/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java b/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java index f295252..6017757 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java @@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.contains; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; import java.time.Clock; @@ -20,6 +21,47 @@ import org.junit.jupiter.api.Test; final class QuestCommandTest { private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z"); + @Test + void disabledSettingGatesEveryPlayerCommandFormAndAutocomplete() throws Exception { + QuestCreationGateway creator = mock(QuestCreationGateway.class); + QuestBrowser browser = mock(QuestBrowser.class); + QuestCompletionGateway completer = mock(QuestCompletionGateway.class); + QuestCancellationGateway canceller = mock(QuestCancellationGateway.class); + QuestClaimGateway claimant = mock(QuestClaimGateway.class); + PlayerCommandSettings settings = new PlayerCommandSettings( + new PlayerCommandSettingsRepository() { + @Override public boolean loadEnabled() { return false; } + @Override public void saveEnabled(boolean enabled) { } + } + ); + QuestCommand executor = new QuestCommand( + creator, browser, completer, canceller, claimant, + Clock.fixed(NOW, ZoneOffset.UTC), settings + ); + Player player = mock(Player.class); + Command command = mock(Command.class); + List forms = List.of( + new String[] {}, + new String[] {"list"}, + new String[] {"create", "stone", "1"}, + new String[] {"complete", "quest-id"}, + new String[] {"cancel", "quest-id"}, + new String[] {"claim"} + ); + + for (String[] form : forms) { + assertTrue(executor.onCommand(player, command, "quests", form)); + } + + verify(player, times(forms.size())).sendMessage( + "Player quest commands are disabled. Use a physical quest board instead." + ); + assertTrue(executor.onTabComplete( + player, command, "quests", new String[] {""} + ).isEmpty()); + verifyNoInteractions(creator, browser, completer, canceller, claimant); + } + @Test void routesValidatedCreateArgumentsWithCurrentUtcTime() { RecordingCreator creator = new RecordingCreator();