diff --git a/README.md b/README.md index 24d236d..8f35421 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The approved behavior is specified in the [OKF knowledge bundle](knowledge/index ## Status -The project foundation and user stories are established. Quest-board behavior remains in the backlog until its stories are implemented and verified. +Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. Right-clicking any registered board opens the native quest-board dialog. Quest creation, delivery, and claims remain under development. ## Requirements diff --git a/build.gradle.kts b/build.gradle.kts index 1aa9316..b469451 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { testImplementation("org.purpurmc.purpur:purpur-api:26.2.build.2618-stable") testImplementation(platform("org.junit:junit-bom:5.13.4")) testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.mockito:mockito-core:5.18.0") testImplementation("org.yaml:snakeyaml:2.4") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/knowledge/log.md b/knowledge/log.md index 026bfbb..14248d8 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -26,3 +26,10 @@ description: Chronological record of material decisions affecting Spigot Quest B - Published the project to the public `dmg/spigot-quest-board` Gitea repository. - Added strict Java 25/Purpur 26.2 Gradle builds, plugin metadata, a metadata regression test, and CI/release workflows modeled on Spigot Base. - Verified the plugin foundation and JAR with `./gradlew clean check jar`. + +## 2026-09-05 — Shared physical quest boards + +- Added durable world-UUID and block-coordinate board registration through `/questadmin createboard`. +- Registered main-hand interactions open one shared native Purpur dialog while unregistered blocks remain untouched. +- Persistence publishes a board only after an atomic YAML save succeeds and rejects duplicate locations. +- Verified 11 tests and the plugin JAR with `./gradlew clean check jar`. diff --git a/knowledge/user-stories/us-002-create-and-use-shared-quest-boards.md b/knowledge/user-stories/us-002-create-and-use-shared-quest-boards.md index ac0b878..796f1a2 100644 --- a/knowledge/user-stories/us-002-create-and-use-shared-quest-boards.md +++ b/knowledge/user-stories/us-002-create-and-use-shared-quest-boards.md @@ -2,7 +2,7 @@ type: User Story title: "US-002: Create and use shared quest boards" description: Let administrators establish persistent physical boards that expose one shared quest system. -status: backlog +status: done --- # US-002: Create and use shared quest boards @@ -11,15 +11,15 @@ As an **administrator**, I want to turn a targeted block into a quest board so t ## Acceptance criteria -- [ ] An authorized administrator can use `/questadmin createboard` to register the block they are targeting. -- [ ] Creation rejects a missing, invalid, or already registered target without changing state. -- [ ] Registered boards persist across server restarts with their world and block coordinates. -- [ ] Interacting with any registered board opens the quest-board interface. -- [ ] Every registered board exposes the same global quests and claimable items. -- [ ] The interface uses Purpur's supported native dialog API and follows the interaction style of Spigot Base. -- [ ] Ordinary block interaction is not intercepted at unregistered locations. -- [ ] Administrative actions require the `spigotquestboard.admin` permission, granted to server operators by default. -- [ ] Automated tests verify board registration, persistence, shared visibility, authorization, and interaction routing. +- [x] An authorized administrator can use `/questadmin createboard` to register the block they are targeting. +- [x] Creation rejects a missing, invalid, or already registered target without changing state. +- [x] Registered boards persist across server restarts with their world and block coordinates. +- [x] Interacting with any registered board opens the quest-board interface. +- [x] Every registered board exposes the same global quests and claimable items. +- [x] The interface uses Purpur's supported native dialog API and follows the interaction style of Spigot Base. +- [x] Ordinary block interaction is not intercepted at unregistered locations. +- [x] Administrative actions require the `spigotquestboard.admin` permission, granted to server operators by default. +- [x] Automated tests verify board registration, persistence, shared visibility, authorization, and interaction routing. ## Related diff --git a/src/main/java/games/dmg/spigotquestboard/BoardId.java b/src/main/java/games/dmg/spigotquestboard/BoardId.java new file mode 100644 index 0000000..3058fc8 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/BoardId.java @@ -0,0 +1,17 @@ +package games.dmg.spigotquestboard; + +import java.util.Objects; +import java.util.UUID; +import org.bukkit.block.Block; + +record BoardId(UUID worldId, int x, int y, int z) { + BoardId { + Objects.requireNonNull(worldId, "worldId"); + } + + static BoardId from(Block block) { + return new BoardId( + block.getWorld().getUID(), block.getX(), block.getY(), block.getZ() + ); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/BoardRegistrationResult.java b/src/main/java/games/dmg/spigotquestboard/BoardRegistrationResult.java new file mode 100644 index 0000000..1d6aebe --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/BoardRegistrationResult.java @@ -0,0 +1,6 @@ +package games.dmg.spigotquestboard; + +enum BoardRegistrationResult { + CREATED, + ALREADY_REGISTERED +} diff --git a/src/main/java/games/dmg/spigotquestboard/BoardRegistry.java b/src/main/java/games/dmg/spigotquestboard/BoardRegistry.java new file mode 100644 index 0000000..c607907 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/BoardRegistry.java @@ -0,0 +1,47 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +final class BoardRegistry { + private final BoardRepository repository; + private Map boards; + + BoardRegistry(BoardRepository repository) throws IOException { + this.repository = Objects.requireNonNull(repository, "repository"); + boards = index(repository.load()); + } + + synchronized BoardRegistrationResult register(RegisteredBoard board) throws IOException { + Objects.requireNonNull(board, "board"); + if (boards.containsKey(board.id())) { + return BoardRegistrationResult.ALREADY_REGISTERED; + } + Map candidate = new LinkedHashMap<>(boards); + candidate.put(board.id(), board); + repository.save(new BoardState(Set.copyOf(candidate.values()))); + boards = Map.copyOf(candidate); + return BoardRegistrationResult.CREATED; + } + + synchronized boolean contains(BoardId id) { + return boards.containsKey(id); + } + + synchronized int size() { + return boards.size(); + } + + private static Map index(BoardState state) throws IOException { + Map indexed = new LinkedHashMap<>(); + for (RegisteredBoard board : state.boards()) { + if (indexed.put(board.id(), board) != null) { + throw new IOException("Duplicate quest board location: " + board.id()); + } + } + return Map.copyOf(indexed); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/BoardRepository.java b/src/main/java/games/dmg/spigotquestboard/BoardRepository.java new file mode 100644 index 0000000..76605ad --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/BoardRepository.java @@ -0,0 +1,8 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; + +interface BoardRepository { + BoardState load() throws IOException; + void save(BoardState state) throws IOException; +} diff --git a/src/main/java/games/dmg/spigotquestboard/BoardState.java b/src/main/java/games/dmg/spigotquestboard/BoardState.java new file mode 100644 index 0000000..bb461ab --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/BoardState.java @@ -0,0 +1,14 @@ +package games.dmg.spigotquestboard; + +import java.util.Objects; +import java.util.Set; + +record BoardState(Set boards) { + BoardState { + boards = Set.copyOf(Objects.requireNonNull(boards, "boards")); + } + + static BoardState empty() { + return new BoardState(Set.of()); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestAdminCommand.java b/src/main/java/games/dmg/spigotquestboard/QuestAdminCommand.java new file mode 100644 index 0000000..8e054a6 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestAdminCommand.java @@ -0,0 +1,50 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; +import java.util.Objects; +import org.bukkit.block.Block; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +final class QuestAdminCommand implements CommandExecutor { + private static final String PERMISSION = "spigotquestboard.admin"; + private final BoardRegistry registry; + + QuestAdminCommand(BoardRegistry registry) { + this.registry = Objects.requireNonNull(registry, "registry"); + } + + @Override + public boolean onCommand( + CommandSender sender, Command command, String label, String[] arguments + ) { + if (!sender.hasPermission(PERMISSION)) { + sender.sendMessage("You do not have permission to administer quest boards."); + return true; + } + if (arguments.length != 1 || !"createboard".equalsIgnoreCase(arguments[0])) { + sender.sendMessage("Usage: /questadmin createboard"); + return true; + } + if (!(sender instanceof Player player)) { + sender.sendMessage("A player must target the quest board block."); + return true; + } + Block target = player.getTargetBlockExact(5); + if (target == null) { + sender.sendMessage("Target a physical block within five blocks."); + return true; + } + try { + BoardRegistrationResult result = registry.register(RegisteredBoard.from(target)); + sender.sendMessage(result == BoardRegistrationResult.CREATED + ? "Quest board created." + : "That block is already a quest board."); + } catch (IOException exception) { + sender.sendMessage("The quest board could not be saved. No board was created."); + } + return true; + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java new file mode 100644 index 0000000..2030c87 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestBoardDialogUi.java @@ -0,0 +1,28 @@ +package games.dmg.spigotquestboard; + +import io.papermc.paper.dialog.Dialog; +import io.papermc.paper.registry.data.dialog.DialogBase; +import io.papermc.paper.registry.data.dialog.body.DialogBody; +import io.papermc.paper.registry.data.dialog.type.DialogType; +import java.util.List; +import net.kyori.adventure.text.Component; +import org.bukkit.entity.Player; + +final class QuestBoardDialogUi implements QuestBoardUi { + @Override + public void open(Player player) { + DialogBase base = DialogBase.builder(Component.text("Quest Board")) + .externalTitle(Component.text("Quest Board")) + .body(List.of(DialogBody.plainMessage( + Component.text("No active quests or claimable items."), 420 + ))) + .canCloseWithEscape(true) + .pause(false) + .afterAction(DialogBase.DialogAfterAction.CLOSE) + .build(); + Dialog dialog = Dialog.create(factory -> factory.empty() + .base(base) + .type(DialogType.notice())); + player.showDialog(dialog); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestBoardInteractionListener.java b/src/main/java/games/dmg/spigotquestboard/QuestBoardInteractionListener.java new file mode 100644 index 0000000..cdb1d1b --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestBoardInteractionListener.java @@ -0,0 +1,31 @@ +package games.dmg.spigotquestboard; + +import java.util.Objects; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; + +final class QuestBoardInteractionListener implements Listener { + private final BoardRegistry registry; + private final QuestBoardUi ui; + + QuestBoardInteractionListener(BoardRegistry registry, QuestBoardUi ui) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.ui = Objects.requireNonNull(ui, "ui"); + } + + @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) + public void onPlayerInteract(PlayerInteractEvent event) { + if (event.getAction() != Action.RIGHT_CLICK_BLOCK + || event.getHand() != EquipmentSlot.HAND + || event.getClickedBlock() == null + || !registry.contains(BoardId.from(event.getClickedBlock()))) { + return; + } + event.setCancelled(true); + ui.open(event.getPlayer()); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestBoardUi.java b/src/main/java/games/dmg/spigotquestboard/QuestBoardUi.java new file mode 100644 index 0000000..6bbac7d --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/QuestBoardUi.java @@ -0,0 +1,7 @@ +package games.dmg.spigotquestboard; + +import org.bukkit.entity.Player; + +interface QuestBoardUi { + void open(Player player); +} diff --git a/src/main/java/games/dmg/spigotquestboard/RegisteredBoard.java b/src/main/java/games/dmg/spigotquestboard/RegisteredBoard.java new file mode 100644 index 0000000..ea934ac --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/RegisteredBoard.java @@ -0,0 +1,15 @@ +package games.dmg.spigotquestboard; + +import java.util.Objects; +import org.bukkit.block.Block; + +record RegisteredBoard(BoardId id, String worldName) { + RegisteredBoard { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(worldName, "worldName"); + } + + static RegisteredBoard from(Block block) { + return new RegisteredBoard(BoardId.from(block), block.getWorld().getName()); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java index 1174515..e8044a0 100644 --- a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java +++ b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java @@ -1,10 +1,34 @@ package games.dmg.spigotquestboard; +import java.io.IOException; +import java.util.Objects; +import java.util.logging.Level; +import org.bukkit.command.PluginCommand; import org.bukkit.plugin.java.JavaPlugin; public final class SpigotQuestBoardPlugin extends JavaPlugin { @Override public void onEnable() { - getLogger().info("Spigot Quest Board enabled."); + final BoardRegistry boards; + try { + boards = new BoardRegistry(new YamlBoardRepository( + getDataFolder().toPath().resolve("boards.yml") + )); + } catch (IOException exception) { + getLogger().log(Level.SEVERE, "Could not load quest boards", exception); + getServer().getPluginManager().disablePlugin(this); + return; + } + + QuestAdminCommand admin = new QuestAdminCommand(boards); + command("questadmin").setExecutor(admin); + getServer().getPluginManager().registerEvents( + new QuestBoardInteractionListener(boards, new QuestBoardDialogUi()), this + ); + getLogger().info("Spigot Quest Board enabled with " + boards.size() + " boards."); + } + + private PluginCommand command(String name) { + return Objects.requireNonNull(getCommand(name), "Missing command metadata for " + name); } } diff --git a/src/main/java/games/dmg/spigotquestboard/YamlBoardRepository.java b/src/main/java/games/dmg/spigotquestboard/YamlBoardRepository.java new file mode 100644 index 0000000..5cb6339 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/YamlBoardRepository.java @@ -0,0 +1,104 @@ +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.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; + +final class YamlBoardRepository implements BoardRepository { + private final Path path; + + YamlBoardRepository(Path path) { + this.path = path; + } + + @Override + public BoardState load() throws IOException { + if (!Files.exists(path)) { + return BoardState.empty(); + } + YamlConfiguration yaml = new YamlConfiguration(); + try { + yaml.load(path.toFile()); + } catch (InvalidConfigurationException exception) { + throw new IOException("Invalid quest board state", exception); + } + Set boards = new LinkedHashSet<>(); + for (Map entry : yaml.getMapList("boards")) { + try { + UUID worldId = UUID.fromString(requiredString(entry, "world-id")); + String worldName = requiredString(entry, "world-name"); + BoardId id = new BoardId( + worldId, + requiredInteger(entry, "x"), + requiredInteger(entry, "y"), + requiredInteger(entry, "z") + ); + RegisteredBoard board = new RegisteredBoard(id, worldName); + boolean duplicate = boards.stream().anyMatch(existing -> existing.id().equals(id)); + if (duplicate) { + throw new IOException("Duplicate quest board location: " + id); + } + boards.add(board); + } catch (IllegalArgumentException exception) { + throw new IOException("Invalid quest board record", exception); + } + } + return new BoardState(boards); + } + + @Override + public void save(BoardState state) throws IOException { + YamlConfiguration yaml = new YamlConfiguration(); + List> serialized = new ArrayList<>(); + for (RegisteredBoard board : state.boards()) { + serialized.add(Map.of( + "world-id", board.id().worldId().toString(), + "world-name", board.worldName(), + "x", board.id().x(), + "y", board.id().y(), + "z", board.id().z() + )); + } + yaml.set("boards", serialized); + 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); + } + } + + private static String requiredString(Map entry, String key) { + Object value = entry.get(key); + if (!(value instanceof String text) || text.isBlank()) { + throw new IllegalArgumentException("Missing " + key); + } + return text; + } + + private static int requiredInteger(Map entry, String key) { + Object value = entry.get(key); + if (!(value instanceof Number number)) { + throw new IllegalArgumentException("Missing " + key); + } + return number.intValue(); + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/BoardRegistryTest.java b/src/test/java/games/dmg/spigotquestboard/BoardRegistryTest.java new file mode 100644 index 0000000..fdb33d1 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/BoardRegistryTest.java @@ -0,0 +1,67 @@ +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +final class BoardRegistryTest { + private static final RegisteredBoard BOARD = new RegisteredBoard( + new BoardId(UUID.fromString("00000000-0000-0000-0000-000000000001"), 1, 64, 2), + "world" + ); + + @Test + void registersAndPersistsBeforePublishingBoard() throws IOException { + RecordingRepository repository = new RecordingRepository(BoardState.empty()); + BoardRegistry registry = new BoardRegistry(repository); + + assertEquals(BoardRegistrationResult.CREATED, registry.register(BOARD)); + assertTrue(registry.contains(BOARD.id())); + assertEquals(Set.of(BOARD), repository.saved.boards()); + } + + @Test + void duplicateDoesNotWrite() throws IOException { + RecordingRepository repository = new RecordingRepository(new BoardState(Set.of(BOARD))); + BoardRegistry registry = new BoardRegistry(repository); + + assertEquals(BoardRegistrationResult.ALREADY_REGISTERED, registry.register(BOARD)); + assertEquals(0, repository.saveCount); + } + + @Test + void failedPersistenceDoesNotPublishBoard() throws Exception { + BoardRepository repository = new BoardRepository() { + @Override public BoardState load() { return BoardState.empty(); } + @Override public void save(BoardState state) throws IOException { + throw new IOException("disk failed"); + } + }; + BoardRegistry registry = new BoardRegistry(repository); + + assertThrows(IOException.class, () -> registry.register(BOARD)); + assertFalse(registry.contains(BOARD.id())); + } + + private static final class RecordingRepository implements BoardRepository { + private final BoardState initial; + private BoardState saved; + private int saveCount; + + private RecordingRepository(BoardState initial) { + this.initial = initial; + } + + @Override public BoardState load() { return initial; } + @Override public void save(BoardState state) { + saved = state; + saveCount++; + } + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/QuestAdminCommandTest.java b/src/test/java/games/dmg/spigotquestboard/QuestAdminCommandTest.java new file mode 100644 index 0000000..09ea8a4 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/QuestAdminCommandTest.java @@ -0,0 +1,78 @@ +package games.dmg.spigotquestboard; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Set; +import java.util.UUID; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.junit.jupiter.api.Test; + +final class QuestAdminCommandTest { + @Test + void unauthorizedSenderCannotCreateBoard() throws Exception { + MemoryBoardRepository repository = new MemoryBoardRepository(); + QuestAdminCommand executor = new QuestAdminCommand(new BoardRegistry(repository)); + Player player = mock(Player.class); + when(player.hasPermission("spigotquestboard.admin")).thenReturn(false); + + assertTrue(executor.onCommand(player, mock(Command.class), "questadmin", new String[] {"createboard"})); + + verify(player, never()).getTargetBlockExact(5); + assertTrue(repository.state.boards().isEmpty()); + } + + @Test + void registersTargetedPhysicalBlock() throws Exception { + MemoryBoardRepository repository = new MemoryBoardRepository(); + QuestAdminCommand executor = new QuestAdminCommand(new BoardRegistry(repository)); + Player player = mock(Player.class); + Block block = mock(Block.class); + World world = mock(World.class); + UUID worldId = UUID.randomUUID(); + when(player.hasPermission("spigotquestboard.admin")).thenReturn(true); + when(player.getTargetBlockExact(5)).thenReturn(block); + when(block.getWorld()).thenReturn(world); + when(world.getUID()).thenReturn(worldId); + when(world.getName()).thenReturn("world"); + when(block.getX()).thenReturn(10); + when(block.getY()).thenReturn(65); + when(block.getZ()).thenReturn(-4); + + assertTrue(executor.onCommand(player, mock(Command.class), "questadmin", new String[] {"createboard"})); + + assertTrue(repository.state.boards().contains(new RegisteredBoard( + new BoardId(worldId, 10, 65, -4), "world" + ))); + } + + @Test + void consoleAndMissingTargetDoNotChangeState() throws Exception { + MemoryBoardRepository repository = new MemoryBoardRepository(); + QuestAdminCommand executor = new QuestAdminCommand(new BoardRegistry(repository)); + CommandSender console = mock(CommandSender.class); + when(console.hasPermission("spigotquestboard.admin")).thenReturn(true); + assertTrue(executor.onCommand(console, mock(Command.class), "questadmin", new String[] {"createboard"})); + + Player player = mock(Player.class); + when(player.hasPermission("spigotquestboard.admin")).thenReturn(true); + when(player.getTargetBlockExact(5)).thenReturn(null); + assertTrue(executor.onCommand(player, mock(Command.class), "questadmin", new String[] {"createboard"})); + + assertTrue(repository.state.boards().isEmpty()); + } + + private static final class MemoryBoardRepository implements BoardRepository { + private BoardState state = new BoardState(Set.of()); + @Override public BoardState load() { return state; } + @Override public void save(BoardState state) { this.state = state; } + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/QuestBoardInteractionListenerTest.java b/src/test/java/games/dmg/spigotquestboard/QuestBoardInteractionListenerTest.java new file mode 100644 index 0000000..60b67f8 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/QuestBoardInteractionListenerTest.java @@ -0,0 +1,86 @@ +package games.dmg.spigotquestboard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.Set; +import java.util.UUID; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.bukkit.event.block.Action; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.junit.jupiter.api.Test; + +final class QuestBoardInteractionListenerTest { + @Test + void registeredBoardsOpenTheSameGlobalInterface() throws Exception { + UUID worldId = UUID.randomUUID(); + RegisteredBoard first = new RegisteredBoard(new BoardId(worldId, 1, 64, 1), "world"); + RegisteredBoard second = new RegisteredBoard(new BoardId(worldId, 2, 64, 2), "world"); + BoardRepository repository = new FixedRepository(new BoardState(Set.of(first, second))); + BoardRegistry registry = new BoardRegistry(repository); + CountingUi ui = new CountingUi(); + QuestBoardInteractionListener listener = new QuestBoardInteractionListener(registry, ui); + Player player = mock(Player.class); + + PlayerInteractEvent firstEvent = eventFor(first, player); + PlayerInteractEvent secondEvent = eventFor(second, player); + listener.onPlayerInteract(firstEvent); + listener.onPlayerInteract(secondEvent); + + verify(firstEvent).setCancelled(true); + verify(secondEvent).setCancelled(true); + assertEquals(2, ui.opens); + } + + @Test + void unregisteredAndOffHandInteractionsPassThrough() throws Exception { + BoardRegistry registry = new BoardRegistry(new FixedRepository(BoardState.empty())); + CountingUi ui = new CountingUi(); + QuestBoardInteractionListener listener = new QuestBoardInteractionListener(registry, ui); + PlayerInteractEvent event = eventFor( + new RegisteredBoard(new BoardId(UUID.randomUUID(), 1, 64, 1), "world"), + mock(Player.class) + ); + listener.onPlayerInteract(event); + verify(event, never()).setCancelled(true); + + when(event.getHand()).thenReturn(EquipmentSlot.OFF_HAND); + listener.onPlayerInteract(event); + assertEquals(0, ui.opens); + } + + private static PlayerInteractEvent eventFor(RegisteredBoard board, Player player) { + PlayerInteractEvent event = mock(PlayerInteractEvent.class); + Block block = mock(Block.class); + World world = mock(World.class); + when(event.getAction()).thenReturn(Action.RIGHT_CLICK_BLOCK); + when(event.getHand()).thenReturn(EquipmentSlot.HAND); + when(event.getClickedBlock()).thenReturn(block); + when(event.getPlayer()).thenReturn(player); + when(block.getWorld()).thenReturn(world); + when(world.getUID()).thenReturn(board.id().worldId()); + when(world.getName()).thenReturn(board.worldName()); + when(block.getX()).thenReturn(board.id().x()); + when(block.getY()).thenReturn(board.id().y()); + when(block.getZ()).thenReturn(board.id().z()); + return event; + } + + private static final class FixedRepository implements BoardRepository { + private final BoardState state; + private FixedRepository(BoardState state) { this.state = state; } + @Override public BoardState load() { return state; } + @Override public void save(BoardState state) { throw new UnsupportedOperationException(); } + } + + private static final class CountingUi implements QuestBoardUi { + private int opens; + @Override public void open(Player player) { opens++; } + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/YamlBoardRepositoryTest.java b/src/test/java/games/dmg/spigotquestboard/YamlBoardRepositoryTest.java new file mode 100644 index 0000000..2677c21 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/YamlBoardRepositoryTest.java @@ -0,0 +1,43 @@ +package games.dmg.spigotquestboard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.file.Path; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +final class YamlBoardRepositoryTest { + @TempDir Path temporaryDirectory; + + @Test + void missingFileLoadsEmptyState() throws Exception { + YamlBoardRepository repository = new YamlBoardRepository( + temporaryDirectory.resolve("boards.yml") + ); + + assertTrue(repository.load().boards().isEmpty()); + } + + @Test + void roundTripsWorldIdentityAndCoordinates() throws Exception { + Path path = temporaryDirectory.resolve("boards.yml"); + YamlBoardRepository repository = new YamlBoardRepository(path); + BoardState expected = new BoardState(Set.of( + new RegisteredBoard( + new BoardId(UUID.fromString("00000000-0000-0000-0000-000000000001"), 4, 70, -8), + "survival" + ), + new RegisteredBoard( + new BoardId(UUID.fromString("00000000-0000-0000-0000-000000000002"), 4, 70, -8), + "resource" + ) + )); + + repository.save(expected); + + assertEquals(expected, repository.load()); + } +}