feat(board): add shared physical quest boards
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
enum BoardRegistrationResult {
|
||||
CREATED,
|
||||
ALREADY_REGISTERED
|
||||
}
|
||||
@@ -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<BoardId, RegisteredBoard> 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<BoardId, RegisteredBoard> 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<BoardId, RegisteredBoard> index(BoardState state) throws IOException {
|
||||
Map<BoardId, RegisteredBoard> 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
interface BoardRepository {
|
||||
BoardState load() throws IOException;
|
||||
void save(BoardState state) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
record BoardState(Set<RegisteredBoard> boards) {
|
||||
BoardState {
|
||||
boards = Set.copyOf(Objects.requireNonNull(boards, "boards"));
|
||||
}
|
||||
|
||||
static BoardState empty() {
|
||||
return new BoardState(Set.of());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package games.dmg.spigotquestboard;
|
||||
|
||||
import org.bukkit.entity.Player;
|
||||
|
||||
interface QuestBoardUi {
|
||||
void open(Player player);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RegisteredBoard> 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<Map<String, Object>> 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();
|
||||
}
|
||||
}
|
||||
@@ -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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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++; }
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user