From e12e31c0adafe0a81c5bff48d7b4c6d1245223c2 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 5 Sep 2026 10:03:30 -0400 Subject: [PATCH] feat(quests): allow nearby command creation --- README.md | 2 +- knowledge/log.md | 7 ++ knowledge/user-stories/index.md | 1 + .../us-008-control-player-quest-commands.md | 3 +- .../us-012-create-quests-near-a-board.md | 29 +++++ .../dmg/spigotquestboard/BoardProximity.java | 8 ++ .../dmg/spigotquestboard/BoardRegistry.java | 26 +++- .../dmg/spigotquestboard/QuestCommand.java | 64 ++++++++-- .../SpigotQuestBoardPlugin.java | 3 +- .../spigotquestboard/BoardRegistryTest.java | 18 +++ .../PhysicalBoardCreatorTest.java | 4 + .../PluginCommandWiringTest.java | 50 ++++++++ .../spigotquestboard/QuestCommandTest.java | 115 +++++++++++++++++- 13 files changed, 317 insertions(+), 13 deletions(-) create mode 100644 knowledge/user-stories/us-012-create-quests-near-a-board.md create mode 100644 src/main/java/games/dmg/spigotquestboard/BoardProximity.java diff --git a/README.md b/README.md index dbad118..08c5d8e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The plugin JAR is written to `build/libs/`. ## Player commands -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. +Player commands are disabled by default and can be enabled persistently by an administrator. When disabled, players within five blocks of a registered board can still use `/quests create ` with material and quantity autocomplete; other player command forms remain disabled. ```text /quests diff --git a/knowledge/log.md b/knowledge/log.md index bf8e600..6d7fc32 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -105,3 +105,10 @@ description: Chronological record of material decisions affecting Spigot Quest B - Added exact full-structure detection to refresh existing generated boards once without modifying custom or altered signs. - Added two-sign snapshot rollback when a refresh cannot complete safely. - Verified 116 tests and the plugin JAR with `./gradlew clean check jar`. + +## 2026-09-05 — Nearby command-based quest creation + +- Allowed `/quests create ` and its material and quantity autocomplete within five blocks of any registered board location even while global player commands are disabled. +- Kept listing, completion, cancellation, and claiming commands disabled under that setting while preserving global command behavior when enabled. +- Enforced same-world Euclidean proximity with an inclusive five-block boundary for custom and generated boards. +- Verified 121 tests and the plugin JAR with `./gradlew clean check jar`. diff --git a/knowledge/user-stories/index.md b/knowledge/user-stories/index.md index 8e695b0..f769bf8 100644 --- a/knowledge/user-stories/index.md +++ b/knowledge/user-stories/index.md @@ -17,3 +17,4 @@ description: Catalog of user stories for the Spigot Quest Board plugin. 9. [US-009: Use a screen-fitting quest-board interface](us-009-use-a-screen-fitting-quest-board-interface.md) 10. [US-010: Generate a physical quest-board structure](us-010-generate-a-physical-quest-board.md) 11. [US-011: Add readable physical-board signage](us-011-add-readable-physical-board-signage.md) +12. [US-012: Create quests by command near a board](us-012-create-quests-near-a-board.md) 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 a2b58bf..37349ac 100644 --- a/knowledge/user-stories/us-008-control-player-quest-commands.md +++ b/knowledge/user-stories/us-008-control-player-quest-commands.md @@ -14,7 +14,7 @@ As an **administrator**, I want to enable or disable player quest commands so th - [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] When disabled, `/quests`, `list`, `complete`, `cancel`, and `claim` reject execution with a clear message directing the player to a quest board; nearby creation follows [US-012](us-012-create-quests-near-a-board.md). - [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. @@ -25,3 +25,4 @@ As an **administrator**, I want to enable or disable player quest commands so th - [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md) - [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md) - [US-007: Expire quests and claim held items](us-007-expire-quests-and-claim-held-items.md) +- [US-012: Create quests by command near a board](us-012-create-quests-near-a-board.md) diff --git a/knowledge/user-stories/us-012-create-quests-near-a-board.md b/knowledge/user-stories/us-012-create-quests-near-a-board.md new file mode 100644 index 0000000..88acb8b --- /dev/null +++ b/knowledge/user-stories/us-012-create-quests-near-a-board.md @@ -0,0 +1,29 @@ +--- +type: User Story +title: "US-012: Create quests by command near a board" +description: Allow command-based quest creation and material autocomplete near a physical board even when global player commands are disabled. +status: done +--- + +# US-012: Create quests by command near a board + +As a **player**, I want to use the quest-creation command near a quest board so that I can use material autocomplete while still interacting at the physical board. + +## Acceptance criteria + +- [x] When global player quest commands are disabled, `/quests create ` remains available to a player within five blocks of a registered board interaction location. +- [x] Material and quantity autocomplete remain available for nearby quest creation. +- [x] A player farther than five blocks from every registered board is directed to move closer and no quest or escrow change occurs. +- [x] Board proximity requires the player and registered location to be in the same world. +- [x] A distance of exactly five blocks is accepted and a greater distance is rejected. +- [x] Root listing, `list`, `complete`, `cancel`, and `claim` remain disabled while global player commands are disabled. +- [x] When global player quest commands are enabled, all command behavior, including creation, remains available regardless of board proximity. +- [x] Custom single-block boards and every registered interaction location on generated boards satisfy the proximity requirement. +- [x] Board-dialog creation remains available regardless of command settings or command proximity rules. +- [x] Automated tests verify boundaries, cross-world behavior, autocomplete, command settings, disabled subcommands, and generated-board proximity. + +## Related + +- [US-002: Create and use shared quest boards](us-002-create-and-use-shared-quest-boards.md) +- [US-003: Create a block-delivery quest](us-003-create-a-block-delivery-quest.md) +- [US-008: Control player quest commands](us-008-control-player-quest-commands.md) diff --git a/src/main/java/games/dmg/spigotquestboard/BoardProximity.java b/src/main/java/games/dmg/spigotquestboard/BoardProximity.java new file mode 100644 index 0000000..615b3b9 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/BoardProximity.java @@ -0,0 +1,8 @@ +package games.dmg.spigotquestboard; + +import java.util.UUID; + +@FunctionalInterface +interface BoardProximity { + boolean isWithin(UUID worldId, double x, double y, double z, double maximumDistance); +} diff --git a/src/main/java/games/dmg/spigotquestboard/BoardRegistry.java b/src/main/java/games/dmg/spigotquestboard/BoardRegistry.java index 1c7a0fb..bc0f9c7 100644 --- a/src/main/java/games/dmg/spigotquestboard/BoardRegistry.java +++ b/src/main/java/games/dmg/spigotquestboard/BoardRegistry.java @@ -7,7 +7,7 @@ import java.util.Map; import java.util.Objects; import java.util.Set; -final class BoardRegistry { +final class BoardRegistry implements BoardProximity { private final BoardRepository repository; private Map boards; @@ -46,6 +46,30 @@ final class BoardRegistry { return boards.size(); } + @Override + public synchronized boolean isWithin( + java.util.UUID worldId, + double x, + double y, + double z, + double maximumDistance + ) { + Objects.requireNonNull(worldId, "worldId"); + if (maximumDistance < 0.0) { + return false; + } + double maximumDistanceSquared = maximumDistance * maximumDistance; + return boards.keySet().stream() + .filter(id -> id.worldId().equals(worldId)) + .anyMatch(id -> { + double deltaX = x - id.x(); + double deltaY = y - id.y(); + double deltaZ = z - id.z(); + return deltaX * deltaX + deltaY * deltaY + deltaZ * deltaZ + <= maximumDistanceSquared; + }); + } + synchronized Map registeredBoards() { return Map.copyOf(boards); } diff --git a/src/main/java/games/dmg/spigotquestboard/QuestCommand.java b/src/main/java/games/dmg/spigotquestboard/QuestCommand.java index 0a832e1..0996bbf 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestCommand.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestCommand.java @@ -14,6 +14,7 @@ import org.bukkit.entity.Player; final class QuestCommand implements CommandExecutor, TabCompleter { private static final List QUANTITIES = List.of("1", "16", "32", "64"); + private static final double COMMAND_CREATION_DISTANCE = 5.0; private final QuestCreationGateway creator; private final QuestBrowser browser; private final QuestCompletionGateway completer; @@ -21,6 +22,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter { private final QuestClaimGateway claimant; private final Clock clock; private final PlayerCommandSettings playerCommands; + private final BoardProximity boardProximity; QuestCommand(QuestCreationGateway creator, QuestBrowser browser, Clock clock) { this(creator, browser, null, null, null, clock); @@ -53,7 +55,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter { QuestClaimGateway claimant, Clock clock ) { - this(creator, browser, completer, canceller, claimant, clock, null); + this(creator, browser, completer, canceller, claimant, clock, null, null); } QuestCommand( @@ -64,6 +66,19 @@ final class QuestCommand implements CommandExecutor, TabCompleter { QuestClaimGateway claimant, Clock clock, PlayerCommandSettings playerCommands + ) { + this(creator, browser, completer, canceller, claimant, clock, playerCommands, null); + } + + QuestCommand( + QuestCreationGateway creator, + QuestBrowser browser, + QuestCompletionGateway completer, + QuestCancellationGateway canceller, + QuestClaimGateway claimant, + Clock clock, + PlayerCommandSettings playerCommands, + BoardProximity boardProximity ) { this.creator = Objects.requireNonNull(creator, "creator"); this.browser = Objects.requireNonNull(browser, "browser"); @@ -72,6 +87,7 @@ final class QuestCommand implements CommandExecutor, TabCompleter { this.claimant = claimant; this.clock = Objects.requireNonNull(clock, "clock"); this.playerCommands = playerCommands; + this.boardProximity = boardProximity; } @Override @@ -79,10 +95,19 @@ final class QuestCommand implements CommandExecutor, TabCompleter { 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 (isCreate(arguments) && sender instanceof Player player) { + if (!isNearBoard(player)) { + sender.sendMessage( + "Move closer to a quest board to create a quest by command." + ); + return true; + } + } else { + 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]))) { @@ -178,9 +203,20 @@ final class QuestCommand implements CommandExecutor, TabCompleter { public List onTabComplete( CommandSender sender, Command command, String alias, String[] arguments ) { - if (!playerCommandsEnabled() || !(sender instanceof Player)) { + if (!(sender instanceof Player player)) { return List.of(); } + if (!playerCommandsEnabled()) { + if (!isNearBoard(player)) { + return List.of(); + } + if (arguments.length == 1) { + return startsWith(List.of("create"), arguments[0]); + } + if (!isCreate(arguments)) { + return List.of(); + } + } if (arguments.length == 1) { return startsWith( List.of("create", "list", "complete", "cancel", "claim"), arguments[0] @@ -196,7 +232,6 @@ final class QuestCommand implements CommandExecutor, TabCompleter { return startsWith(browser.completableQuestIds(clock.instant()), arguments[1]); } if (arguments.length == 2 && "cancel".equalsIgnoreCase(arguments[0])) { - Player player = (Player) sender; return startsWith( browser.cancellableQuestIds(player.getUniqueId(), clock.instant()), arguments[1] ); @@ -208,6 +243,21 @@ final class QuestCommand implements CommandExecutor, TabCompleter { return playerCommands == null || playerCommands.enabled(); } + private boolean isNearBoard(Player player) { + if (boardProximity == null) { + return false; + } + org.bukkit.Location location = player.getLocation(); + return boardProximity.isWithin( + player.getWorld().getUID(), location.getX(), location.getY(), location.getZ(), + COMMAND_CREATION_DISTANCE + ); + } + + private static boolean isCreate(String[] arguments) { + return arguments.length > 0 && "create".equalsIgnoreCase(arguments[0]); + } + 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 5fe84d2..c5059fe 100644 --- a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java +++ b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java @@ -98,7 +98,8 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin { return new CommandHandlers( new QuestAdminCommand(boards, playerCommands), new QuestCommand( - creator, browser, completer, canceller, claimant, clock, playerCommands + creator, browser, completer, canceller, claimant, clock, playerCommands, + boards ) ); } diff --git a/src/test/java/games/dmg/spigotquestboard/BoardRegistryTest.java b/src/test/java/games/dmg/spigotquestboard/BoardRegistryTest.java index 7fa2e23..44be8e7 100644 --- a/src/test/java/games/dmg/spigotquestboard/BoardRegistryTest.java +++ b/src/test/java/games/dmg/spigotquestboard/BoardRegistryTest.java @@ -69,6 +69,24 @@ final class BoardRegistryTest { assertEquals(0, repository.saveCount); } + @Test + void proximityUsesSameWorldAndAcceptsExactFiveBlockBoundary() throws Exception { + RecordingRepository repository = new RecordingRepository(new BoardState(Set.of(BOARD))); + BoardRegistry registry = new BoardRegistry(repository); + + assertTrue(registry.isWithin( + BOARD.id().worldId(), BOARD.id().x() + 3.0, BOARD.id().y() + 4.0, + BOARD.id().z(), 5.0 + )); + assertFalse(registry.isWithin( + BOARD.id().worldId(), BOARD.id().x() + 3.01, BOARD.id().y() + 4.0, + BOARD.id().z(), 5.0 + )); + assertFalse(registry.isWithin( + UUID.randomUUID(), BOARD.id().x(), BOARD.id().y(), BOARD.id().z(), 5.0 + )); + } + @Test void failedPersistenceDoesNotPublishBoard() throws Exception { BoardRepository repository = new BoardRepository() { diff --git a/src/test/java/games/dmg/spigotquestboard/PhysicalBoardCreatorTest.java b/src/test/java/games/dmg/spigotquestboard/PhysicalBoardCreatorTest.java index a122403..bb33286 100644 --- a/src/test/java/games/dmg/spigotquestboard/PhysicalBoardCreatorTest.java +++ b/src/test/java/games/dmg/spigotquestboard/PhysicalBoardCreatorTest.java @@ -41,6 +41,10 @@ final class PhysicalBoardCreatorTest { assertTrue(repository.state.boards().stream().allMatch( board -> board.worldName().equals("survival") )); + assertTrue(PhysicalBoardPlan.create(ANCHOR, BoardFacing.NORTH) + .interactionLocations().stream().allMatch(location -> registry.isWithin( + location.worldId(), location.x(), location.y(), location.z(), 5.0 + ))); } @Test diff --git a/src/test/java/games/dmg/spigotquestboard/PluginCommandWiringTest.java b/src/test/java/games/dmg/spigotquestboard/PluginCommandWiringTest.java index 6638516..7cdea34 100644 --- a/src/test/java/games/dmg/spigotquestboard/PluginCommandWiringTest.java +++ b/src/test/java/games/dmg/spigotquestboard/PluginCommandWiringTest.java @@ -11,6 +11,8 @@ import java.util.List; import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; +import org.bukkit.Location; +import org.bukkit.World; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; @@ -44,6 +46,54 @@ final class PluginCommandWiringTest { assertTrue(worldLookups.get() > 0); } + @Test + void playerCommandUsesBoardRegistryForNearbyCreation() throws Exception { + UUID worldId = UUID.randomUUID(); + BoardId boardId = new BoardId(worldId, 10, 64, 20); + BoardRegistry boards = new BoardRegistry(new BoardRepository() { + @Override public BoardState load() { + return new BoardState(Set.of(new RegisteredBoard(boardId, "survival"))); + } + @Override public void save(BoardState state) { } + }); + PlayerCommandSettings settings = new PlayerCommandSettings( + new PlayerCommandSettingsRepository() { + @Override public boolean loadEnabled() { return false; } + @Override public void saveEnabled(boolean enabled) { } + } + ); + QuestCreationGateway creator = mock(QuestCreationGateway.class); + Quest created = mock(Quest.class); + when(created.id()).thenReturn(UUID.randomUUID()); + when(creator.create( + org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.anyString(), + org.mockito.ArgumentMatchers.anyInt(), + org.mockito.ArgumentMatchers.any() + )).thenReturn(created); + SpigotQuestBoardPlugin.CommandHandlers handlers = + SpigotQuestBoardPlugin.commandHandlers( + boards, settings, creator, mock(QuestBrowser.class), null, null, null, + Clock.systemUTC() + ); + World world = mock(World.class); + when(world.getUID()).thenReturn(worldId); + Player player = mock(Player.class); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(new Location(world, 13, 68, 20)); + + assertTrue(handlers.quests().onCommand( + player, mock(Command.class), "quests", new String[] {"create", "stone", "1"} + )); + + verify(creator).create( + org.mockito.ArgumentMatchers.eq(player), + org.mockito.ArgumentMatchers.eq("stone"), + org.mockito.ArgumentMatchers.eq(1), + org.mockito.ArgumentMatchers.any() + ); + } + @Test void adminAndPlayerCommandsShareThePersistedSetting() throws Exception { PlayerCommandSettings settings = new PlayerCommandSettings( diff --git a/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java b/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java index 6017757..1ac6a51 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestCommandTest.java @@ -14,15 +14,19 @@ import java.time.Instant; import java.time.ZoneOffset; import java.util.List; import java.util.UUID; +import org.bukkit.Location; +import org.bukkit.World; import org.bukkit.command.Command; import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; final class QuestCommandTest { private static final Instant NOW = Instant.parse("2026-09-05T03:00:00Z"); + private static final UUID WORLD_ID = + UUID.fromString("00000000-0000-0000-0000-000000000099"); @Test - void disabledSettingGatesEveryPlayerCommandFormAndAutocomplete() throws Exception { + void disabledSettingGatesNonCreationCommandForms() throws Exception { QuestCreationGateway creator = mock(QuestCreationGateway.class); QuestBrowser browser = mock(QuestBrowser.class); QuestCompletionGateway completer = mock(QuestCompletionGateway.class); @@ -43,7 +47,6 @@ final class QuestCommandTest { 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"} @@ -62,6 +65,97 @@ final class QuestCommandTest { verifyNoInteractions(creator, browser, completer, canceller, claimant); } + @Test + void disabledSettingAllowsNearbyCreationAndOnlyCreationAutocomplete() throws Exception { + RecordingCreator creator = new RecordingCreator(); + PlayerCommandSettings settings = settings(false); + BoardProximity proximity = (worldId, x, y, z, maximumDistance) -> { + assertEquals(WORLD_ID, worldId); + assertEquals(4.0, x); + assertEquals(3.0, y); + assertEquals(0.0, z); + assertEquals(5.0, maximumDistance); + return true; + }; + QuestCommand executor = new QuestCommand( + creator, now -> List.of(), null, null, null, + Clock.fixed(NOW, ZoneOffset.UTC), settings, proximity + ); + Player player = playerAt(4.0, 3.0, 0.0); + Command command = mock(Command.class); + + assertTrue(executor.onCommand( + player, command, "quests", new String[] {"create", "stone", "1"} + )); + + assertEquals(1, creator.calls); + assertEquals(List.of("create"), executor.onTabComplete( + player, command, "quests", new String[] {""} + )); + assertTrue(executor.onTabComplete( + player, command, "quests", new String[] {"li"} + ).isEmpty()); + assertEquals(List.of("STONE", "STONE_BRICKS"), executor.onTabComplete( + player, command, "quests", new String[] {"create", "sto"} + )); + assertEquals(List.of("1", "16", "32", "64"), executor.onTabComplete( + player, command, "quests", new String[] {"create", "stone", ""} + )); + assertTrue(executor.onTabComplete( + player, command, "quests", new String[] {"complete", ""} + ).isEmpty()); + } + + @Test + void disabledSettingRejectsAwayCreationBeforeCreatorOrEscrowGateway() throws Exception { + QuestCreationGateway creator = mock(QuestCreationGateway.class); + QuestCommand executor = new QuestCommand( + creator, mock(QuestBrowser.class), null, null, null, + Clock.fixed(NOW, ZoneOffset.UTC), settings(false), + (worldId, x, y, z, maximumDistance) -> false + ); + Player player = playerAt(5.01, 0.0, 0.0); + Command command = mock(Command.class); + + assertTrue(executor.onCommand( + player, command, "quests", new String[] {"create", "stone", "1"} + )); + + verify(player).sendMessage("Move closer to a quest board to create a quest by command."); + verifyNoInteractions(creator); + assertTrue(executor.onTabComplete( + player, command, "quests", new String[] {""} + ).isEmpty()); + assertTrue(executor.onTabComplete( + player, command, "quests", new String[] {"create", "sto"} + ).isEmpty()); + } + + @Test + void enabledSettingPreservesAllCommandsWithoutConsultingProximity() throws Exception { + RecordingCreator creator = new RecordingCreator(); + QuestBrowser browser = now -> List.of(creator.quest(1)); + QuestCommand executor = new QuestCommand( + creator, browser, null, null, null, Clock.fixed(NOW, ZoneOffset.UTC), + settings(true), + (worldId, x, y, z, maximumDistance) -> { + throw new AssertionError("Enabled commands must bypass board proximity"); + } + ); + Player player = mock(Player.class); + Command command = mock(Command.class); + + assertTrue(executor.onCommand( + player, command, "quests", new String[] {"create", "stone", "1"} + )); + assertTrue(executor.onCommand(player, command, "quests", new String[] {"list"})); + assertEquals(List.of("create"), executor.onTabComplete( + player, command, "quests", new String[] {"cr"} + )); + + assertEquals(1, creator.calls); + } + @Test void routesValidatedCreateArgumentsWithCurrentUtcTime() { RecordingCreator creator = new RecordingCreator(); @@ -238,6 +332,23 @@ final class QuestCommandTest { ).isEmpty()); } + private static PlayerCommandSettings settings(boolean enabled) throws Exception { + return new PlayerCommandSettings(new PlayerCommandSettingsRepository() { + @Override public boolean loadEnabled() { return enabled; } + @Override public void saveEnabled(boolean newValue) { } + }); + } + + private static Player playerAt(double x, double y, double z) { + World world = mock(World.class); + when(world.getUID()).thenReturn(WORLD_ID); + Location location = new Location(world, x, y, z); + Player player = mock(Player.class); + when(player.getWorld()).thenReturn(world); + when(player.getLocation()).thenReturn(location); + return player; + } + private static QuestCommand command(RecordingCreator creator) { return command(creator, now -> List.of()); }