From 72e58383a211e6f2974102a9c15565433364e975 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 5 Sep 2026 09:26:45 -0400 Subject: [PATCH] feat(board): add readable physical board signs --- README.md | 2 +- knowledge/log.md | 7 + knowledge/user-stories/index.md | 1 + ...011-add-readable-physical-board-signage.md | 26 +++ .../dmg/spigotquestboard/BoardRegistry.java | 4 + .../BukkitPhysicalBoardWorld.java | 47 +++- .../spigotquestboard/PhysicalBoardPlan.java | 45 +++- .../PhysicalBoardSignKind.java | 7 + .../PhysicalBoardUpgrader.java | 188 ++++++++++++++++ .../spigotquestboard/PhysicalBoardWorld.java | 4 + .../SpigotQuestBoardPlugin.java | 14 +- .../BukkitPhysicalBoardWorldTest.java | 72 ++++++ .../PhysicalBoardCreatorTest.java | 10 +- .../PhysicalBoardPlanTest.java | 21 +- .../PhysicalBoardUpgraderTest.java | 206 ++++++++++++++++++ .../PluginCommandWiringTest.java | 30 +++ .../QuestAdminCommandTest.java | 4 +- .../YamlBoardRepositoryTest.java | 2 +- 18 files changed, 667 insertions(+), 23 deletions(-) create mode 100644 knowledge/user-stories/us-011-add-readable-physical-board-signage.md create mode 100644 src/main/java/games/dmg/spigotquestboard/PhysicalBoardSignKind.java create mode 100644 src/main/java/games/dmg/spigotquestboard/PhysicalBoardUpgrader.java create mode 100644 src/test/java/games/dmg/spigotquestboard/PhysicalBoardUpgraderTest.java diff --git a/README.md b/README.md index 45c0b59..4d28261 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`. 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. +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, with centered title and browsing-instruction signs above three decorative signs; its visible panel blocks and all five signs are registered. 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 diff --git a/knowledge/log.md b/knowledge/log.md index 9131738..f2f97c5 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -91,3 +91,10 @@ description: Chronological record of material decisions affecting Spigot Quest B - 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`. + +## 2026-09-05 — Readable physical-board signage + +- Added centered `Quest Board` and right-click instruction signs on the two layers above each generated board's three decorative signs. +- Registered all five signs and the plank face as shared-board interaction locations. +- Added exact legacy-structure detection and failure-safe startup upgrades for previously generated three-sign boards without touching custom or altered structures. +- Verified 111 tests and the plugin JAR with `./gradlew clean check jar`. diff --git a/knowledge/user-stories/index.md b/knowledge/user-stories/index.md index da87490..8e695b0 100644 --- a/knowledge/user-stories/index.md +++ b/knowledge/user-stories/index.md @@ -16,3 +16,4 @@ description: Catalog of user stories for the Spigot Quest Board plugin. 8. [US-008: Control player quest commands](us-008-control-player-quest-commands.md) 9. [US-009: Use a screen-fitting quest-board interface](us-009-use-a-screen-fitting-quest-board-interface.md) 10. [US-010: Generate a physical quest-board structure](us-010-generate-a-physical-quest-board.md) +11. [US-011: Add readable physical-board signage](us-011-add-readable-physical-board-signage.md) diff --git a/knowledge/user-stories/us-011-add-readable-physical-board-signage.md b/knowledge/user-stories/us-011-add-readable-physical-board-signage.md new file mode 100644 index 0000000..154ce05 --- /dev/null +++ b/knowledge/user-stories/us-011-add-readable-physical-board-signage.md @@ -0,0 +1,26 @@ +--- +type: User Story +title: "US-011: Add readable physical-board signage" +description: Give generated quest boards a readable title and browsing instruction above their decorative signs. +status: done +--- + +# US-011: Add readable physical-board signage + +As a **player**, I want readable signs on a generated quest board so that I know what it is and how to open it. + +## Acceptance criteria + +- [x] A generated physical board has a centered top sign reading `Quest Board` across its sign lines. +- [x] A centered sign immediately below instructs the player to right-click a sign below to browse quests. +- [x] The existing lower row retains three decorative signs with obfuscated or gibberish text. +- [x] All five signs and the visible oak planks open the globally shared quest-board interface. +- [x] New physical boards reserve and validate space for all five signs without overwriting blocks. +- [x] Existing generated boards with the prior three-sign layout are detected from their exact registered oak structure and upgraded with the two centered readable signs. +- [x] Existing custom single-block boards and unrelated structures are not modified. +- [x] Existing-board upgrades never overwrite occupied sign locations and persist new interaction locations failure-safely. +- [x] Automated tests verify sign placement, readable text, decorative text, interaction registration, obstruction handling, and prior-layout migration. + +## Related + +- [US-010: Generate a physical quest-board structure](us-010-generate-a-physical-quest-board.md) diff --git a/src/main/java/games/dmg/spigotquestboard/BoardRegistry.java b/src/main/java/games/dmg/spigotquestboard/BoardRegistry.java index dd8735a..1c7a0fb 100644 --- a/src/main/java/games/dmg/spigotquestboard/BoardRegistry.java +++ b/src/main/java/games/dmg/spigotquestboard/BoardRegistry.java @@ -46,6 +46,10 @@ final class BoardRegistry { return boards.size(); } + synchronized Map registeredBoards() { + return Map.copyOf(boards); + } + private static Map index(BoardState state) throws IOException { Map indexed = new LinkedHashMap<>(); for (RegisteredBoard board : state.boards()) { diff --git a/src/main/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorld.java b/src/main/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorld.java index 16a7c77..6627bf2 100644 --- a/src/main/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorld.java +++ b/src/main/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorld.java @@ -1,5 +1,6 @@ package games.dmg.spigotquestboard; +import java.util.List; import java.util.Objects; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.NamedTextColor; @@ -17,6 +18,13 @@ final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld { private static final Component DECORATIVE_TEXT = Component.text("xxxxxxxx") .color(NamedTextColor.DARK_GREEN) .decorate(TextDecoration.OBFUSCATED); + private static final List TITLE_TEXT = List.of( + Component.text("Quest"), Component.text("Board"), Component.empty(), Component.empty() + ); + private static final List INSTRUCTION_TEXT = List.of( + Component.text("Right-click"), Component.text("a sign below"), + Component.text("to browse"), Component.text("quests") + ); private final World world; BukkitPhysicalBoardWorld(World world) { @@ -55,14 +63,39 @@ final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld { 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); + List lines = signLines(placement.signKind()); + for (int line = 0; line < lines.size(); line++) { + sign.getSide(Side.FRONT).line(line, lines.get(line)); } if (!sign.update(true, false)) { throw new IllegalStateException("Could not configure physical quest-board sign"); } } + @Override + public boolean matches(PhysicalBoardPlan.Placement placement, BoardFacing facing) { + Block block = block(placement.location()); + Material material = Material.matchMaterial(placement.material()); + if (material == null || block.getType() != material) { + return false; + } + if (!placement.sign()) { + return true; + } + if (!(block.getBlockData() instanceof WallSign wallSign) + || wallSign.getFacing() != facing.blockFace() + || !(block.getState() instanceof Sign sign)) { + return false; + } + List expected = signLines(placement.signKind()); + for (int line = 0; line < expected.size(); line++) { + if (!expected.get(line).equals(sign.getSide(Side.FRONT).line(line))) { + return false; + } + } + return true; + } + @Override public void restore(BoardId location, Object snapshot) { if (!(snapshot instanceof BlockState state) || !state.update(true, false)) { @@ -70,6 +103,16 @@ final class BukkitPhysicalBoardWorld implements PhysicalBoardWorld { } } + static List signLines(PhysicalBoardSignKind kind) { + return switch (Objects.requireNonNull(kind, "kind")) { + case TITLE -> TITLE_TEXT; + case INSTRUCTION -> INSTRUCTION_TEXT; + case DECORATIVE -> List.of( + DECORATIVE_TEXT, DECORATIVE_TEXT, DECORATIVE_TEXT, DECORATIVE_TEXT + ); + }; + } + private Block block(BoardId location) { if (!world.getUID().equals(location.worldId())) { throw new IllegalArgumentException("Physical board location belongs to another world"); diff --git a/src/main/java/games/dmg/spigotquestboard/PhysicalBoardPlan.java b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardPlan.java index 2bf4714..258b348 100644 --- a/src/main/java/games/dmg/spigotquestboard/PhysicalBoardPlan.java +++ b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardPlan.java @@ -22,8 +22,8 @@ record PhysicalBoardPlan( 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); + List placements = new ArrayList<>(25); + Set interactions = new LinkedHashSet<>(17); for (int height = 1; height <= 4; height++) { for (int lateral = -2; lateral <= 2; lateral++) { @@ -37,12 +37,32 @@ record PhysicalBoardPlan( } for (int lateral = -1; lateral <= 1; lateral++) { BoardId location = offset(anchor, facing, lateral, 2, 1); - placements.add(new Placement(location, "OAK_WALL_SIGN")); + placements.add(new Placement( + location, "OAK_WALL_SIGN", PhysicalBoardSignKind.DECORATIVE + )); interactions.add(location); } + addSign( + placements, interactions, offset(anchor, facing, 0, 3, 1), + PhysicalBoardSignKind.INSTRUCTION + ); + addSign( + placements, interactions, offset(anchor, facing, 0, 4, 1), + PhysicalBoardSignKind.TITLE + ); return new PhysicalBoardPlan(facing, placements, interactions); } + private static void addSign( + List placements, + Set interactions, + BoardId location, + PhysicalBoardSignKind kind + ) { + placements.add(new Placement(location, "OAK_WALL_SIGN", kind)); + interactions.add(location); + } + private static BoardId offset( BoardId anchor, BoardFacing facing, int lateral, int vertical, int forward ) { @@ -54,14 +74,29 @@ record PhysicalBoardPlan( ); } - record Placement(BoardId location, String material) { + record Placement( + BoardId location, + String material, + PhysicalBoardSignKind signKind + ) { Placement { Objects.requireNonNull(location, "location"); Objects.requireNonNull(material, "material"); + if ("OAK_WALL_SIGN".equals(material) != (signKind != null)) { + throw new IllegalArgumentException("Only wall signs require sign metadata"); + } + } + + Placement(BoardId location, String material) { + this( + location, + material, + "OAK_WALL_SIGN".equals(material) ? PhysicalBoardSignKind.DECORATIVE : null + ); } boolean sign() { - return "OAK_WALL_SIGN".equals(material); + return signKind != null; } } } diff --git a/src/main/java/games/dmg/spigotquestboard/PhysicalBoardSignKind.java b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardSignKind.java new file mode 100644 index 0000000..3d21940 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardSignKind.java @@ -0,0 +1,7 @@ +package games.dmg.spigotquestboard; + +enum PhysicalBoardSignKind { + TITLE, + INSTRUCTION, + DECORATIVE +} diff --git a/src/main/java/games/dmg/spigotquestboard/PhysicalBoardUpgrader.java b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardUpgrader.java new file mode 100644 index 0000000..b701cb9 --- /dev/null +++ b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardUpgrader.java @@ -0,0 +1,188 @@ +package games.dmg.spigotquestboard; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +final class PhysicalBoardUpgrader { + interface WorldFactory { + PhysicalBoardWorld open(UUID worldId); + } + + private record Candidate(BoardId anchor, BoardFacing facing, String worldName) { } + private record RelativeInteraction(int lateral, int height, int forward) { } + + private static final List LEGACY_INTERACTIONS = legacyInteractions(); + + private final BoardRegistry registry; + private final WorldFactory worlds; + + PhysicalBoardUpgrader(BoardRegistry registry, WorldFactory worlds) { + this.registry = Objects.requireNonNull(registry, "registry"); + this.worlds = Objects.requireNonNull(worlds, "worlds"); + } + + int upgrade() throws IOException { + int upgraded = 0; + Map registered = registry.registeredBoards(); + for (Candidate candidate : candidates(registered)) { + if (!hasLegacyRegistrations(candidate, registered)) { + continue; + } + PhysicalBoardWorld world = worlds.open(candidate.anchor().worldId()); + if (world != null && isLegacyBoard(candidate, world) + && upgrade(candidate, world)) { + upgraded++; + registered = registry.registeredBoards(); + } + } + return upgraded; + } + + private static boolean hasLegacyRegistrations( + Candidate candidate, Map registered + ) { + PhysicalBoardPlan plan = PhysicalBoardPlan.create(candidate.anchor(), candidate.facing()); + for (BoardId location : legacyInteractionLocations(plan)) { + RegisteredBoard board = registered.get(location); + if (board == null || !candidate.worldName().equals(board.worldName())) { + return false; + } + } + return newSigns(plan).stream().noneMatch(sign -> + registered.containsKey(sign.location()) + ); + } + + private boolean isLegacyBoard(Candidate candidate, PhysicalBoardWorld world) { + PhysicalBoardPlan plan = PhysicalBoardPlan.create(candidate.anchor(), candidate.facing()); + for (PhysicalBoardPlan.Placement placement : legacyPlacements(plan)) { + if (!world.matches(placement, candidate.facing())) { + return false; + } + } + for (PhysicalBoardPlan.Placement placement : newSigns(plan)) { + if (!world.isEmpty(placement.location())) { + return false; + } + } + return true; + } + + private boolean upgrade(Candidate candidate, PhysicalBoardWorld world) throws IOException { + List signs = newSigns( + PhysicalBoardPlan.create(candidate.anchor(), candidate.facing()) + ); + Map snapshots = new LinkedHashMap<>(); + try { + for (PhysicalBoardPlan.Placement sign : signs) { + snapshots.put(sign.location(), world.snapshot(sign.location())); + } + for (PhysicalBoardPlan.Placement sign : signs) { + world.place(sign, candidate.facing()); + } + List additions = signs.stream() + .map(sign -> new RegisteredBoard(sign.location(), candidate.worldName())) + .toList(); + if (registry.registerAll(additions) != BoardRegistrationResult.CREATED) { + rollback(world, snapshots, null); + return false; + } + return true; + } catch (RuntimeException | IOException exception) { + rollback(world, snapshots, exception); + if (exception instanceof IOException ioException) { + throw ioException; + } + throw new IOException("Could not upgrade physical quest board", exception); + } + } + + private static Set candidates(Map registered) { + Set candidates = new LinkedHashSet<>(); + for (RegisteredBoard board : registered.values()) { + for (BoardFacing facing : BoardFacing.values()) { + for (RelativeInteraction relative : LEGACY_INTERACTIONS) { + BoardId location = board.id(); + candidates.add(new Candidate(new BoardId( + location.worldId(), + location.x() - relative.lateral() * facing.rightX() + - relative.forward() * facing.x(), + location.y() - relative.height(), + location.z() - relative.lateral() * facing.rightZ() + - relative.forward() * facing.z() + ), facing, board.worldName())); + } + } + } + return candidates; + } + + private static List legacyInteractions() { + List interactions = new ArrayList<>(15); + for (int height = 1; height <= 4; height++) { + for (int lateral = -1; lateral <= 1; lateral++) { + interactions.add(new RelativeInteraction(lateral, height, 0)); + } + } + for (int lateral = -1; lateral <= 1; lateral++) { + interactions.add(new RelativeInteraction(lateral, 2, 1)); + } + return List.copyOf(interactions); + } + + private static List legacyPlacements(PhysicalBoardPlan plan) { + return plan.placements().stream().filter(placement -> + !placement.sign() || placement.signKind() == PhysicalBoardSignKind.DECORATIVE + ).toList(); + } + + private static Set legacyInteractionLocations(PhysicalBoardPlan plan) { + Set locations = new LinkedHashSet<>(); + for (PhysicalBoardPlan.Placement placement : legacyPlacements(plan)) { + if ("OAK_PLANKS".equals(placement.material()) || placement.sign()) { + locations.add(placement.location()); + } + } + return Set.copyOf(locations); + } + + private static List newSigns(PhysicalBoardPlan plan) { + return plan.placements().stream().filter(placement -> + placement.signKind() == PhysicalBoardSignKind.TITLE + || placement.signKind() == PhysicalBoardSignKind.INSTRUCTION + ).toList(); + } + + 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 restore physical board upgrade", rollbackFailure); + } + } + } +} diff --git a/src/main/java/games/dmg/spigotquestboard/PhysicalBoardWorld.java b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardWorld.java index 55a5fc4..ccbd694 100644 --- a/src/main/java/games/dmg/spigotquestboard/PhysicalBoardWorld.java +++ b/src/main/java/games/dmg/spigotquestboard/PhysicalBoardWorld.java @@ -7,5 +7,9 @@ interface PhysicalBoardWorld { void place(PhysicalBoardPlan.Placement placement, BoardFacing facing); + default boolean matches(PhysicalBoardPlan.Placement placement, BoardFacing facing) { + return false; + } + void restore(BoardId location, Object snapshot); } diff --git a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java index 2263f38..5fe84d2 100644 --- a/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java +++ b/src/main/java/games/dmg/spigotquestboard/SpigotQuestBoardPlugin.java @@ -13,10 +13,15 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin { final BoardRegistry boards; final QuestService quests; final PlayerCommandSettings playerCommands; + final int upgradedBoards; try { boards = new BoardRegistry(new YamlBoardRepository( getDataFolder().toPath().resolve("boards.yml") )); + upgradedBoards = upgradePhysicalBoards(boards, worldId -> { + org.bukkit.World world = getServer().getWorld(worldId); + return world == null ? null : new BukkitPhysicalBoardWorld(world); + }); quests = new QuestService(new YamlQuestRepository( getDataFolder().toPath().resolve("quests.yml") )); @@ -69,10 +74,17 @@ public final class SpigotQuestBoardPlugin extends JavaPlugin { ); getLogger().info( "Spigot Quest Board enabled with " + boards.size() + " boards and " - + quests.state().quests().size() + " quests." + + quests.state().quests().size() + " quests; upgraded " + upgradedBoards + + " physical boards." ); } + static int upgradePhysicalBoards( + BoardRegistry boards, PhysicalBoardUpgrader.WorldFactory worlds + ) throws IOException { + return new PhysicalBoardUpgrader(boards, worlds).upgrade(); + } + static CommandHandlers commandHandlers( BoardRegistry boards, PlayerCommandSettings playerCommands, diff --git a/src/test/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorldTest.java b/src/test/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorldTest.java index eff5864..163bac8 100644 --- a/src/test/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorldTest.java +++ b/src/test/java/games/dmg/spigotquestboard/BukkitPhysicalBoardWorldTest.java @@ -1,6 +1,7 @@ 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 static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -8,6 +9,7 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import java.util.List; import java.util.UUID; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.format.TextDecoration; @@ -56,6 +58,76 @@ final class BukkitPhysicalBoardWorldTest { )); } + @Test + void matchesOnlyAnExactDecorativeSignForLegacyIdentification() { + 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.getType()).thenReturn(Material.OAK_WALL_SIGN); + when(block.getBlockData()).thenReturn(wallSign); + when(block.getState()).thenReturn(sign); + when(wallSign.getFacing()).thenReturn(org.bukkit.block.BlockFace.NORTH); + when(sign.getSide(Side.FRONT)).thenReturn(signSide); + List decorative = BukkitPhysicalBoardWorld.signLines( + PhysicalBoardSignKind.DECORATIVE + ); + for (int line = 0; line < decorative.size(); line++) { + when(signSide.line(line)).thenReturn(decorative.get(line)); + } + BukkitPhysicalBoardWorld adapter = new BukkitPhysicalBoardWorld(world); + PhysicalBoardPlan.Placement placement = new PhysicalBoardPlan.Placement( + location, "OAK_WALL_SIGN", PhysicalBoardSignKind.DECORATIVE + ); + + assertTrue(adapter.matches(placement, BoardFacing.NORTH)); + assertFalse(adapter.matches(placement, BoardFacing.SOUTH)); + } + + @Test + void rendersReadableTitleAndInstructionText() { + assertEquals( + List.of(Component.text("Quest"), Component.text("Board"), + Component.empty(), Component.empty()), + placedLines(PhysicalBoardSignKind.TITLE) + ); + assertEquals( + List.of(Component.text("Right-click"), Component.text("a sign below"), + Component.text("to browse"), Component.text("quests")), + placedLines(PhysicalBoardSignKind.INSTRUCTION) + ); + } + + private static List placedLines(PhysicalBoardSignKind kind) { + 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); + + new BukkitPhysicalBoardWorld(world).place( + new PhysicalBoardPlan.Placement(location, "OAK_WALL_SIGN", kind), + BoardFacing.NORTH + ); + + ArgumentCaptor text = ArgumentCaptor.forClass(Component.class); + verify(signSide, times(4)).line(any(Integer.class), text.capture()); + return text.getAllValues(); + } + @Test void restoresCapturedBukkitBlockState() { UUID worldId = UUID.randomUUID(); diff --git a/src/test/java/games/dmg/spigotquestboard/PhysicalBoardCreatorTest.java b/src/test/java/games/dmg/spigotquestboard/PhysicalBoardCreatorTest.java index 6e209c4..a122403 100644 --- a/src/test/java/games/dmg/spigotquestboard/PhysicalBoardCreatorTest.java +++ b/src/test/java/games/dmg/spigotquestboard/PhysicalBoardCreatorTest.java @@ -33,10 +33,10 @@ final class PhysicalBoardCreatorTest { creator.create(anchorBlock(), BoardFacing.NORTH) ); - assertEquals(23, world.placed.size()); + assertEquals(25, world.placed.size()); assertEquals("AIR", world.materials.getOrDefault(ANCHOR, "AIR")); - assertEquals(15, registry.size()); - assertEquals(15, repository.state.boards().size()); + assertEquals(17, registry.size()); + assertEquals(17, repository.state.boards().size()); assertEquals(1, repository.saves); assertTrue(repository.state.boards().stream().allMatch( board -> board.worldName().equals("survival") @@ -49,7 +49,9 @@ final class PhysicalBoardCreatorTest { BoardRegistry registry = new BoardRegistry(repository); FakeWorld world = new FakeWorld(); BoardId obstruction = PhysicalBoardPlan.create(ANCHOR, BoardFacing.EAST) - .placements().get(7).location(); + .placements().stream() + .filter(placement -> placement.signKind() == PhysicalBoardSignKind.TITLE) + .findFirst().orElseThrow().location(); world.materials.put(obstruction, "STONE"); assertEquals( diff --git a/src/test/java/games/dmg/spigotquestboard/PhysicalBoardPlanTest.java b/src/test/java/games/dmg/spigotquestboard/PhysicalBoardPlanTest.java index e407a57..03c0077 100644 --- a/src/test/java/games/dmg/spigotquestboard/PhysicalBoardPlanTest.java +++ b/src/test/java/games/dmg/spigotquestboard/PhysicalBoardPlanTest.java @@ -19,9 +19,9 @@ final class PhysicalBoardPlanTest { .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() + assertEquals(5, plan.placements().stream() .filter(block -> block.material().equals("OAK_WALL_SIGN")).count()); - assertEquals(15, plan.interactionLocations().size()); + assertEquals(17, 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()); @@ -35,8 +35,8 @@ final class PhysicalBoardPlanTest { for (BoardFacing facing : BoardFacing.values()) { PhysicalBoardPlan plan = PhysicalBoardPlan.create(anchor, facing); - assertEquals(23, plan.placements().size()); - assertEquals(23, new HashSet<>(plan.placements().stream() + assertEquals(25, plan.placements().size()); + assertEquals(25, new HashSet<>(plan.placements().stream() .map(PhysicalBoardPlan.Placement::location).toList()).size()); var backing = plan.placements().stream().filter( @@ -53,13 +53,20 @@ final class PhysicalBoardPlanTest { var signs = plan.placements().stream().filter( PhysicalBoardPlan.Placement::sign ).toList(); - assertEquals(3, signs.size()); + assertEquals(5, 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 + && sign.location().y() >= anchor.y() + 2 + && sign.location().y() <= anchor.y() + 4 )); - assertEquals(15, plan.interactionLocations().size()); + assertEquals(1, signs.stream() + .filter(sign -> sign.signKind() == PhysicalBoardSignKind.TITLE).count()); + assertEquals(1, signs.stream() + .filter(sign -> sign.signKind() == PhysicalBoardSignKind.INSTRUCTION).count()); + assertEquals(3, signs.stream() + .filter(sign -> sign.signKind() == PhysicalBoardSignKind.DECORATIVE).count()); + assertEquals(17, plan.interactionLocations().size()); assertTrue(plan.interactionLocations().containsAll(signs.stream() .map(PhysicalBoardPlan.Placement::location).toList())); assertTrue(backing.stream() diff --git a/src/test/java/games/dmg/spigotquestboard/PhysicalBoardUpgraderTest.java b/src/test/java/games/dmg/spigotquestboard/PhysicalBoardUpgraderTest.java new file mode 100644 index 0000000..e280eae --- /dev/null +++ b/src/test/java/games/dmg/spigotquestboard/PhysicalBoardUpgraderTest.java @@ -0,0 +1,206 @@ +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 java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; + +final class PhysicalBoardUpgraderTest { + private static final UUID WORLD_ID = UUID.fromString( + "00000000-0000-0000-0000-000000000011" + ); + private static final BoardId ANCHOR = new BoardId(WORLD_ID, 10, 64, 20); + private static final BoardFacing FACING = BoardFacing.NORTH; + + @Test + void upgradesOnlyAnExactRegisteredLegacyStructure() throws Exception { + RecordingRepository repository = new RecordingRepository(legacyRegistrations(), false); + BoardRegistry registry = new BoardRegistry(repository); + FakeWorld world = legacyWorld(); + + assertEquals(1, upgrader(registry, world).upgrade()); + + assertEquals(17, registry.size()); + assertEquals(1, repository.saves); + assertEquals(Set.of(PhysicalBoardSignKind.TITLE, PhysicalBoardSignKind.INSTRUCTION), + world.placed.values().stream().map(PhysicalBoardPlan.Placement::signKind) + .collect(Collectors.toSet())); + assertEquals(2, world.placed.size()); + } + + @Test + void doesNotAlterCustomBoardsOrStructuresThatDoNotExactlyMatch() throws Exception { + RegisteredBoard custom = new RegisteredBoard( + new BoardId(WORLD_ID, 100, 70, 100), "survival" + ); + RecordingRepository customRepository = new RecordingRepository(Set.of(custom), false); + FakeWorld customWorld = new FakeWorld(); + assertEquals(0, upgrader(new BoardRegistry(customRepository), customWorld).upgrade()); + assertTrue(customWorld.placed.isEmpty()); + + RecordingRepository changedRepository = new RecordingRepository( + legacyRegistrations(), false + ); + FakeWorld changedWorld = legacyWorld(); + PhysicalBoardPlan.Placement log = plan().placements().stream() + .filter(placement -> "OAK_LOG".equals(placement.material())).findFirst().orElseThrow(); + changedWorld.existing.put(log.location(), new PhysicalBoardPlan.Placement( + log.location(), "STONE" + )); + assertEquals(0, upgrader(new BoardRegistry(changedRepository), changedWorld).upgrade()); + assertTrue(changedWorld.placed.isEmpty()); + } + + @Test + void doesNotOverwriteAnOccupiedNewSignCell() throws Exception { + RecordingRepository repository = new RecordingRepository(legacyRegistrations(), false); + BoardRegistry registry = new BoardRegistry(repository); + FakeWorld world = legacyWorld(); + BoardId title = newSigns().stream() + .filter(sign -> sign.signKind() == PhysicalBoardSignKind.TITLE) + .findFirst().orElseThrow().location(); + world.existing.put(title, new PhysicalBoardPlan.Placement(title, "STONE")); + + assertEquals(0, upgrader(registry, world).upgrade()); + + assertTrue(world.placed.isEmpty()); + assertEquals(15, registry.size()); + assertEquals(0, repository.saves); + } + + @Test + void rollsBackBothCellsWhenSignConstructionFails() throws Exception { + RecordingRepository repository = new RecordingRepository(legacyRegistrations(), false); + BoardRegistry registry = new BoardRegistry(repository); + FakeWorld world = legacyWorld(); + world.failPlacement = 1; + + assertThrows(IOException.class, () -> upgrader(registry, world).upgrade()); + + assertTrue(newSigns().stream().allMatch(sign -> !world.existing.containsKey(sign.location()))); + assertEquals(15, registry.size()); + assertEquals(0, repository.saves); + } + + @Test + void rollsBackBothCellsWhenAtomicPersistenceFails() throws Exception { + RecordingRepository repository = new RecordingRepository(legacyRegistrations(), true); + BoardRegistry registry = new BoardRegistry(repository); + FakeWorld world = legacyWorld(); + + assertThrows(IOException.class, () -> upgrader(registry, world).upgrade()); + + assertTrue(newSigns().stream().allMatch(sign -> !world.existing.containsKey(sign.location()))); + assertEquals(15, registry.size()); + assertEquals(1, repository.saves); + } + + private static PhysicalBoardUpgrader upgrader(BoardRegistry registry, FakeWorld world) { + return new PhysicalBoardUpgrader( + registry, worldId -> WORLD_ID.equals(worldId) ? world : null + ); + } + + private static PhysicalBoardPlan plan() { + return PhysicalBoardPlan.create(ANCHOR, FACING); + } + + private static Set legacyRegistrations() { + return plan().placements().stream().filter(placement -> + "OAK_PLANKS".equals(placement.material()) + || placement.signKind() == PhysicalBoardSignKind.DECORATIVE + ).map(placement -> new RegisteredBoard(placement.location(), "survival")) + .collect(Collectors.toSet()); + } + + private static FakeWorld legacyWorld() { + FakeWorld world = new FakeWorld(); + plan().placements().stream().filter(placement -> + placement.signKind() != PhysicalBoardSignKind.TITLE + && placement.signKind() != PhysicalBoardSignKind.INSTRUCTION + ).forEach(placement -> { + world.existing.put(placement.location(), placement); + world.facings.put(placement.location(), FACING); + }); + return world; + } + + private static java.util.List newSigns() { + return plan().placements().stream().filter(placement -> + placement.signKind() == PhysicalBoardSignKind.TITLE + || placement.signKind() == PhysicalBoardSignKind.INSTRUCTION + ).toList(); + } + + private static final class FakeWorld implements PhysicalBoardWorld { + private final Map existing = new HashMap<>(); + private final Map facings = new HashMap<>(); + private final Map placed = new HashMap<>(); + private int placements; + private int failPlacement = -1; + + @Override public boolean isEmpty(BoardId location) { + return !existing.containsKey(location); + } + + @Override public Object snapshot(BoardId location) { + return existing.get(location); + } + + @Override public void place(PhysicalBoardPlan.Placement placement, BoardFacing facing) { + if (placements++ == failPlacement) { + throw new IllegalStateException("simulated sign build failure"); + } + existing.put(placement.location(), placement); + facings.put(placement.location(), facing); + placed.put(placement.location(), placement); + } + + @Override public boolean matches( + PhysicalBoardPlan.Placement placement, BoardFacing facing + ) { + return placement.equals(existing.get(placement.location())) + && (!placement.sign() || facing == facings.get(placement.location())); + } + + @Override public void restore(BoardId location, Object snapshot) { + if (snapshot == null) { + existing.remove(location); + facings.remove(location); + } else { + PhysicalBoardPlan.Placement placement = (PhysicalBoardPlan.Placement) snapshot; + existing.put(location, placement); + } + } + } + + private static final class RecordingRepository implements BoardRepository { + private BoardState state; + private final boolean failSave; + private int saves; + + private RecordingRepository(Set boards, boolean failSave) { + state = new BoardState(boards); + 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/PluginCommandWiringTest.java b/src/test/java/games/dmg/spigotquestboard/PluginCommandWiringTest.java index 4b57b82..6638516 100644 --- a/src/test/java/games/dmg/spigotquestboard/PluginCommandWiringTest.java +++ b/src/test/java/games/dmg/spigotquestboard/PluginCommandWiringTest.java @@ -1,5 +1,6 @@ package games.dmg.spigotquestboard; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -8,12 +9,41 @@ import static org.mockito.Mockito.when; import java.time.Clock; import java.util.List; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.junit.jupiter.api.Test; final class PluginCommandWiringTest { + @Test + void startupPhysicalBoardUpgradeDelegatesToLoadedWorlds() throws Exception { + UUID worldId = UUID.randomUUID(); + PhysicalBoardPlan plan = PhysicalBoardPlan.create( + new BoardId(worldId, 1, 64, 1), BoardFacing.NORTH + ); + Set legacy = plan.placements().stream() + .filter(placement -> "OAK_PLANKS".equals(placement.material()) + || placement.signKind() == PhysicalBoardSignKind.DECORATIVE) + .map(placement -> new RegisteredBoard(placement.location(), "survival")) + .collect(java.util.stream.Collectors.toSet()); + BoardRegistry registry = new BoardRegistry(new BoardRepository() { + @Override public BoardState load() { return new BoardState(legacy); } + @Override public void save(BoardState state) { } + }); + AtomicInteger worldLookups = new AtomicInteger(); + + int upgraded = SpigotQuestBoardPlugin.upgradePhysicalBoards(registry, id -> { + assertEquals(worldId, id); + worldLookups.incrementAndGet(); + return null; + }); + + assertEquals(0, upgraded); + assertTrue(worldLookups.get() > 0); + } + @Test void adminAndPlayerCommandsShareThePersistedSetting() throws Exception { PlayerCommandSettings settings = new PlayerCommandSettings( diff --git a/src/test/java/games/dmg/spigotquestboard/QuestAdminCommandTest.java b/src/test/java/games/dmg/spigotquestboard/QuestAdminCommandTest.java index b5d848d..2fa68b4 100644 --- a/src/test/java/games/dmg/spigotquestboard/QuestAdminCommandTest.java +++ b/src/test/java/games/dmg/spigotquestboard/QuestAdminCommandTest.java @@ -206,8 +206,8 @@ final class QuestAdminCommandTest { new String[] {"createboard", "physical"} )); - assertEquals(23, placements.get()); - assertEquals(15, repository.state.boards().size()); + assertEquals(25, placements.get()); + assertEquals(17, repository.state.boards().size()); verify(player).sendMessage("Physical quest board created."); } diff --git a/src/test/java/games/dmg/spigotquestboard/YamlBoardRepositoryTest.java b/src/test/java/games/dmg/spigotquestboard/YamlBoardRepositoryTest.java index 96fe166..d908db3 100644 --- a/src/test/java/games/dmg/spigotquestboard/YamlBoardRepositoryTest.java +++ b/src/test/java/games/dmg/spigotquestboard/YamlBoardRepositoryTest.java @@ -57,7 +57,7 @@ final class YamlBoardRepositoryTest { assertEquals(BoardRegistrationResult.CREATED, first.registerAll(generated)); BoardRegistry restarted = new BoardRegistry(new YamlBoardRepository(path)); - assertEquals(15, restarted.size()); + assertEquals(17, restarted.size()); assertTrue(generated.stream().allMatch(board -> restarted.contains(board.id()))); }