feat(admin): control player quest commands
Release / release (push) Successful in 2m8s
CI / build (push) Successful in 1m15s

This commit is contained in:
dmg
2026-09-05 08:36:45 -04:00
parent af6d6d0827
commit be13b9b87c
14 changed files with 537 additions and 30 deletions
+3 -3
View File
@@ -21,9 +21,9 @@ Administrators can register persistent shared quest boards by targeting a block
The plugin JAR is written to `build/libs/`. 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 ```text
/quests /quests
@@ -34,7 +34,7 @@ Player commands are disabled by default and can be enabled by an administrator.
/quests claim /quests claim
``` ```
## Planned administration ## Administration
The `spigotquestboard.admin` permission is granted to server operators by default. The `spigotquestboard.admin` permission is granted to server operators by default.
+7
View File
@@ -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. - 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. - Added persisted online and next-login expiry notifications.
- Verified 73 tests and the plugin JAR with `./gradlew clean check jar`. - 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`.
@@ -2,7 +2,7 @@
type: User Story type: User Story
title: "US-008: Control player quest commands" title: "US-008: Control player quest commands"
description: Let administrators require physical-board interaction by controlling access to 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 # 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 ## Acceptance criteria
- [ ] Player `/quests` commands are disabled by default. - [x] Player `/quests` commands are disabled by default.
- [ ] An authorized administrator can use `/questadmin commands enable|disable` with contextual autocomplete. - [x] An authorized administrator can use `/questadmin commands enable|disable` with contextual autocomplete.
- [ ] The command setting persists across server restarts. - [x] 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. - [x] 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. - [x] Disabling commands does not prevent any equivalent action through a registered board.
- [ ] `/questadmin` remains available to authorized administrators regardless of the player-command setting. - [x] `/questadmin` remains available to authorized administrators regardless of the player-command setting.
- [ ] Unauthorized users cannot change the setting. - [x] Unauthorized users cannot change the setting.
- [ ] Automated tests verify the default, persistence, authorization, every gated subcommand, and autocomplete. - [x] Automated tests verify the default, persistence, authorization, every gated subcommand, and autocomplete.
## Related ## Related
@@ -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;
}
}
@@ -0,0 +1,8 @@
package games.dmg.spigotquestboard;
import java.io.IOException;
interface PlayerCommandSettingsRepository {
boolean loadEnabled() throws IOException;
void saveEnabled(boolean enabled) throws IOException;
}
@@ -1,19 +1,24 @@
package games.dmg.spigotquestboard; package games.dmg.spigotquestboard;
import java.io.IOException; import java.io.IOException;
import java.util.List;
import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import org.bukkit.block.Block; import org.bukkit.block.Block;
import org.bukkit.command.Command; import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender; import org.bukkit.command.CommandSender;
import org.bukkit.command.TabCompleter;
import org.bukkit.entity.Player; import org.bukkit.entity.Player;
final class QuestAdminCommand implements CommandExecutor { final class QuestAdminCommand implements CommandExecutor, TabCompleter {
private static final String PERMISSION = "spigotquestboard.admin"; private static final String PERMISSION = "spigotquestboard.admin";
private final BoardRegistry registry; private final BoardRegistry registry;
private final PlayerCommandSettings playerCommands;
QuestAdminCommand(BoardRegistry registry) { QuestAdminCommand(BoardRegistry registry, PlayerCommandSettings playerCommands) {
this.registry = Objects.requireNonNull(registry, "registry"); this.registry = Objects.requireNonNull(registry, "registry");
this.playerCommands = Objects.requireNonNull(playerCommands, "playerCommands");
} }
@Override @Override
@@ -24,18 +29,50 @@ final class QuestAdminCommand implements CommandExecutor {
sender.sendMessage("You do not have permission to administer quest boards."); sender.sendMessage("You do not have permission to administer quest boards.");
return true; return true;
} }
if (arguments.length == 2 && "commands".equalsIgnoreCase(arguments[0])) {
return updatePlayerCommands(sender, arguments[1]);
}
if (arguments.length != 1 || !"createboard".equalsIgnoreCase(arguments[0])) { if (arguments.length != 1 || !"createboard".equalsIgnoreCase(arguments[0])) {
sender.sendMessage("Usage: /questadmin createboard"); sender.sendMessage(
"Usage: /questadmin createboard | /questadmin commands enable|disable"
);
return true; 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)) { if (!(sender instanceof Player player)) {
sender.sendMessage("A player must target the quest board block."); sender.sendMessage("A player must target the quest board block.");
return true; return;
} }
Block target = player.getTargetBlockExact(5); Block target = player.getTargetBlockExact(5);
if (target == null) { if (target == null) {
sender.sendMessage("Target a physical block within five blocks."); sender.sendMessage("Target a physical block within five blocks.");
return true; return;
} }
try { try {
BoardRegistrationResult result = registry.register(RegisteredBoard.from(target)); BoardRegistrationResult result = registry.register(RegisteredBoard.from(target));
@@ -45,6 +82,28 @@ final class QuestAdminCommand implements CommandExecutor {
} catch (IOException exception) { } catch (IOException exception) {
sender.sendMessage("The quest board could not be saved. No board was created."); sender.sendMessage("The quest board could not be saved. No board was created.");
} }
return true; }
@Override
public List<String> 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<String> startsWith(List<String> candidates, String prefix) {
String normalized = prefix.toLowerCase(Locale.ROOT);
return candidates.stream()
.filter(candidate -> candidate.startsWith(normalized))
.toList();
} }
} }
@@ -20,6 +20,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
private final QuestCancellationGateway canceller; private final QuestCancellationGateway canceller;
private final QuestClaimGateway claimant; private final QuestClaimGateway claimant;
private final Clock clock; private final Clock clock;
private final PlayerCommandSettings playerCommands;
QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) { QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) {
this(creator, browser, null, null, null, clock); this(creator, browser, null, null, null, clock);
@@ -51,6 +52,18 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
QuestCancellationGateway canceller, QuestCancellationGateway canceller,
QuestClaimGateway claimant, QuestClaimGateway claimant,
Clock clock 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.creator = Objects.requireNonNull(creator, "creator");
this.browser = Objects.requireNonNull(browser, "browser"); this.browser = Objects.requireNonNull(browser, "browser");
@@ -58,12 +71,19 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
this.canceller = canceller; this.canceller = canceller;
this.claimant = claimant; this.claimant = claimant;
this.clock = Objects.requireNonNull(clock, "clock"); this.clock = Objects.requireNonNull(clock, "clock");
this.playerCommands = playerCommands;
} }
@Override @Override
public boolean onCommand( public boolean onCommand(
CommandSender sender, Command command, String label, String[] arguments 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 if (arguments.length == 0
|| (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0]))) { || (arguments.length == 1 && "list".equalsIgnoreCase(arguments[0]))) {
Instant now = clock.instant(); Instant now = clock.instant();
@@ -158,7 +178,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
public List<String> onTabComplete( public List<String> onTabComplete(
CommandSender sender, Command command, String alias, String[] arguments CommandSender sender, Command command, String alias, String[] arguments
) { ) {
if (!(sender instanceof Player)) { if (!playerCommandsEnabled() || !(sender instanceof Player)) {
return List.of(); return List.of();
} }
if (arguments.length == 1) { if (arguments.length == 1) {
@@ -184,6 +204,10 @@ final class QuestCommand implements CommandExecutor, TabCompleter {
return List.of(); return List.of();
} }
private boolean playerCommandsEnabled() {
return playerCommands == null || playerCommands.enabled();
}
private static List<String> startsWith(List<String> candidates, String prefix) { private static List<String> startsWith(List<String> candidates, String prefix) {
String normalized = prefix.toLowerCase(Locale.ROOT); String normalized = prefix.toLowerCase(Locale.ROOT);
return candidates.stream() return candidates.stream()
@@ -12,6 +12,7 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
public void onEnable() { public void onEnable() {
final BoardRegistry boards; final BoardRegistry boards;
final QuestService quests; final QuestService quests;
final PlayerCommandSettings playerCommands;
try { try {
boards = new BoardRegistry(new YamlBoardRepository( boards = new BoardRegistry(new YamlBoardRepository(
getDataFolder().toPath().resolve("boards.yml") getDataFolder().toPath().resolve("boards.yml")
@@ -19,15 +20,17 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
quests = new QuestService(new YamlQuestRepository( quests = new QuestService(new YamlQuestRepository(
getDataFolder().toPath().resolve("quests.yml") getDataFolder().toPath().resolve("quests.yml")
)); ));
playerCommands = new PlayerCommandSettings(
new YamlPlayerCommandSettingsRepository(
getDataFolder().toPath().resolve("settings.yml")
)
);
} catch (IOException exception) { } 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); getServer().getPluginManager().disablePlugin(this);
return; return;
} }
QuestAdminCommand admin = new QuestAdminCommand(boards);
command("questadmin").setExecutor(admin);
QuestCreationGateway creator = new QuestCreationController( QuestCreationGateway creator = new QuestCreationController(
quests, new BukkitBlockMaterialCatalog(), new BukkitHeldRewardInventory() quests, new BukkitBlockMaterialCatalog(), new BukkitHeldRewardInventory()
); );
@@ -42,11 +45,13 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin {
QuestClaimGateway claimant = new QuestClaimController( QuestClaimGateway claimant = new QuestClaimController(
quests, new BukkitQuestClaimInventory() quests, new BukkitQuestClaimInventory()
); );
QuestCommand questCommand = new QuestCommand( CommandHandlers handlers = commandHandlers(
creator, quests, completer, canceller, claimant, clock boards, playerCommands, creator, quests, completer, canceller, claimant, clock
); );
command("quests").setExecutor(questCommand); command("questadmin").setExecutor(handlers.admin());
command("quests").setTabCompleter(questCommand); command("questadmin").setTabCompleter(handlers.admin());
command("quests").setExecutor(handlers.quests());
command("quests").setTabCompleter(handlers.quests());
getServer().getPluginManager().registerEvents(notifier, this); getServer().getPluginManager().registerEvents(notifier, this);
getServer().getPluginManager().registerEvents( getServer().getPluginManager().registerEvents(
new QuestBoardInteractionListener( 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) { private PluginCommand command(String name) {
return Objects.requireNonNull(getCommand(name), "Missing command metadata for " + name); return Objects.requireNonNull(getCommand(name), "Missing command metadata for " + name);
} }
@@ -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);
}
}
}
@@ -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());
}
}
@@ -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());
}
}
@@ -1,5 +1,6 @@
package games.dmg.spigotquestboard; 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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock; 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.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import java.io.IOException;
import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.UUID; import java.util.UUID;
import org.bukkit.World; import org.bukkit.World;
@@ -20,7 +23,10 @@ final class QuestAdminCommandTest {
@Test @Test
void unauthorizedSenderCannotCreateBoard() throws Exception { void unauthorizedSenderCannotCreateBoard() throws Exception {
MemoryBoardRepository repository = new MemoryBoardRepository(); 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); Player player = mock(Player.class);
when(player.hasPermission("spigotquestboard.admin")).thenReturn(false); when(player.hasPermission("spigotquestboard.admin")).thenReturn(false);
@@ -30,10 +36,111 @@ final class QuestAdminCommandTest {
assertTrue(repository.state.boards().isEmpty()); 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 @Test
void registersTargetedPhysicalBlock() throws Exception { void registersTargetedPhysicalBlock() throws Exception {
MemoryBoardRepository repository = new MemoryBoardRepository(); 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); Player player = mock(Player.class);
Block block = mock(Block.class); Block block = mock(Block.class);
World world = mock(World.class); World world = mock(World.class);
@@ -57,7 +164,10 @@ final class QuestAdminCommandTest {
@Test @Test
void consoleAndMissingTargetDoNotChangeState() throws Exception { void consoleAndMissingTargetDoNotChangeState() throws Exception {
MemoryBoardRepository repository = new MemoryBoardRepository(); 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); CommandSender console = mock(CommandSender.class);
when(console.hasPermission("spigotquestboard.admin")).thenReturn(true); when(console.hasPermission("spigotquestboard.admin")).thenReturn(true);
assertTrue(executor.onCommand(console, mock(Command.class), "questadmin", new String[] {"createboard"})); assertTrue(executor.onCommand(console, mock(Command.class), "questadmin", new String[] {"createboard"}));
@@ -70,6 +180,18 @@ final class QuestAdminCommandTest {
assertTrue(repository.state.boards().isEmpty()); 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 static final class MemoryBoardRepository implements BoardRepository {
private BoardState state = new BoardState(Set.of()); private BoardState state = new BoardState(Set.of());
@Override public BoardState load() { return state; } @Override public BoardState load() { return state; }
@@ -16,6 +16,42 @@ import org.junit.jupiter.api.Test;
final class QuestBoardDialogUiTest { final class QuestBoardDialogUiTest {
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z"); 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<Player> 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 @Test
void dialogSubmissionUsesEquivalentCreationFlow() { void dialogSubmissionUsesEquivalentCreationFlow() {
RecordingCreator creator = new RecordingCreator(false); RecordingCreator creator = new RecordingCreator(false);
@@ -6,6 +6,7 @@ import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times; import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import java.time.Clock; import java.time.Clock;
@@ -20,6 +21,47 @@ import org.junit.jupiter.api.Test;
final class QuestCommandTest { final class QuestCommandTest {
private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z"); 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<String[]> 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 @Test
void routesValidatedCreateArgumentsWithCurrentUtcTime() { void routesValidatedCreateArgumentsWithCurrentUtcTime() {
RecordingCreator creator = new RecordingCreator(); RecordingCreator creator = new RecordingCreator();