feat(admin): control player quest commands
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user