From bb537b05565187b201034a721a34c4161805f97b Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 5 Sep 2026 09:00:18 -0400 Subject: [PATCH] feat(board): generate physical oak quest boards --- README.md | 3 +- knowledge/log.md | 8 + .../us-010-generate-a-physical-quest-board.md | 24 +-- .../dmg/spigotquestboard/BoardFacing.java | 54 ++++++ .../dmg/spigotquestboard/BoardRegistry.java | 21 ++- .../BukkitPhysicalBoardWorld.java | 79 +++++++++ .../PhysicalBoardCreationResult.java | 7 + .../PhysicalBoardCreator.java | 93 ++++++++++ .../spigotquestboard/PhysicalBoardPlan.java | 67 ++++++++ .../spigotquestboard/PhysicalBoardWorld.java | 11 ++ .../spigotquestboard/QuestAdminCommand.java | 65 ++++++- src/main/resources/plugin.yml | 2 +- .../spigotquestboard/BoardRegistryTest.java | 34 ++++ .../BukkitPhysicalBoardWorldTest.java | 72 ++++++++ .../PhysicalBoardCreatorTest.java | 161 ++++++++++++++++++ .../PhysicalBoardPlanTest.java | 90 ++++++++++ .../QuestAdminCommandTest.java | 98 ++++++++++- .../YamlBoardRepositoryTest.java | 40 +++++ 18 files changed, 903 insertions(+), 26 deletions(-) create mode 100644 src/main/java/games/dmg/spigotquestboard/BoardFacing.java create mode 100644 src/main/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorld.java create mode 100644 src/main/java/games/dmg/spigotquestboard/PhysicalBoardCreationResult.java create mode 100644 src/main/java/games/dmg/spigotquestboard/PhysicalBoardCreator.java create mode 100644 src/main/java/games/dmg/spigotquestboard/PhysicalBoardPlan.java create mode 100644 src/main/java/games/dmg/spigotquestboard/PhysicalBoardWorld.java create mode 100644 src/test/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorldTest.java create mode 100644 src/test/java/games/dmg/spigotquestboard/PhysicalBoardCreatorTest.java create mode 100644 src/test/java/games/dmg/spigotquestboard/PhysicalBoardPlanTest.java diff --git a/README.md b/README.md index 142a8de..45c0b59 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The approved behavior is specified in the [OKF knowledge bundle](knowledge/index ## Status -Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. Right-clicking any registered board opens a compact native dashboard with dedicated browsing, creation, and claim screens. A player can request a block and quantity while escrowing the exact reward stack held in their main hand. Every board and `/quests list` show the same active quests with requested blocks, rewards, issuers, and time remaining. Players can complete quests at any board or with `/quests complete ` by delivering the required blocks. Exact escrowed rewards are granted immediately, and delivered blocks are held for the issuer. Issuers can cancel their own active quests at any board or with `/quests cancel `. Completed deliveries and rewards from cancelled or seven-day-expired quests are held durably and can be collected at any board or with `/quests claim`; inventory overflow drops at the claimant's feet. +Administrators can register persistent shared quest boards by targeting a block within five blocks and running `/questadmin createboard`. They can instead run `/questadmin createboard physical` to generate a five-wide oak board above the targeted ground anchor and register its visible panel blocks and signs. Right-clicking any registered board opens a compact native dashboard with dedicated browsing, creation, and claim screens. A player can request a block and quantity while escrowing the exact reward stack held in their main hand. Every board and `/quests list` show the same active quests with requested blocks, rewards, issuers, and time remaining. Players can complete quests at any board or with `/quests complete ` by delivering the required blocks. Exact escrowed rewards are granted immediately, and delivered blocks are held for the issuer. Issuers can cancel their own active quests at any board or with `/quests cancel `. Completed deliveries and rewards from cancelled or seven-day-expired quests are held durably and can be collected at any board or with `/quests claim`; inventory overflow drops at the claimant's feet. ## Requirements @@ -40,6 +40,7 @@ The `spigotquestboard.admin` permission is granted to server operators by defaul ```text /questadmin createboard +/questadmin createboard physical /questadmin commands ``` diff --git a/knowledge/log.md b/knowledge/log.md index 6fdc6ff..9131738 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -83,3 +83,11 @@ description: Chronological record of material decisions affecting Spigot Quest B - Split browsing, creation, and claims into dedicated native dialog screens with Back navigation. - Presented active quests as individual detail dialogs while retaining completion and issuer cancellation actions. - Verified 88 tests, including a 250-quest navigation case, and the plugin JAR with `./gradlew clean check jar`. + +## 2026-09-05 — Generated physical quest boards + +- Added `/questadmin createboard physical` to generate a five-wide, four-tall board above the targeted ground anchor facing the administrator. +- Used oak-log pillars, an oak-plank face, and three oak wall signs with obfuscated decorative text. +- Registered all visible planks and signs in one durable operation and added obstruction checks plus block rollback when construction or persistence fails. +- Preserved existing single-block board creation and persisted board compatibility. +- Verified 103 tests and the plugin JAR with `./gradlew clean check jar`. diff --git a/knowledge/user-stories/us-010-generate-a-physical-quest-board.md b/knowledge/user-stories/us-010-generate-a-physical-quest-board.md index 1ebbd0b..5df4738 100644 --- a/knowledge/user-stories/us-010-generate-a-physical-quest-board.md +++ b/knowledge/user-stories/us-010-generate-a-physical-quest-board.md @@ -2,7 +2,7 @@ type: User Story title: "US-010: Generate a physical quest-board structure" description: Let an administrator generate and register a decorative oak quest board at a targeted ground anchor. -status: backlog +status: done --- # US-010: Generate a physical quest-board structure @@ -11,17 +11,17 @@ As an **administrator**, I want quest-board creation to optionally construct a r ## Acceptance criteria -- [ ] `/questadmin createboard physical` generates and registers a physical quest board while the existing `/questadmin createboard` behavior remains unchanged. -- [ ] The targeted block is treated as the ground anchor and remains unchanged. -- [ ] The generated board faces the administrator and is five blocks wide and four blocks tall. -- [ ] The outer columns use oak-log pillars and the center uses oak planks. -- [ ] Oak wall signs on the front display decorative obfuscated or gibberish text. -- [ ] Clicking generated visible planks or signs opens the same globally shared quest-board interface. -- [ ] Generation requires empty space and refuses to overwrite an existing structure. -- [ ] Structure generation and interaction-location registration form one failure-safe transaction; failure restores changed blocks and registers nothing. -- [ ] Generated interaction locations persist across restarts and remain compatible with existing registered boards. -- [ ] The `physical` argument is offered through permission-aware command autocomplete. -- [ ] Automated tests verify geometry, facing direction, obstruction handling, rollback, registration, persistence, and autocomplete. +- [x] `/questadmin createboard physical` generates and registers a physical quest board while the existing `/questadmin createboard` behavior remains unchanged. +- [x] The targeted block is treated as the ground anchor and remains unchanged. +- [x] The generated board faces the administrator and is five blocks wide and four blocks tall. +- [x] The outer columns use oak-log pillars and the center uses oak planks. +- [x] Oak wall signs on the front display decorative obfuscated or gibberish text. +- [x] Clicking generated visible planks or signs opens the same globally shared quest-board interface. +- [x] Generation requires empty space and refuses to overwrite an existing structure. +- [x] Structure generation and interaction-location registration form one failure-safe transaction; failure restores changed blocks and registers nothing. +- [x] Generated interaction locations persist across restarts and remain compatible with existing registered boards. +- [x] The `physical` argument is offered through permission-aware command autocomplete. +- [x] Automated tests verify geometry, facing direction, obstruction handling, rollback, registration, persistence, and autocomplete. ## Related diff --git a/src/main/java/games/dmg/spigotquestboard/BoardFacing.java b/src/main/java/games/dmg/spigotquestboard/BoardFacing.java new file mode 100644 index 0000000..15b82fa --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/BoardFacing.java @@ -0,0 +1,54 @@ +package games.dmg.spigotquestboard; + +import org.bukkit.block.BlockFace; + +/** The direction from the board toward its viewer. */ +enum BoardFacing { + NORTH(0, -1), + EAST(1, 0), + SOUTH(0, 1), + WEST(-1, 0); + + private final int x; + private final int z; + + BoardFacing(int x, int z) { + this.x = x; + this.z = z; + } + + int x() { + return x; + } + + int z() { + return z; + } + + int rightX() { + return -z; + } + + int rightZ() { + return x; + } + + BlockFace blockFace() { + return switch (this) { + case NORTH -> BlockFace.NORTH; + case EAST -> BlockFace.EAST; + case SOUTH -> BlockFace.SOUTH; + case WEST -> BlockFace.WEST; + }; + } + + static BoardFacing towardPlayer(BlockFace playerFacing) { + return switch (playerFacing) { + case NORTH -> SOUTH; + case EAST -> WEST; + case SOUTH -> NORTH; + case WEST -> EAST; + default -> throw new IllegalArgumentException("Player must face a cardinal direction"); + }; + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/BoardRegistry.java b/src/main/java/games/dmg/spigotquestboard/BoardRegistry.java index c607907..dd8735a 100644 --- a/src/main/java/games/dmg/spigotquestboard/BoardRegistry.java +++ b/src/main/java/games/dmg/spigotquestboard/BoardRegistry.java @@ -1,6 +1,7 @@ package games.dmg.spigotquestboard; import java.io.IOException; +import java.util.Collection; import java.util.LinkedHashMap; import java.util.Map; import java.util.Objects; @@ -16,12 +17,22 @@ final class BoardRegistry { } synchronized BoardRegistrationResult register(RegisteredBoard board) throws IOException { - Objects.requireNonNull(board, "board"); - if (boards.containsKey(board.id())) { - return BoardRegistrationResult.ALREADY_REGISTERED; - } + return registerAll(Set.of(Objects.requireNonNull(board, "board"))); + } + + synchronized BoardRegistrationResult registerAll(Collection additions) + throws IOException { + Objects.requireNonNull(additions, "additions"); Map candidate = new LinkedHashMap<>(boards); - candidate.put(board.id(), board); + for (RegisteredBoard board : additions) { + Objects.requireNonNull(board, "board"); + if (candidate.putIfAbsent(board.id(), board) != null) { + return BoardRegistrationResult.ALREADY_REGISTERED; + } + } + if (additions.isEmpty()) { + return BoardRegistrationResult.CREATED; + } repository.save(new BoardState(Set.copyOf(candidate.values()))); boards = Map.copyOf(candidate); return BoardRegistrationResult.CREATED; diff --git a/src/main/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorld.java b/src/main/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorld.java new file mode 100644 index 0000000..16a7c77 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorld.java @@ -0,0 +1,79 @@ +package games.dmg.spigotquestboard; + +import java.util.Objects; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.block.Sign; +import org.bukkit.block.data.BlockData; +import org.bukkit.block.data.type.WallSign; +import org.bukkit.block.sign.Side; + +final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld { + private static final Component DECORATIVE_TEXT = Component.text("xxxxxxxx") + .color(NamedTextColor.DARK_GREEN) + .decorate(TextDecoration.OBFUSCATED); + private final World world; + + BukkitPhysicalBoardWorld(World world) { + this.world = Objects.requireNonNull(world, "world"); + } + + @Override + public boolean isEmpty(BoardId location) { + return block(location).getType().isAir(); + } + + @Override + public Object snapshot(BoardId location) { + return block(location).getState(); + } + + @Override + public void place(PhysicalBoardPlan.Placement placement, BoardFacing facing) { + Block block = block(placement.location()); + Material material = Objects.requireNonNull( + Material.matchMaterial(placement.material()), + "Unknown physical board material " + placement.material() + ); + block.setType(material, false); + if (!placement.sign()) { + return; + } + + BlockData data = block.getBlockData(); + if (!(data instanceof WallSign wallSign)) { + throw new IllegalStateException("Oak wall sign did not create wall-sign data"); + } + wallSign.setFacing(facing.blockFace()); + block.setBlockData(wallSign, false); + BlockState state = block.getState(); + if (!(state instanceof Sign sign)) { + throw new IllegalStateException("Oak wall sign did not create sign state"); + } + for (int line = 0; line < 4; line++) { + sign.getSide(Side.FRONT).line(line, DECORATIVE_TEXT); + } + if (!sign.update(true, false)) { + throw new IllegalStateException("Could not configure physical quest-board sign"); + } + } + + @Override + public void restore(BoardId location, Object snapshot) { + if (!(snapshot instanceof BlockState state) || !state.update(true, false)) { + throw new IllegalStateException("Could not restore block at " + location); + } + } + + private Block block(BoardId location) { + if (!world.getUID().equals(location.worldId())) { + throw new IllegalArgumentException("Physical board location belongs to another world"); + } + return world.getBlockAt(location.x(), location.y(), location.z()); + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/PhysicalBoardCreationResult.java b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardCreationResult.java new file mode 100644 index 0000000..f439a24 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardCreationResult.java @@ -0,0 +1,7 @@ +package games.dmg.spigotquestboard; + +enum PhysicalBoardCreationResult { + CREATED, + OBSTRUCTED, + ALREADY_REGISTERED +} diff --git a/src/main/java/games/dmg/spigotquestboard/PhysicalBoardCreator.java b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardCreator.java new file mode 100644 index 0000000..e388587 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardCreator.java @@ -0,0 +1,93 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import org.bukkit.block.Block; + +final class PhysicalBoardCreator { + interface WorldFactory { + PhysicalBoardWorld open(Block anchor); + } + + private final BoardRegistry registry; + private final WorldFactory worlds; + + PhysicalBoardCreator(BoardRegistry registry, WorldFactory worlds) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.worlds = Objects.requireNonNull(worlds, "worlds"); + } + + PhysicalBoardCreationResult create(Block anchorBlock, BoardFacing facing) throws IOException { + Objects.requireNonNull(anchorBlock, "anchorBlock"); + BoardId anchor = BoardId.from(anchorBlock); + String worldName = anchorBlock.getWorld().getName(); + PhysicalBoardPlan plan = PhysicalBoardPlan.create(anchor, facing); + PhysicalBoardWorld world = worlds.open(anchorBlock); + + for (PhysicalBoardPlan.Placement placement : plan.placements()) { + if (!world.isEmpty(placement.location())) { + return PhysicalBoardCreationResult.OBSTRUCTED; + } + } + for (BoardId location : plan.interactionLocations()) { + if (registry.contains(location)) { + return PhysicalBoardCreationResult.ALREADY_REGISTERED; + } + } + + Map snapshots = new LinkedHashMap<>(); + try { + for (PhysicalBoardPlan.Placement placement : plan.placements()) { + snapshots.put(placement.location(), world.snapshot(placement.location())); + } + for (PhysicalBoardPlan.Placement placement : plan.placements()) { + world.place(placement, facing); + } + List registrations = plan.interactionLocations().stream() + .map(location -> new RegisteredBoard(location, worldName)) + .toList(); + BoardRegistrationResult result = registry.registerAll(registrations); + if (result != BoardRegistrationResult.CREATED) { + rollback(world, snapshots, null); + return PhysicalBoardCreationResult.ALREADY_REGISTERED; + } + return PhysicalBoardCreationResult.CREATED; + } catch (RuntimeException | IOException exception) { + rollback(world, snapshots, exception); + if (exception instanceof IOException ioException) { + throw ioException; + } + throw new IOException("Could not generate physical quest board", exception); + } + } + + private static void rollback( + PhysicalBoardWorld world, Map snapshots, Throwable failure + ) throws IOException { + RuntimeException rollbackFailure = null; + List> entries = new ArrayList<>(snapshots.entrySet()); + for (int index = entries.size() - 1; index >= 0; index--) { + Map.Entry entry = entries.get(index); + try { + world.restore(entry.getKey(), entry.getValue()); + } catch (RuntimeException exception) { + if (rollbackFailure == null) { + rollbackFailure = exception; + } else { + rollbackFailure.addSuppressed(exception); + } + } + } + if (rollbackFailure != null) { + if (failure != null) { + failure.addSuppressed(rollbackFailure); + } else { + throw new IOException("Could not fully restore physical quest board", rollbackFailure); + } + } + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/PhysicalBoardPlan.java b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardPlan.java new file mode 100644 index 0000000..2bf4714 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardPlan.java @@ -0,0 +1,67 @@ +package games.dmg.spigotquestboard; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; + +record PhysicalBoardPlan( + BoardFacing facing, + List placements, + Set interactionLocations +) { + PhysicalBoardPlan { + Objects.requireNonNull(facing, "facing"); + placements = List.copyOf(Objects.requireNonNull(placements, "placements")); + interactionLocations = Set.copyOf(Objects.requireNonNull( + interactionLocations, "interactionLocations" + )); + } + + static PhysicalBoardPlan create(BoardId anchor, BoardFacing facing) { + Objects.requireNonNull(anchor, "anchor"); + Objects.requireNonNull(facing, "facing"); + List placements = new ArrayList<>(23); + Set interactions = new LinkedHashSet<>(15); + + for (int height = 1; height <= 4; height++) { + for (int lateral = -2; lateral <= 2; lateral++) { + BoardId location = offset(anchor, facing, lateral, height, 0); + String material = Math.abs(lateral) == 2 ? "OAK_LOG" : "OAK_PLANKS"; + placements.add(new Placement(location, material)); + if ("OAK_PLANKS".equals(material)) { + interactions.add(location); + } + } + } + for (int lateral = -1; lateral <= 1; lateral++) { + BoardId location = offset(anchor, facing, lateral, 2, 1); + placements.add(new Placement(location, "OAK_WALL_SIGN")); + interactions.add(location); + } + return new PhysicalBoardPlan(facing, placements, interactions); + } + + private static BoardId offset( + BoardId anchor, BoardFacing facing, int lateral, int vertical, int forward + ) { + return new BoardId( + anchor.worldId(), + anchor.x() + lateral * facing.rightX() + forward * facing.x(), + anchor.y() + vertical, + anchor.z() + lateral * facing.rightZ() + forward * facing.z() + ); + } + + record Placement(BoardId location, String material) { + Placement { + Objects.requireNonNull(location, "location"); + Objects.requireNonNull(material, "material"); + } + + boolean sign() { + return "OAK_WALL_SIGN".equals(material); + } + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/PhysicalBoardWorld.java b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardWorld.java new file mode 100644 index 0000000..55a5fc4 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardWorld.java @@ -0,0 +1,11 @@ +package games.dmg.spigotquestboard; + +interface PhysicalBoardWorld { + boolean isEmpty(BoardId location); + + Object snapshot(BoardId location); + + void place(PhysicalBoardPlan.Placement placement, BoardFacing facing); + + void restore(BoardId location, Object snapshot); +} diff --git a/src/main/java/games/dmg/spigotquestboard/QuestAdminCommand.java b/src/main/java/games/dmg/spigotquestboard/QuestAdminCommand.java index cffb199..f47ef9f 100644 --- a/src/main/java/games/dmg/spigotquestboard/QuestAdminCommand.java +++ b/src/main/java/games/dmg/spigotquestboard/QuestAdminCommand.java @@ -15,10 +15,26 @@ final class QuestAdminCommand implements CommandExecutor, TabCompleter { private static final String PERMISSION = "spigotquestboard.admin"; private final BoardRegistry registry; private final PlayerCommandSettings playerCommands; + private final PhysicalBoardCreator physicalBoards; QuestAdminCommand(BoardRegistry registry, PlayerCommandSettings playerCommands) { + this( + registry, + playerCommands, + new PhysicalBoardCreator( + registry, anchor -> new BukkitPhysicalBoardWorld(anchor.getWorld()) + ) + ); + } + + QuestAdminCommand( + BoardRegistry registry, + PlayerCommandSettings playerCommands, + PhysicalBoardCreator physicalBoards + ) { this.registry = Objects.requireNonNull(registry, "registry"); this.playerCommands = Objects.requireNonNull(playerCommands, "playerCommands"); + this.physicalBoards = Objects.requireNonNull(physicalBoards, "physicalBoards"); } @Override @@ -32,13 +48,19 @@ final class QuestAdminCommand implements CommandExecutor, TabCompleter { if (arguments.length == 2 && "commands".equalsIgnoreCase(arguments[0])) { return updatePlayerCommands(sender, arguments[1]); } - if (arguments.length != 1 || !"createboard".equalsIgnoreCase(arguments[0])) { - sender.sendMessage( - "Usage: /questadmin createboard | /questadmin commands enable|disable" - ); + if (arguments.length == 1 && "createboard".equalsIgnoreCase(arguments[0])) { + createBoard(sender); return true; } - createBoard(sender); + if (arguments.length == 2 + && "createboard".equalsIgnoreCase(arguments[0]) + && "physical".equalsIgnoreCase(arguments[1])) { + createPhysicalBoard(sender); + return true; + } + sender.sendMessage( + "Usage: /questadmin createboard [physical] | /questadmin commands enable|disable" + ); return true; } @@ -84,6 +106,36 @@ final class QuestAdminCommand implements CommandExecutor, TabCompleter { } } + private void createPhysicalBoard(CommandSender sender) { + if (!(sender instanceof Player player)) { + sender.sendMessage("A player must target the ground anchor block."); + return; + } + Block target = player.getTargetBlockExact(5); + if (target == null) { + sender.sendMessage("Target a ground anchor within five blocks."); + return; + } + try { + PhysicalBoardCreationResult result = physicalBoards.create( + target, BoardFacing.towardPlayer(player.getFacing()) + ); + switch (result) { + case CREATED -> sender.sendMessage("Physical quest board created."); + case OBSTRUCTED -> sender.sendMessage( + "The physical quest board needs 23 empty placement blocks." + ); + case ALREADY_REGISTERED -> sender.sendMessage( + "A physical quest-board interaction location is already registered." + ); + } + } catch (IOException | IllegalArgumentException exception) { + sender.sendMessage( + "The physical quest board could not be created. All changed blocks were restored." + ); + } + } + @Override public List onTabComplete( CommandSender sender, Command command, String alias, String[] arguments @@ -97,6 +149,9 @@ final class QuestAdminCommand implements CommandExecutor, TabCompleter { if (arguments.length == 2 && "commands".equalsIgnoreCase(arguments[0])) { return startsWith(List.of("enable", "disable"), arguments[1]); } + if (arguments.length == 2 && "createboard".equalsIgnoreCase(arguments[0])) { + return startsWith(List.of("physical"), arguments[1]); + } return List.of(); } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index e78ff1f..c2c881e 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -10,7 +10,7 @@ commands: usage: /quests [list|create|complete|cancel|claim] questadmin: description: Administer Spigot Quest Board. - usage: /questadmin + usage: /questadmin permission: spigotquestboard.admin permissions: spigotquestboard.admin: diff --git a/src/test/java/games/dmg/spigotquestboard/BoardRegistryTest.java b/src/test/java/games/dmg/spigotquestboard/BoardRegistryTest.java index fdb33d1..7fa2e23 100644 --- a/src/test/java/games/dmg/spigotquestboard/BoardRegistryTest.java +++ b/src/test/java/games/dmg/spigotquestboard/BoardRegistryTest.java @@ -26,6 +26,40 @@ final class BoardRegistryTest { assertEquals(Set.of(BOARD), repository.saved.boards()); } + @Test + void registersManyLocationsInOneRepositorySave() throws IOException { + RegisteredBoard second = new RegisteredBoard( + new BoardId(BOARD.id().worldId(), 2, 64, 2), "world" + ); + RecordingRepository repository = new RecordingRepository(BoardState.empty()); + BoardRegistry registry = new BoardRegistry(repository); + + assertEquals( + BoardRegistrationResult.CREATED, + registry.registerAll(Set.of(BOARD, second)) + ); + + assertEquals(1, repository.saveCount); + assertEquals(Set.of(BOARD, second), repository.saved.boards()); + assertTrue(registry.contains(BOARD.id())); + assertTrue(registry.contains(second.id())); + } + + @Test + void duplicateInBatchPublishesAndPersistsNothing() throws IOException { + RegisteredBoard conflict = new RegisteredBoard(BOARD.id(), "renamed-world"); + RecordingRepository repository = new RecordingRepository(new BoardState(Set.of(BOARD))); + BoardRegistry registry = new BoardRegistry(repository); + + assertEquals( + BoardRegistrationResult.ALREADY_REGISTERED, + registry.registerAll(Set.of(conflict)) + ); + + assertEquals(0, repository.saveCount); + assertEquals(1, registry.size()); + } + @Test void duplicateDoesNotWrite() throws IOException { RecordingRepository repository = new RecordingRepository(new BoardState(Set.of(BOARD))); diff --git a/src/test/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorldTest.java b/src/test/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorldTest.java new file mode 100644 index 0000000..eff5864 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorldTest.java @@ -0,0 +1,72 @@ +package games.dmg.spigotquestboard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.UUID; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.TextDecoration; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockState; +import org.bukkit.block.Sign; +import org.bukkit.block.data.type.WallSign; +import org.bukkit.block.sign.Side; +import org.bukkit.block.sign.SignSide; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +final class BukkitPhysicalBoardWorldTest { + @Test + void configuresWallSignFacingAndDecorativeObfuscatedText() { + UUID worldId = UUID.randomUUID(); + BoardId location = new BoardId(worldId, 1, 65, 2); + World world = mock(World.class); + Block block = mock(Block.class); + WallSign wallSign = mock(WallSign.class); + Sign sign = mock(Sign.class); + SignSide signSide = mock(SignSide.class); + when(world.getUID()).thenReturn(worldId); + when(world.getBlockAt(1, 65, 2)).thenReturn(block); + when(block.getBlockData()).thenReturn(wallSign); + when(block.getState()).thenReturn(sign); + when(sign.getSide(Side.FRONT)).thenReturn(signSide); + when(sign.update(true, false)).thenReturn(true); + BukkitPhysicalBoardWorld adapter = new BukkitPhysicalBoardWorld(world); + + adapter.place( + new PhysicalBoardPlan.Placement(location, "OAK_WALL_SIGN"), + BoardFacing.NORTH + ); + + verify(block).setType(Material.OAK_WALL_SIGN, false); + verify(wallSign).setFacing(org.bukkit.block.BlockFace.NORTH); + verify(block).setBlockData(wallSign, false); + ArgumentCaptor text = ArgumentCaptor.forClass(Component.class); + verify(signSide, times(4)).line(any(Integer.class), text.capture()); + assertEquals(4, text.getAllValues().size()); + assertTrue(text.getAllValues().stream().allMatch(component -> + component.decoration(TextDecoration.OBFUSCATED) == TextDecoration.State.TRUE + )); + } + + @Test + void restoresCapturedBukkitBlockState() { + UUID worldId = UUID.randomUUID(); + BoardId location = new BoardId(worldId, 1, 65, 2); + World world = mock(World.class); + BlockState snapshot = mock(BlockState.class); + when(world.getUID()).thenReturn(worldId); + when(snapshot.update(true, false)).thenReturn(true); + + new BukkitPhysicalBoardWorld(world).restore(location, snapshot); + + verify(snapshot).update(true, false); + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/PhysicalBoardCreatorTest.java b/src/test/java/games/dmg/spigotquestboard/PhysicalBoardCreatorTest.java new file mode 100644 index 0000000..6e209c4 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/PhysicalBoardCreatorTest.java @@ -0,0 +1,161 @@ +package games.dmg.spigotquestboard; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.junit.jupiter.api.Test; + +final class PhysicalBoardCreatorTest { + private static final UUID WORLD_ID = UUID.fromString( + "00000000-0000-0000-0000-000000000010" + ); + private static final BoardId ANCHOR = new BoardId(WORLD_ID, 10, 64, 20); + + @Test + void buildsAndAtomicallyRegistersEveryVisiblePanelBlock() throws Exception { + RecordingRepository repository = new RecordingRepository(false); + BoardRegistry registry = new BoardRegistry(repository); + FakeWorld world = new FakeWorld(); + PhysicalBoardCreator creator = creator(registry, world); + + assertEquals( + PhysicalBoardCreationResult.CREATED, + creator.create(anchorBlock(), BoardFacing.NORTH) + ); + + assertEquals(23, world.placed.size()); + assertEquals("AIR", world.materials.getOrDefault(ANCHOR, "AIR")); + assertEquals(15, registry.size()); + assertEquals(15, repository.state.boards().size()); + assertEquals(1, repository.saves); + assertTrue(repository.state.boards().stream().allMatch( + board -> board.worldName().equals("survival") + )); + } + + @Test + void obstructionPreflightChangesAndRegistersNothing() throws Exception { + RecordingRepository repository = new RecordingRepository(false); + BoardRegistry registry = new BoardRegistry(repository); + FakeWorld world = new FakeWorld(); + BoardId obstruction = PhysicalBoardPlan.create(ANCHOR, BoardFacing.EAST) + .placements().get(7).location(); + world.materials.put(obstruction, "STONE"); + + assertEquals( + PhysicalBoardCreationResult.OBSTRUCTED, + creator(registry, world).create(anchorBlock(), BoardFacing.EAST) + ); + + assertTrue(world.placed.isEmpty()); + assertEquals("STONE", world.materials.get(obstruction)); + assertEquals(0, repository.saves); + assertEquals(0, registry.size()); + } + + @Test + void placementFailureRestoresEveryChangedBlockAndRegistersNothing() throws Exception { + RecordingRepository repository = new RecordingRepository(false); + BoardRegistry registry = new BoardRegistry(repository); + FakeWorld world = new FakeWorld(); + world.failPlacement = 11; + + assertThrows(IOException.class, () -> creator(registry, world).create( + anchorBlock(), BoardFacing.SOUTH + )); + + assertTrue(world.materials.values().stream().allMatch("AIR"::equals)); + assertEquals(0, repository.saves); + assertEquals(0, registry.size()); + } + + @Test + void persistenceFailureRestoresWholeStructureAndPublishesNothing() throws Exception { + RecordingRepository repository = new RecordingRepository(true); + BoardRegistry registry = new BoardRegistry(repository); + FakeWorld world = new FakeWorld(); + + assertThrows(IOException.class, () -> creator(registry, world).create( + anchorBlock(), BoardFacing.WEST + )); + + assertTrue(world.materials.values().stream().allMatch("AIR"::equals)); + assertEquals(1, repository.saves); + assertEquals(0, registry.size()); + } + + private static PhysicalBoardCreator creator(BoardRegistry registry, FakeWorld world) { + return new PhysicalBoardCreator(registry, ignored -> world); + } + + private static Block anchorBlock() { + World world = mock(World.class); + when(world.getUID()).thenReturn(WORLD_ID); + when(world.getName()).thenReturn("survival"); + Block block = mock(Block.class); + when(block.getWorld()).thenReturn(world); + when(block.getX()).thenReturn(ANCHOR.x()); + when(block.getY()).thenReturn(ANCHOR.y()); + when(block.getZ()).thenReturn(ANCHOR.z()); + return block; + } + + private static final class FakeWorld implements PhysicalBoardWorld { + private final Map materials = new HashMap<>(); + private final Map placed = new HashMap<>(); + private int placements; + private int failPlacement = -1; + + @Override public boolean isEmpty(BoardId location) { + return "AIR".equals(materials.getOrDefault(location, "AIR")); + } + + @Override public Object snapshot(BoardId location) { + return materials.getOrDefault(location, "AIR"); + } + + @Override public void place(PhysicalBoardPlan.Placement placement, BoardFacing facing) { + if (placements++ == failPlacement) { + throw new IllegalStateException("simulated sign configuration failure"); + } + materials.put(placement.location(), placement.material()); + placed.put(placement.location(), placement.material()); + } + + @Override public void restore(BoardId location, Object snapshot) { + materials.put(location, (String) snapshot); + } + } + + private static final class RecordingRepository implements BoardRepository { + private BoardState state = new BoardState(Set.of()); + private final boolean failSave; + private int saves; + + private RecordingRepository(boolean failSave) { + this.failSave = failSave; + } + + @Override public BoardState load() { + return state; + } + + @Override public void save(BoardState state) throws IOException { + saves++; + if (failSave) { + throw new IOException("disk full"); + } + this.state = state; + } + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/PhysicalBoardPlanTest.java b/src/test/java/games/dmg/spigotquestboard/PhysicalBoardPlanTest.java new file mode 100644 index 0000000..e407a57 --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/PhysicalBoardPlanTest.java @@ -0,0 +1,90 @@ +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.assertTrue; + +import java.util.HashSet; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +final class PhysicalBoardPlanTest { + @Test + void createsFiveWideFourTallOakBoardAboveGroundAnchor() { + BoardId anchor = new BoardId(UUID.randomUUID(), 0, 64, 0); + + PhysicalBoardPlan plan = PhysicalBoardPlan.create(anchor, BoardFacing.NORTH); + + assertEquals(8, plan.placements().stream() + .filter(block -> block.material().equals("OAK_LOG")).count()); + assertEquals(12, plan.placements().stream() + .filter(block -> block.material().equals("OAK_PLANKS")).count()); + assertEquals(3, plan.placements().stream() + .filter(block -> block.material().equals("OAK_WALL_SIGN")).count()); + assertEquals(15, plan.interactionLocations().size()); + assertFalse(plan.placements().stream().anyMatch(block -> block.location().equals(anchor))); + assertEquals(-2, plan.placements().stream().mapToInt(block -> block.location().x()).min().orElseThrow()); + assertEquals(2, plan.placements().stream().mapToInt(block -> block.location().x()).max().orElseThrow()); + assertEquals(65, plan.placements().stream().mapToInt(block -> block.location().y()).min().orElseThrow()); + assertEquals(68, plan.placements().stream().mapToInt(block -> block.location().y()).max().orElseThrow()); + } + + @Test + void rotatesWidthAndFrontMountedSignsForEveryFacing() { + BoardId anchor = new BoardId(UUID.randomUUID(), 10, 64, 20); + + for (BoardFacing facing : BoardFacing.values()) { + PhysicalBoardPlan plan = PhysicalBoardPlan.create(anchor, facing); + assertEquals(23, plan.placements().size()); + assertEquals(23, new HashSet<>(plan.placements().stream() + .map(PhysicalBoardPlan.Placement::location).toList()).size()); + + var backing = plan.placements().stream().filter( + placement -> !placement.sign() + ).toList(); + assertEquals(20, backing.size()); + assertTrue(backing.stream().allMatch(placement -> + forward(anchor, placement.location(), facing) == 0 + && Math.abs(lateral(anchor, placement.location(), facing)) <= 2 + && placement.location().y() >= anchor.y() + 1 + && placement.location().y() <= anchor.y() + 4 + )); + + var signs = plan.placements().stream().filter( + PhysicalBoardPlan.Placement::sign + ).toList(); + assertEquals(3, signs.size()); + assertTrue(signs.stream().allMatch(sign -> + forward(anchor, sign.location(), facing) == 1 + && Math.abs(lateral(anchor, sign.location(), facing)) <= 1 + && sign.location().y() == anchor.y() + 2 + )); + assertEquals(15, plan.interactionLocations().size()); + assertTrue(plan.interactionLocations().containsAll(signs.stream() + .map(PhysicalBoardPlan.Placement::location).toList())); + assertTrue(backing.stream() + .filter(placement -> placement.material().equals("OAK_PLANKS")) + .allMatch(placement -> plan.interactionLocations().contains( + placement.location() + ))); + } + } + + private static int forward(BoardId anchor, BoardId location, BoardFacing facing) { + return (location.x() - anchor.x()) * facing.x() + + (location.z() - anchor.z()) * facing.z(); + } + + private static int lateral(BoardId anchor, BoardId location, BoardFacing facing) { + return (location.x() - anchor.x()) * facing.rightX() + + (location.z() - anchor.z()) * facing.rightZ(); + } + + @Test + void boardFrontFacesOppositeTheAdministratorsLookDirection() { + assertEquals(BoardFacing.SOUTH, BoardFacing.towardPlayer(org.bukkit.block.BlockFace.NORTH)); + assertEquals(BoardFacing.WEST, BoardFacing.towardPlayer(org.bukkit.block.BlockFace.EAST)); + assertEquals(BoardFacing.NORTH, BoardFacing.towardPlayer(org.bukkit.block.BlockFace.SOUTH)); + assertEquals(BoardFacing.EAST, BoardFacing.towardPlayer(org.bukkit.block.BlockFace.WEST)); + } +} diff --git a/src/test/java/games/dmg/spigotquestboard/QuestAdminCommandTest.java b/src/test/java/games/dmg/spigotquestboard/QuestAdminCommandTest.java index b042694..b5d848d 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestAdminCommandTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestAdminCommandTest.java @@ -12,7 +12,9 @@ import java.io.IOException; import java.util.List; import java.util.Set; import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; import org.bukkit.World; +import org.bukkit.block.BlockFace; import org.bukkit.block.Block; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; @@ -126,9 +128,12 @@ final class QuestAdminCommandTest { assertEquals(List.of("disable"), executor.onTabComplete( authorized, command, "questadmin", new String[] {"commands", "di"} )); - assertTrue(executor.onTabComplete( + assertEquals(List.of("physical"), executor.onTabComplete( authorized, command, "questadmin", new String[] {"createboard", ""} - ).isEmpty()); + )); + assertEquals(List.of("physical"), executor.onTabComplete( + authorized, command, "questadmin", new String[] {"createboard", "phy"} + )); assertTrue(executor.onTabComplete( unauthorized, command, "questadmin", new String[] {""} ).isEmpty()); @@ -161,6 +166,95 @@ final class QuestAdminCommandTest { ))); } + @Test + void physicalArgumentBuildsAndRegistersAllInteractionLocations() throws Exception { + MemoryBoardRepository repository = new MemoryBoardRepository(); + BoardRegistry registry = new BoardRegistry(repository); + AtomicInteger placements = new AtomicInteger(); + PhysicalBoardWorld worldAdapter = new PhysicalBoardWorld() { + @Override public boolean isEmpty(BoardId location) { return true; } + @Override public Object snapshot(BoardId location) { return "AIR"; } + @Override public void place( + PhysicalBoardPlan.Placement placement, BoardFacing facing + ) { + assertEquals(BoardFacing.SOUTH, facing); + placements.incrementAndGet(); + } + @Override public void restore(BoardId location, Object snapshot) { } + }; + QuestAdminCommand executor = new QuestAdminCommand( + registry, + new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository()), + new PhysicalBoardCreator(registry, ignored -> worldAdapter) + ); + Player player = mock(Player.class); + Block anchor = mock(Block.class); + World world = mock(World.class); + UUID worldId = UUID.randomUUID(); + when(player.hasPermission("spigotquestboard.admin")).thenReturn(true); + when(player.getTargetBlockExact(5)).thenReturn(anchor); + when(player.getFacing()).thenReturn(BlockFace.NORTH); + when(anchor.getWorld()).thenReturn(world); + when(world.getUID()).thenReturn(worldId); + when(world.getName()).thenReturn("world"); + when(anchor.getX()).thenReturn(3); + when(anchor.getY()).thenReturn(70); + when(anchor.getZ()).thenReturn(4); + + assertTrue(executor.onCommand( + player, mock(Command.class), "questadmin", + new String[] {"createboard", "physical"} + )); + + assertEquals(23, placements.get()); + assertEquals(15, repository.state.boards().size()); + verify(player).sendMessage("Physical quest board created."); + } + + @Test + void physicalBoardObstructionIsReportedWithoutRegistration() throws Exception { + MemoryBoardRepository repository = new MemoryBoardRepository(); + BoardRegistry registry = new BoardRegistry(repository); + PhysicalBoardWorld obstructedWorld = new PhysicalBoardWorld() { + @Override public boolean isEmpty(BoardId location) { return false; } + @Override public Object snapshot(BoardId location) { + throw new AssertionError("obstructed generation must not snapshot"); + } + @Override public void place( + PhysicalBoardPlan.Placement placement, BoardFacing facing + ) { + throw new AssertionError("obstructed generation must not place"); + } + @Override public void restore(BoardId location, Object snapshot) { + throw new AssertionError("obstructed generation must not restore"); + } + }; + QuestAdminCommand executor = new QuestAdminCommand( + registry, + new PlayerCommandSettings(new MemoryPlayerCommandSettingsRepository()), + new PhysicalBoardCreator(registry, ignored -> obstructedWorld) + ); + Player player = mock(Player.class); + Block anchor = mock(Block.class); + World world = mock(World.class); + when(player.hasPermission("spigotquestboard.admin")).thenReturn(true); + when(player.getTargetBlockExact(5)).thenReturn(anchor); + when(player.getFacing()).thenReturn(BlockFace.SOUTH); + when(anchor.getWorld()).thenReturn(world); + when(world.getUID()).thenReturn(UUID.randomUUID()); + when(world.getName()).thenReturn("world"); + + executor.onCommand( + player, mock(Command.class), "questadmin", + new String[] {"createboard", "physical"} + ); + + assertTrue(repository.state.boards().isEmpty()); + verify(player).sendMessage( + "The physical quest board needs 23 empty placement blocks." + ); + } + @Test void consoleAndMissingTargetDoNotChangeState() throws Exception { MemoryBoardRepository repository = new MemoryBoardRepository(); diff --git a/src/test/java/games/dmg/spigotquestboard/YamlBoardRepositoryTest.java b/src/test/java/games/dmg/spigotquestboard/YamlBoardRepositoryTest.java index 2677c21..96fe166 100644 --- a/src/test/java/games/dmg/spigotquestboard/YamlBoardRepositoryTest.java +++ b/src/test/java/games/dmg/spigotquestboard/YamlBoardRepositoryTest.java @@ -3,6 +3,7 @@ package games.dmg.spigotquestboard; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.nio.file.Files; import java.nio.file.Path; import java.util.Set; import java.util.UUID; @@ -21,6 +22,45 @@ final class YamlBoardRepositoryTest { assertTrue(repository.load().boards().isEmpty()); } + @Test + void loadsExistingSingleLocationBoardsFileWithoutMigration() throws Exception { + Path path = temporaryDirectory.resolve("boards.yml"); + Files.writeString(path, """ + boards: + - world-id: 00000000-0000-0000-0000-000000000001 + world-name: survival + x: 4 + y: 70 + z: -8 + """); + + BoardState state = new YamlBoardRepository(path).load(); + + assertEquals(Set.of(new RegisteredBoard( + new BoardId(UUID.fromString("00000000-0000-0000-0000-000000000001"), 4, 70, -8), + "survival" + )), state.boards()); + } + + @Test + void persistsAllGeneratedInteractionLocationsAcrossRegistryRestart() throws Exception { + UUID worldId = UUID.fromString("00000000-0000-0000-0000-000000000003"); + PhysicalBoardPlan plan = PhysicalBoardPlan.create( + new BoardId(worldId, 10, 64, 20), BoardFacing.WEST + ); + Set generated = plan.interactionLocations().stream() + .map(location -> new RegisteredBoard(location, "survival")) + .collect(java.util.stream.Collectors.toSet()); + Path path = temporaryDirectory.resolve("boards.yml"); + BoardRegistry first = new BoardRegistry(new YamlBoardRepository(path)); + + assertEquals(BoardRegistrationResult.CREATED, first.registerAll(generated)); + BoardRegistry restarted = new BoardRegistry(new YamlBoardRepository(path)); + + assertEquals(15, restarted.size()); + assertTrue(generated.stream().allMatch(board -> restarted.contains(board.id()))); + } + @Test void roundTripsWorldIdentityAndCoordinates() throws Exception { Path path = temporaryDirectory.resolve("boards.yml");