diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d316ddc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,67 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Border is a BentoBox addon for Minecraft Paper servers that creates and renders per-player world borders around islands. Players cannot pass the border. It supports two rendering modes: barrier blocks with particles, and vanilla world borders. + +**Target:** Paper 1.21+ with BentoBox 3.10+, Java 21 + +## Build Commands + +```bash +mvn clean package # Build the plugin JAR +mvn test # Run all tests +mvn test -Dtest=BorderTest # Run a single test class +mvn verify # Full build with JaCoCo coverage +``` + +Output JAR: `target/Border-{version}.jar` + +## Architecture + +### Addon Lifecycle (BentoBox Pladdon pattern) + +`BorderPladdon` (plugin entry point) → `Border` (addon, extends `Addon`) → registers commands, listeners, and border implementations per game mode. + +### Border Rendering — Strategy + Proxy + +`BorderShower` is the core interface with methods: `showBorder`, `hideBorder`, `clearUser`, `refreshView`, `teleportEntity`. + +Two implementations: +- **`ShowBarrier`** — Renders barrier blocks and colored particles around island edges. Caches barrier block positions per player. Uses async chunk loading. +- **`ShowWorldBorder`** — Uses Paper's per-player WorldBorder API. Manipulates border animation (shrink/grow/static) to achieve color effects. + +**`PerPlayerBorderProxy`** delegates to the correct implementation based on per-player metadata. Falls back to addon-wide default settings. + +### Per-Player State via Metadata + +Player preferences are stored as BentoBox `MetaDataValue` entries: +- `Border_state` — border on/off toggle +- `Border_bordertype` — BARRIER or VANILLA (stored as byte id) +- `Border_color` — RED, GREEN, or BLUE + +### Commands + +Registered as subcommands under each game mode's player command: +- `IslandBorderCommand` (`/[gamemode] border`) — toggle border visibility +- `BorderTypeCommand` (`/[gamemode] bordertype`) — switch between barrier/vanilla +- `BorderColorCommand` (`/[gamemode] bordercolor`) — change border color + +### Event Handling + +`PlayerListener` handles all player events (join, quit, move, teleport, mount, item drop) to trigger border show/hide/refresh and enforce boundary teleportation. + +## Testing + +Uses JUnit 5 + Mockito 5 + MockBukkit. All test classes extend `CommonTestSetup` which provides comprehensive mocking of BentoBox, Bukkit server, worlds, players, and island managers. + +`WhiteBox` is a reflection utility for accessing private fields in tests. + +## Key Conventions + +- Configuration is managed via the `Settings` class with BentoBox's `@StoreAt`/`@ConfigComment` annotations and `Config` loader +- Game modes can be excluded via `Settings.getDisabledGameModes()` +- Localization files live in `src/main/resources/locales/` (20+ languages, YAML format) +- `BorderType` is an enum with byte-based serialization ids (`fromId`/`getId`) diff --git a/pom.xml b/pom.xml index 56e11ea..060ec23 100644 --- a/pom.xml +++ b/pom.xml @@ -51,7 +51,7 @@ ${build.version}-SNAPSHOT - 4.8.0 + 4.8.1 -LOCAL BentoBoxWorld_Border @@ -102,25 +102,41 @@ + jitpack.io https://jitpack.io + + true + - papermc - https://repo.papermc.io/repository/maven-public/ + papermc + https://repo.papermc.io/repository/maven-public/ + + false + bentoboxworld https://repo.codemc.org/repository/bentoboxworld/ + + false + codemc https://repo.codemc.org/repository/maven-snapshots/ + + true + codemc-repo https://repo.codemc.org/repository/maven-public/ + + false + diff --git a/src/main/java/world/bentobox/border/Border.java b/src/main/java/world/bentobox/border/Border.java index beab5d2..0cc2b61 100644 --- a/src/main/java/world/bentobox/border/Border.java +++ b/src/main/java/world/bentobox/border/Border.java @@ -9,7 +9,6 @@ import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.api.configuration.Config; import world.bentobox.bentobox.api.metadata.MetaDataValue; -import world.bentobox.bentobox.util.Util; import world.bentobox.border.commands.BorderColorCommand; import world.bentobox.border.commands.BorderTypeCommand; import world.bentobox.border.commands.IslandBorderCommand; @@ -122,7 +121,11 @@ public Settings getSettings() { * @return true if world is being handled by Border */ public boolean inGameWorld(World world) { - return gameModes.stream().anyMatch(gm -> gm.inWorld(Util.getWorld(world))); + if (world.getEnvironment() == World.Environment.NETHER && + !getPlugin().getIWM().isIslandNether(world)) { return false;} + if (world.getEnvironment() == World.Environment.THE_END && + !getPlugin().getIWM().isIslandEnd(world)) { return false;} + return gameModes.stream().anyMatch(gm -> gm.inWorld(world)); } public Set getAvailableBorderTypesView() { diff --git a/src/main/java/world/bentobox/border/listeners/PlayerListener.java b/src/main/java/world/bentobox/border/listeners/PlayerListener.java index 2eb3a24..cfb4bfa 100644 --- a/src/main/java/world/bentobox/border/listeners/PlayerListener.java +++ b/src/main/java/world/bentobox/border/listeners/PlayerListener.java @@ -38,6 +38,7 @@ import org.bukkit.util.RayTraceResult; import org.bukkit.util.Vector; +import world.bentobox.bentobox.BentoBox; import world.bentobox.bentobox.api.addons.Addon; import world.bentobox.bentobox.api.events.island.IslandProtectionRangeChangeEvent; import world.bentobox.bentobox.api.flags.Flag; @@ -238,13 +239,14 @@ public void onPlayerTeleport(PlayerTeleportEvent e) { return; } Location to = e.getTo(); - - show.clearUser(User.getInstance(player)); - + User user = User.getInstance(player); + show.hideBorder(user); + show.clearUser(user); if (!addon.inGameWorld(to.getWorld())) { return; } + TeleportCause cause = e.getCause(); boolean isBlacklistedCause = cause == TeleportCause.ENDER_PEARL || cause == TeleportCause.CONSUMABLE_EFFECT; @@ -287,6 +289,9 @@ public void onPlayerTeleport(PlayerTeleportEvent e) { */ @EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true) public void onPlayerLeaveIsland(PlayerMoveEvent e) { + if (!addon.inGameWorld(e.getTo().getWorld())) { + return; + } Player p = e.getPlayer(); if (!isOn(p)) { return; @@ -295,13 +300,16 @@ public void onPlayerLeaveIsland(PlayerMoveEvent e) { if (!addon.getSettings().isReturnTeleport() || !outsideCheck(e.getPlayer(), from, e.getTo())) { return; } + BentoBox.getInstance().logDebug("Player " + p.getName() + " is trying to leave the island protection zone."); // Move the player back inside the border if (addon.getIslands().getProtectedIslandAt(from).isPresent()) { + BentoBox.getInstance().logDebug("Player " + p.getName() + " is being teleported back inside the island protection zone."); e.setCancelled(true); inTeleport.add(p.getUniqueId()); Util.teleportAsync(p, from).thenRun(() -> inTeleport.remove(p.getUniqueId())); return; } + BentoBox.getInstance().logDebug("Player " + p.getName() + " is not on an island, trying to find the nearest island to teleport them back to."); // Backtrack - try to find island at current location, or fall back to the player's own island Optional optionalIsland = addon.getIslands().getIslandAt(p.getLocation()); if (optionalIsland.isEmpty()) { @@ -340,6 +348,7 @@ public void onPlayerLeaveIsland(PlayerMoveEvent e) { } Util.teleportAsync(p, targetPos).thenRun(() -> inTeleport.remove(p.getUniqueId())); } else { + BentoBox.getInstance().logDebug("Ray trace did not find a valid position for player " + p.getName() + " so teleporting them back to their island home."); Util.teleportAsync(p, i.getHome("")).thenRun(() -> inTeleport.remove(p.getUniqueId())); } diff --git a/src/main/java/world/bentobox/border/listeners/ShowWorldBorder.java b/src/main/java/world/bentobox/border/listeners/ShowWorldBorder.java index c98051e..afebc51 100644 --- a/src/main/java/world/bentobox/border/listeners/ShowWorldBorder.java +++ b/src/main/java/world/bentobox/border/listeners/ShowWorldBorder.java @@ -78,6 +78,16 @@ public void hideBorder(User user) { user.getPlayer().setWorldBorder(null); } + @Override + public void clearUser(User user) { + user.getPlayer().setWorldBorder(null); + } + + @Override + public void refreshView(User user, Island island) { + showBorder(user.getPlayer(), island); + } + /** * Teleport player back within the island space they are in * @param entity player diff --git a/src/test/java/world/bentobox/border/BorderTest.java b/src/test/java/world/bentobox/border/BorderTest.java index b6e2ee2..a313258 100644 --- a/src/test/java/world/bentobox/border/BorderTest.java +++ b/src/test/java/world/bentobox/border/BorderTest.java @@ -14,6 +14,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.bukkit.World.Environment; + import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.util.Util; @@ -51,6 +53,44 @@ public void testInGameWorldReturnsFalseWhenNoGameModeMatches() throws Exception assertFalse(border.inGameWorld(world)); } + @Test + public void testInGameWorldReturnsFalseForVanillaNether() { + when(world.getEnvironment()).thenReturn(Environment.NETHER); + when(iwm.isIslandNether(world)).thenReturn(false); + + assertFalse(border.inGameWorld(world)); + } + + @Test + public void testInGameWorldReturnsFalseForVanillaEnd() { + when(world.getEnvironment()).thenReturn(Environment.THE_END); + when(iwm.isIslandEnd(world)).thenReturn(false); + + assertFalse(border.inGameWorld(world)); + } + + @Test + public void testInGameWorldDelegatesToGameModeForIslandNether() throws Exception { + when(world.getEnvironment()).thenReturn(Environment.NETHER); + when(iwm.isIslandNether(world)).thenReturn(true); + GameModeAddon matching = mock(GameModeAddon.class); + when(matching.inWorld(world)).thenReturn(true); + getGameModes().add(matching); + + assertTrue(border.inGameWorld(world)); + } + + @Test + public void testInGameWorldDelegatesToGameModeForIslandEnd() throws Exception { + when(world.getEnvironment()).thenReturn(Environment.THE_END); + when(iwm.isIslandEnd(world)).thenReturn(true); + GameModeAddon matching = mock(GameModeAddon.class); + when(matching.inWorld(world)).thenReturn(true); + getGameModes().add(matching); + + assertTrue(border.inGameWorld(world)); + } + @Test public void testGetAvailableBorderTypesViewIsUnmodifiable() { Set view = border.getAvailableBorderTypesView(); diff --git a/src/test/java/world/bentobox/border/listeners/PlayerListenerTest.java b/src/test/java/world/bentobox/border/listeners/PlayerListenerTest.java index a6a0e70..b223d91 100644 --- a/src/test/java/world/bentobox/border/listeners/PlayerListenerTest.java +++ b/src/test/java/world/bentobox/border/listeners/PlayerListenerTest.java @@ -132,6 +132,7 @@ public void setUp() throws Exception { when(gma.getPermissionPrefix()).thenReturn("bskyblock."); when(iwm.getAddon(world)).thenReturn(Optional.of(gma)); when(plugin.getIWM()).thenReturn(iwm); + when(iwm.isIslandNether(any())).thenReturn(true); // Util CompletableFuture future = new CompletableFuture<>(); @@ -200,18 +201,53 @@ public void testOnPlayerTeleportNotInGameWorld() { when(addon.inGameWorld(any())).thenReturn(false); PlayerTeleportEvent event = new PlayerTeleportEvent(player, from, to, TeleportCause.NETHER_PORTAL); pl.onPlayerTeleport(event); + verify(show).hideBorder(user); verify(show).clearUser(user); mockedBukkit.verify(Bukkit::getScheduler, never()); } - + /** * Test method for {@link world.bentobox.border.listeners.PlayerListener#onPlayerTeleport(org.bukkit.event.player.PlayerTeleportEvent)}. */ @Test public void testOnPlayerTeleportInGameWorld() { when(addon.inGameWorld(any())).thenReturn(true); + when(iwm.isIslandNether(any())).thenReturn(false); // In-game world teleport (non-island nether) should still trigger scheduler + PlayerTeleportEvent event = new PlayerTeleportEvent(player, from, to, TeleportCause.NETHER_PORTAL); + pl.onPlayerTeleport(event); + verify(show).hideBorder(user); + verify(show).clearUser(user); + mockedBukkit.verify(Bukkit::getScheduler); + } + + /** + * Test method for {@link world.bentobox.border.listeners.PlayerListener#onPlayerTeleport(org.bukkit.event.player.PlayerTeleportEvent)}. + * Island nether world - should proceed with border management. + */ + @Test + public void testOnPlayerTeleportInIslandNether() { + when(addon.inGameWorld(any())).thenReturn(true); + when(iwm.isIslandNether(any())).thenReturn(true); + when(iwm.isIslandEnd(any())).thenReturn(false); PlayerTeleportEvent event = new PlayerTeleportEvent(player, from, to, TeleportCause.NETHER_PORTAL); pl.onPlayerTeleport(event); + verify(show).hideBorder(user); + verify(show).clearUser(user); + mockedBukkit.verify(Bukkit::getScheduler); + } + + /** + * Test method for {@link world.bentobox.border.listeners.PlayerListener#onPlayerTeleport(org.bukkit.event.player.PlayerTeleportEvent)}. + * Island end world - should proceed with border management. + */ + @Test + public void testOnPlayerTeleportInIslandEnd() { + when(addon.inGameWorld(any())).thenReturn(true); + when(iwm.isIslandNether(any())).thenReturn(false); + when(iwm.isIslandEnd(any())).thenReturn(true); + PlayerTeleportEvent event = new PlayerTeleportEvent(player, from, to, TeleportCause.END_PORTAL); + pl.onPlayerTeleport(event); + verify(show).hideBorder(user); verify(show).clearUser(user); mockedBukkit.verify(Bukkit::getScheduler); } @@ -251,7 +287,67 @@ public void testOnPlayerLeaveIslandReturnTeleportOutsideCheckNotInGameWorld() { pl.onPlayerLeaveIsland(event); verify(addon, never()).getIslands(); } - + + /** + * Test method for {@link world.bentobox.border.listeners.PlayerListener#onPlayerLeaveIsland(org.bukkit.event.player.PlayerMoveEvent)}. + * Tests that movement in a vanilla (non-island) nether world is ignored — players should NOT + * be teleported back when using the vanilla nether. + */ + @Test + public void testOnPlayerLeaveIslandVanillaNether() { + when(addon.inGameWorld(any())).thenReturn(false); // vanilla nether is not a game world + settings.setReturnTeleport(true); + PlayerMoveEvent event = new PlayerMoveEvent(player, from, to); + pl.onPlayerLeaveIsland(event); + verify(addon, never()).getIslands(); + } + + /** + * Test method for {@link world.bentobox.border.listeners.PlayerListener#onPlayerLeaveIsland(org.bukkit.event.player.PlayerMoveEvent)}. + * Tests that movement in a vanilla (non-island) end world is ignored. + */ + @Test + public void testOnPlayerLeaveIslandVanillaEnd() { + when(addon.inGameWorld(any())).thenReturn(false); // vanilla end is not a game world + settings.setReturnTeleport(true); + PlayerMoveEvent event = new PlayerMoveEvent(player, from, to); + pl.onPlayerLeaveIsland(event); + verify(addon, never()).getIslands(); + } + + /** + * Test method for {@link world.bentobox.border.listeners.PlayerListener#onPlayerLeaveIsland(org.bukkit.event.player.PlayerMoveEvent)}. + * Tests that a player trying to leave the protection zone in an island nether world is + * teleported back. + */ + @Test + public void testOnPlayerLeaveIslandIslandNetherReturnsTeleport() { + when(iwm.isIslandNether(any())).thenReturn(true); + when(iwm.isIslandEnd(any())).thenReturn(false); + when(island.onIsland(any())).thenReturn(false); + settings.setReturnTeleport(true); + PlayerMoveEvent event = new PlayerMoveEvent(player, from, to); + pl.onPlayerLeaveIsland(event); + assertTrue(event.isCancelled()); + } + + /** + * Test method for {@link world.bentobox.border.listeners.PlayerListener#onPlayerLeaveIsland(org.bukkit.event.player.PlayerMoveEvent)}. + * Tests that a player trying to leave the protection zone in an island end world is + * teleported back. + */ + @Test + public void testOnPlayerLeaveIslandIslandEndReturnsTeleport() { + when(iwm.isIslandNether(any())).thenReturn(false); + when(iwm.isIslandEnd(any())).thenReturn(true); + when(island.onIsland(any())).thenReturn(false); + settings.setReturnTeleport(true); + PlayerMoveEvent event = new PlayerMoveEvent(player, from, to); + pl.onPlayerLeaveIsland(event); + assertTrue(event.isCancelled()); + } + + /** * Test method for {@link world.bentobox.border.listeners.PlayerListener#onPlayerLeaveIsland(org.bukkit.event.player.PlayerMoveEvent)}. */ diff --git a/src/test/java/world/bentobox/border/listeners/ShowWorldBorderTest.java b/src/test/java/world/bentobox/border/listeners/ShowWorldBorderTest.java index 44a266e..f5c1487 100644 --- a/src/test/java/world/bentobox/border/listeners/ShowWorldBorderTest.java +++ b/src/test/java/world/bentobox/border/listeners/ShowWorldBorderTest.java @@ -121,6 +121,25 @@ public void testHideBorder() { svwb.hideBorder(user); verify(mockPlayer).setWorldBorder(null); } + + /** + * Test method for {@link world.bentobox.border.listeners.ShowWorldBorder#clearUser(world.bentobox.bentobox.api.user.User)}. + */ + @Test + public void testClearUser() { + svwb.clearUser(user); + verify(mockPlayer).setWorldBorder(null); + } + + /** + * Test method for {@link world.bentobox.border.listeners.ShowWorldBorder#refreshView(world.bentobox.bentobox.api.user.User, world.bentobox.bentobox.database.objects.Island)}. + */ + @Test + public void testRefreshView() { + svwb.refreshView(user, island); + verify(mockPlayer).setWorldBorder(wb); + verify(wb).setSize(200.0D); + } /** * Test method for {@link world.bentobox.border.listeners.ShowWorldBorder#showBorder(org.bukkit.entity.Player, world.bentobox.bentobox.database.objects.Island)}.