diff --git a/README.md b/README.md index 8529fba..ce26cb5 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Each covered world can have its own boundary, allowing the playable space to fol - **Per-world boundaries** — rectangular regions define the playable area in each covered world. - **Advance warnings** — on-screen titles alert players while they are still inside the border's warning zone. - **Out-of-bounds damage** — players beyond the region take repeated damage until they return. +- **Lethal buffer** — optionally, players further out are killed outright, and past the buffer nothing happens, so builds far from the map are safe for players teleported to them while no one can walk, ride or fly there, as long as the lethal band (buffer-width minus lethal-depth) is wider than a player can travel between two border checks. The plugin logs a warning on load when it is not. Respawn points inside the lethal band send players to the world spawn instead, and ender pearls, chorus fruit, Nether portals and End gateways cannot jump a player over the band. - **Gameplay-aware enforcement** — survival and adventure players are affected, with creative and spectator modes exempt. - **Staff bypass** — a permission allows authorized players to cross the boundary without warnings or damage. diff --git a/src/main/java/net/tfminecraft/worldborder/WorldBorder.java b/src/main/java/net/tfminecraft/worldborder/WorldBorder.java index 81a3e1b..3fd7470 100644 --- a/src/main/java/net/tfminecraft/worldborder/WorldBorder.java +++ b/src/main/java/net/tfminecraft/worldborder/WorldBorder.java @@ -9,6 +9,8 @@ import net.tfminecraft.worldborder.cache.Cache; import net.tfminecraft.worldborder.border.BorderManager; +import net.tfminecraft.worldborder.border.RespawnGuard; +import net.tfminecraft.worldborder.border.TeleportGuard; import net.tfminecraft.worldborder.command.CommandManager; import net.tfminecraft.worldborder.loader.ConfigLoader; @@ -33,6 +35,8 @@ public void onEnable() { } else { getLogger().severe("Command 'worldborder' missing from plugin.yml"); } + getServer().getPluginManager().registerEvents(new RespawnGuard(), this); + getServer().getPluginManager().registerEvents(new TeleportGuard(), this); borderManager.start(); getLogger().info("WorldBorder enabled."); } diff --git a/src/main/java/net/tfminecraft/worldborder/border/BorderManager.java b/src/main/java/net/tfminecraft/worldborder/border/BorderManager.java index 27e4455..91d5ea9 100644 --- a/src/main/java/net/tfminecraft/worldborder/border/BorderManager.java +++ b/src/main/java/net/tfminecraft/worldborder/border/BorderManager.java @@ -7,6 +7,7 @@ import org.bukkit.Bukkit; import org.bukkit.GameMode; +import org.bukkit.Location; import org.bukkit.entity.Player; import org.bukkit.scheduler.BukkitTask; @@ -80,7 +81,24 @@ private void tick() { 15, 0); } - case SAFE -> clearWarning(player); + case LETHAL -> { + clearWarning(player); + // Health straight to zero: no damage event, so armour, totems and + // plugins that cancel damage can't carry anyone across the band. + player.setHealth(0); + if (!player.isDead()) { + // Another plugin cancelled the death; don't let them walk on in the band. + Location safe = RespawnGuard.safeSpot(player.getWorld(), region); + if (player.teleport(safe)) { + plugin.getLogger().info("Moved " + player.getName() + " out of the lethal band to " + + RespawnGuard.describe(player.getWorld(), safe) + " after their death was cancelled."); + } else { + plugin.getLogger().warning("Could not move " + player.getName() + + " out of the lethal band after their death was cancelled."); + } + } + } + case SAFE, BEYOND -> clearWarning(player); } } } @@ -102,7 +120,7 @@ private void clearWarning(Player player) { } } - private boolean affects(Player player) { + static boolean affects(Player player) { GameMode mode = player.getGameMode(); if (mode != GameMode.SURVIVAL && mode != GameMode.ADVENTURE) { return false; diff --git a/src/main/java/net/tfminecraft/worldborder/border/Region.java b/src/main/java/net/tfminecraft/worldborder/border/Region.java index 3cb8293..b0df7b4 100644 --- a/src/main/java/net/tfminecraft/worldborder/border/Region.java +++ b/src/main/java/net/tfminecraft/worldborder/border/Region.java @@ -2,24 +2,41 @@ public final class Region { + /** No lethal band: everything past the edge only takes the regular damage. */ + public static final int NO_LETHAL_DEPTH = -1; + /** No outer limit: the damage (and lethal) band reaches out forever. */ + public static final int ENDLESS_BUFFER = 0; + public final String world; public final int minX; public final int maxX; public final int minZ; public final int maxZ; + /** Blocks past the edge where the lethal band starts, or {@link #NO_LETHAL_DEPTH}. */ + public final int lethalDepth; + /** Blocks past the edge where the buffer ends and nothing happens, or {@link #ENDLESS_BUFFER}. */ + public final int bufferWidth; public Region(String world, int minX, int maxX, int minZ, int maxZ) { + this(world, minX, maxX, minZ, maxZ, NO_LETHAL_DEPTH, ENDLESS_BUFFER); + } + + public Region(String world, int minX, int maxX, int minZ, int maxZ, int lethalDepth, int bufferWidth) { this.world = world; this.minX = minX; this.maxX = maxX; this.minZ = minZ; this.maxZ = maxZ; + this.lethalDepth = lethalDepth; + this.bufferWidth = bufferWidth; } public enum Zone { SAFE, WARNING, - OUTSIDE + OUTSIDE, + LETHAL, + BEYOND } public Zone zoneAt(double x, double z, int graceInset) { @@ -27,11 +44,39 @@ public Zone zoneAt(double x, double z, int graceInset) { Math.min(x - minX, maxX - x), Math.min(z - minZ, maxZ - z)); if (dist < 0) { - return Zone.OUTSIDE; + return outsideZone(-dist); } if (dist <= graceInset) { return Zone.WARNING; } return Zone.SAFE; } + + /** + * Band for a player {@code depth} blocks past the edge, measured along the furthest + * axis so the bands are square like the border itself. + */ + private Zone outsideZone(double depth) { + if (bufferWidth > ENDLESS_BUFFER && depth > bufferWidth) { + return Zone.BEYOND; + } + if (lethalDepth >= 0 && depth >= lethalDepth) { + return Zone.LETHAL; + } + return Zone.OUTSIDE; + } + + /** + * Width of the lethal band in blocks, {@code 0} when there is none, + * or {@link Double#POSITIVE_INFINITY} when it never ends. + */ + public double lethalBandWidth() { + if (lethalDepth < 0) { + return 0; + } + if (bufferWidth <= ENDLESS_BUFFER) { + return Double.POSITIVE_INFINITY; + } + return Math.max(0, bufferWidth - lethalDepth); + } } diff --git a/src/main/java/net/tfminecraft/worldborder/border/RespawnGuard.java b/src/main/java/net/tfminecraft/worldborder/border/RespawnGuard.java new file mode 100644 index 0000000..8fd9af2 --- /dev/null +++ b/src/main/java/net/tfminecraft/worldborder/border/RespawnGuard.java @@ -0,0 +1,54 @@ +package net.tfminecraft.worldborder.border; + +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerRespawnEvent; + +import net.tfminecraft.worldborder.WorldBorder; +import net.tfminecraft.worldborder.cache.Cache; + +/** + * A bed or anchor in the lethal band would kill the player again every border check, + * so respawns that would land there go to the world spawn instead. + */ +public final class RespawnGuard implements Listener { + + // Runs after other plugins have picked the respawn point. + @EventHandler(priority = EventPriority.HIGHEST) + public void onRespawn(PlayerRespawnEvent event) { + if (!BorderManager.affects(event.getPlayer())) { + return; + } + Location to = event.getRespawnLocation(); + World world = to.getWorld(); + Region region = world == null ? null : Cache.borderForWorld(world.getName()); + if (region == null || region.zoneAt(to.getX(), to.getZ(), Cache.graceInset) != Region.Zone.LETHAL) { + return; + } + Location safe = safeSpot(world, region); + event.setRespawnLocation(safe); + WorldBorder.plugin.getLogger().info("Moved " + event.getPlayer().getName() + + "'s respawn out of the lethal band (" + to.getBlockX() + ", " + to.getBlockZ() + + ") to " + describe(world, safe) + "."); + } + + /** The world spawn, or the middle of the border when the spawn is out in the lethal band too. */ + static Location safeSpot(World world, Region region) { + Location spawn = world.getSpawnLocation(); + if (region.zoneAt(spawn.getX(), spawn.getZ(), Cache.graceInset) != Region.Zone.LETHAL) { + return spawn; + } + int x = (int) Math.floorDiv((long) region.minX + region.maxX, 2L); + int z = (int) Math.floorDiv((long) region.minZ + region.maxZ, 2L); + return new Location(world, x + 0.5, world.getHighestBlockYAt(x, z) + 1, z + 0.5); + } + + static String describe(World world, Location safe) { + return safe.equals(world.getSpawnLocation()) + ? "the spawn of " + world.getName() + : "the centre of the " + world.getName() + " border"; + } +} diff --git a/src/main/java/net/tfminecraft/worldborder/border/TeleportGuard.java b/src/main/java/net/tfminecraft/worldborder/border/TeleportGuard.java new file mode 100644 index 0000000..b8e405a --- /dev/null +++ b/src/main/java/net/tfminecraft/worldborder/border/TeleportGuard.java @@ -0,0 +1,70 @@ +package net.tfminecraft.worldborder.border; + +import java.util.EnumSet; +import java.util.Set; + +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerPortalEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.event.player.PlayerTeleportEvent.TeleportCause; + +import net.tfminecraft.worldborder.WorldBorder; +import net.tfminecraft.worldborder.cache.Cache; + +/** + * The border is only checked every few ticks, so a jump that lands past the lethal band + * (an ender pearl cannon, chorus fruit, a portal) would never be seen inside it. Teleports + * players set off themselves can't land in the lethal band or past the buffer; staff and + * plugin teleports (COMMAND, PLUGIN) still can, which is how players reach far builds. + * Leaving the End (END_PORTAL) is left alone: it only goes to a bed or the world spawn, and + * a bed past the buffer means the player was sent out there. + */ +public final class TeleportGuard implements Listener { + + static final Set PLAYER_TELEPORTS = EnumSet.of( + TeleportCause.ENDER_PEARL, + TeleportCause.CONSUMABLE_EFFECT, + TeleportCause.NETHER_PORTAL, + TeleportCause.END_GATEWAY); + + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onTeleport(PlayerTeleportEvent event) { + check(event); + } + + // Portal events have their own handler list, so they need their own handler. + @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) + public void onPortal(PlayerPortalEvent event) { + check(event); + } + + private void check(PlayerTeleportEvent event) { + if (!PLAYER_TELEPORTS.contains(event.getCause()) || !BorderManager.affects(event.getPlayer())) { + return; + } + Location to = event.getTo(); + World world = to == null ? null : to.getWorld(); + Region region = world == null ? null : Cache.borderForWorld(world.getName()); + if (region == null) { + return; + } + Region.Zone zone = region.zoneAt(to.getX(), to.getZ(), Cache.graceInset); + if (zone != Region.Zone.LETHAL && zone != Region.Zone.BEYOND) { + return; + } + Location from = event.getFrom(); + if (world.equals(from.getWorld()) + && region.zoneAt(from.getX(), from.getZ(), Cache.graceInset) == Region.Zone.BEYOND) { + // Already out past the buffer, e.g. a pearl thrown around a far build. + return; + } + event.setCancelled(true); + WorldBorder.plugin.getLogger().info("Stopped " + event.getPlayer().getName() + "'s " + + event.getCause() + " teleport to " + zone + " (" + to.getBlockX() + ", " + to.getBlockZ() + + ") in " + world.getName() + "."); + } +} diff --git a/src/main/java/net/tfminecraft/worldborder/cache/Cache.java b/src/main/java/net/tfminecraft/worldborder/cache/Cache.java index 86a76f8..3ba0eb9 100644 --- a/src/main/java/net/tfminecraft/worldborder/cache/Cache.java +++ b/src/main/java/net/tfminecraft/worldborder/cache/Cache.java @@ -19,6 +19,14 @@ private Cache() {} public static int graceInset = 20; public static String titleWarning = "§cTurn back"; public static String subtitleWarning = "§cYou feel continuing further would be very dangerous..."; + /** + * Fastest a player can travel, in blocks per second. Only used to warn when a + * lethal band is narrow enough to cross between two border checks. The default + * is roughly the most the server's movement check normally accepts from an elytra + * flight (about 17 blocks a tick). It is a guideline, not a hard limit: lag or a + * client that holds back packets can move further, so keep bands well above it. + */ + public static double maxTravelSpeed = 350.0; /** * When empty, borders apply to any world with an entry in {@link #borders}. diff --git a/src/main/java/net/tfminecraft/worldborder/command/CommandManager.java b/src/main/java/net/tfminecraft/worldborder/command/CommandManager.java index 4ede93d..0b42cbf 100644 --- a/src/main/java/net/tfminecraft/worldborder/command/CommandManager.java +++ b/src/main/java/net/tfminecraft/worldborder/command/CommandManager.java @@ -68,6 +68,19 @@ private void sendDebug(Player player) { player.sendMessage("§7Zone: " + formatZone(zone)); player.sendMessage("§7Border: §fx " + region.minX + "-" + region.maxX + ", z " + region.minZ + "-" + region.maxZ); + player.sendMessage("§7Buffer: §f" + describeBuffer(region)); + } + + private static String describeBuffer(Region region) { + boolean lethalBand = region.lethalDepth != Region.NO_LETHAL_DEPTH + && (region.bufferWidth == Region.ENDLESS_BUFFER || region.lethalDepth <= region.bufferWidth); + String lethal = lethalBand + ? "lethal from " + region.lethalDepth + " blocks out" + : "no lethal band"; + String end = region.bufferWidth == Region.ENDLESS_BUFFER + ? "no outer limit" + : "ends " + region.bufferWidth + " blocks out"; + return lethal + ", " + end; } private static String formatCoord(double value) { @@ -82,6 +95,8 @@ private static String formatZone(Region.Zone zone) { case SAFE -> "§aSAFE"; case WARNING -> "§cWARNING"; case OUTSIDE -> "§cOUTSIDE"; + case LETHAL -> "§4LETHAL"; + case BEYOND -> "§bBEYOND"; }; } diff --git a/src/main/java/net/tfminecraft/worldborder/loader/ConfigLoader.java b/src/main/java/net/tfminecraft/worldborder/loader/ConfigLoader.java index 2049c7e..14d0edb 100644 --- a/src/main/java/net/tfminecraft/worldborder/loader/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/worldborder/loader/ConfigLoader.java @@ -29,6 +29,7 @@ public void load(File configFile) { Cache.graceInset = Math.max(0, config.getInt("grace-inset", 20)); Cache.titleWarning = config.getString("title-warning", Cache.titleWarning); Cache.subtitleWarning = config.getString("subtitle-warning", Cache.subtitleWarning); + Cache.maxTravelSpeed = Math.max(0.0, config.getDouble("max-travel-speed", 350.0)); Cache.worlds = new java.util.ArrayList<>(config.getStringList("worlds")); @@ -48,9 +49,48 @@ public void load(File configFile) { + ", z: " + minZ + "-" + maxZ + ")"); continue; } - borders.put(worldKey, new Region(worldKey, minX, maxX, minZ, maxZ)); + int lethalDepth = config.getInt(path + "lethal-depth", Region.NO_LETHAL_DEPTH); + if (lethalDepth < 0) { + lethalDepth = Region.NO_LETHAL_DEPTH; + } + int bufferWidth = Math.max(Region.ENDLESS_BUFFER, + config.getInt(path + "buffer-width", Region.ENDLESS_BUFFER)); + Region region = new Region(worldKey, minX, maxX, minZ, maxZ, lethalDepth, bufferWidth); + warnIfBufferCanBeCrossed(region); + borders.put(worldKey, region); } } Cache.borders = borders; } + + /** + * The border is only checked every tick-interval-ticks, so a lethal band narrower than + * one check's worth of travel can be flown across without ever being seen inside it. + */ + private void warnIfBufferCanBeCrossed(Region region) { + if (region.bufferWidth == Region.ENDLESS_BUFFER) { + return; + } + if (region.lethalDepth == Region.NO_LETHAL_DEPTH) { + WorldBorder.plugin.getLogger().warning( + "Border for world '" + region.world + "' has buffer-width " + region.bufferWidth + + " but no lethal-depth: anyone who survives the damage can cross the buffer."); + return; + } + if (region.lethalDepth > region.bufferWidth) { + WorldBorder.plugin.getLogger().warning( + "Border for world '" + region.world + "' has lethal-depth " + region.lethalDepth + + " past buffer-width " + region.bufferWidth + + ", so there is no lethal band: anyone who survives the damage can cross the buffer."); + return; + } + double minimum = Cache.maxTravelSpeed * Cache.tickIntervalTicks / 20.0; + if (region.lethalBandWidth() < minimum) { + WorldBorder.plugin.getLogger().warning( + "Border for world '" + region.world + "' has a lethal band of " + region.lethalBandWidth() + + " blocks (lethal-depth " + region.lethalDepth + " to buffer-width " + region.bufferWidth + + "): at max-travel-speed " + Cache.maxTravelSpeed + " it needs at least " + + minimum + " blocks so no one crosses it between checks."); + } + } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 616f68e..fbfd73d 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -6,14 +6,37 @@ grace-inset: 20 title-warning: "§cTurn back" subtitle-warning: "§cYou feel continuing further would be very dangerous..." +# Fastest a player can travel, in blocks per second. Only used to log a +# warning when a lethal band is too narrow to be noticed between two border +# checks (band < speed * tick-interval-ticks / 20). 350 is roughly the most the +# server's movement check normally accepts from an elytra flight (about 17 blocks +# a tick); real elytra flight is about 35-70 and vehicles about 25. It is a +# guideline, not a hard limit (lag or a client holding back packets can go +# further), so keep the lethal band well above it. +max-travel-speed: 350.0 + # Worlds this plugin applies to. Empty = all worlds. worlds: [] # Per-world square border (block coords, inclusive). # Outside these X/Z bounds: damage. Within grace-inset of edge: warning. +# +# Optional buffer, in blocks past the edge (furthest axis, so bands are square): +# lethal-depth: from this far out, players are killed outright (no armour, +# totem or damage-cancelling plugin saves them). +# Leave out or set negative for no lethal band. +# buffer-width: past this far out nothing happens, so builds out there are +# safe for players teleported to them. Leave out or 0 for no +# outer limit (the border reaches out forever). +# Keep the lethal band (buffer-width - lethal-depth) wide, so no one can fly +# across it between checks. Ender pearls, chorus fruit, Nether portals and End +# gateways can't land a player in the lethal band or past the buffer (unless +# they are already past it); staff and plugin teleports can. borders: # example_world: # min-x: 0 # max-x: 0 # min-z: 0 # max-z: 0 + # lethal-depth: 100 + # buffer-width: 1000 diff --git a/src/test/java/net/tfminecraft/worldborder/WorldBorderTest.java b/src/test/java/net/tfminecraft/worldborder/WorldBorderTest.java index ca46619..e7fedc5 100644 --- a/src/test/java/net/tfminecraft/worldborder/WorldBorderTest.java +++ b/src/test/java/net/tfminecraft/worldborder/WorldBorderTest.java @@ -1,6 +1,13 @@ package net.tfminecraft.worldborder; +import net.tfminecraft.worldborder.border.Region; import net.tfminecraft.worldborder.cache.Cache; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.bukkit.event.player.PlayerRespawnEvent.RespawnReason; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -58,6 +65,22 @@ void enablesWithBundledConfigAndCommand() { server.getScheduler().performTicks(10); } + @Test + void registeredRespawnGuardMovesLethalRespawnsToTheWorldSpawn() { + World world = server.addSimpleWorld("map"); + world.setSpawnLocation(50, 64, 50); + Cache.borders.put("map", new Region("map", 0, 100, 0, 100, 50, 500)); + Player player = server.addPlayer(); + player.setGameMode(GameMode.SURVIVAL); + PlayerRespawnEvent event = new PlayerRespawnEvent( + player, new Location(world, 300, 64, 50), false, false, false, + RespawnReason.DEATH); + + server.getPluginManager().callEvent(event); + + assertEquals(world.getSpawnLocation(), event.getRespawnLocation()); + } + @Test void reloadsExistingConfigWithoutReplacingIt() throws Exception { Path config = plugin.getDataFolder().toPath().resolve("config.yml"); diff --git a/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java b/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java index a3f5ede..d1f7170 100644 --- a/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java +++ b/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java @@ -6,7 +6,9 @@ import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -263,6 +265,86 @@ void deadPlayersAreSkippedWithoutBorderChecksOrDamage() { verify(alive).damage(3.5); } + @Test + void lethalBandKillsOutrightAndClearsTheWarning() { + Cache.borders.put("overworld", new Region("overworld", 0, 100, 0, 100, 50, 500)); + Player player = playerAt(5, 50); + tick(); + + move(player, 120, 50); + tick(); + verify(player).damage(3.5); + verify(player, never()).setHealth(anyDouble()); + + doAnswer(invocation -> { + when(player.isDead()).thenReturn(true); + return null; + }).when(player).setHealth(0); + move(player, 150, 50); + tick(); + verify(player).setHealth(0); + verify(player, times(1)).damage(anyDouble()); + verify(player, times(1)).resetTitle(); + verify(player, never()).teleport(any(Location.class)); + } + + @Test + void cancelledDeathInTheLethalBandMovesThePlayerToTheWorldSpawn() { + Cache.borders.put("overworld", new Region("overworld", 0, 100, 0, 100, 50, 500)); + Location spawn = new Location(world, 50, 64, 50); + when(world.getSpawnLocation()).thenReturn(spawn); + Player player = playerAt(150, 50); + when(player.getName()).thenReturn("Revived"); + when(player.teleport(spawn)).thenReturn(true); + + tick(); + + verify(player).setHealth(0); + verify(player).teleport(spawn); + verify(plugin.getLogger()).info( + "Moved Revived out of the lethal band to the spawn of overworld after their death was cancelled."); + } + + @Test + void failedMoveAfterACancelledDeathIsReportedNotClaimed() { + Cache.borders.put("overworld", new Region("overworld", 0, 100, 0, 100, 50, 500)); + when(world.getSpawnLocation()).thenReturn(new Location(world, 50, 64, 50)); + Player player = playerAt(150, 50); + when(player.getName()).thenReturn("Stuck"); + + tick(); + + verify(plugin.getLogger()).warning("Could not move Stuck out of the lethal band after their death was cancelled."); + verify(plugin.getLogger(), never()).info(contains("Moved Stuck")); + } + + @Test + void beyondTheBufferNothingHappensAndAnExistingWarningIsCleared() { + Cache.borders.put("overworld", new Region("overworld", 0, 100, 0, 100, 50, 500)); + Player player = playerAt(5, 50); + tick(); + + move(player, 50, 700); + tick(); + tick(); + verify(player, times(1)).resetTitle(); + verify(player, never()).damage(anyDouble()); + verify(player, never()).setHealth(anyDouble()); + } + + @Test + void exemptPlayersAreNeverKilledInTheLethalBand() { + Cache.borders.put("overworld", new Region("overworld", 0, 100, 0, 100, 0, 500)); + Player bypass = playerAt(200, 50); + when(bypass.hasPermission("worldborder.bypass")).thenReturn(true); + Player creative = playerAt(200, 50); + when(creative.getGameMode()).thenReturn(GameMode.CREATIVE); + + tick(); + verify(bypass, never()).setHealth(anyDouble()); + verify(creative, never()).setHealth(anyDouble()); + } + private Player playerAt(double x, double z) { Player player = mock(Player.class); UUID id = UUID.randomUUID(); diff --git a/src/test/java/net/tfminecraft/worldborder/border/RegionTest.java b/src/test/java/net/tfminecraft/worldborder/border/RegionTest.java index e1d6d2f..6f33836 100644 --- a/src/test/java/net/tfminecraft/worldborder/border/RegionTest.java +++ b/src/test/java/net/tfminecraft/worldborder/border/RegionTest.java @@ -18,7 +18,9 @@ void retainsWorldAndAsymmetricBounds() { () -> assertEquals(-12, region.minX), () -> assertEquals(23, region.maxX), () -> assertEquals(-31, region.minZ), - () -> assertEquals(47, region.maxZ)); + () -> assertEquals(47, region.maxZ), + () -> assertEquals(Region.NO_LETHAL_DEPTH, region.lethalDepth), + () -> assertEquals(Region.ENDLESS_BUFFER, region.bufferWidth)); } @ParameterizedTest(name = "({0}, {1}), inset {2}: {3}") @@ -68,4 +70,54 @@ void supportsIntegerExtremeBoundsWithoutOverflow() { assertEquals(Region.Zone.WARNING, enormous.zoneAt(Integer.MAX_VALUE, 0, 20)); assertEquals(Region.Zone.OUTSIDE, enormous.zoneAt((double) Integer.MAX_VALUE + 1, 0, 20)); } + + @ParameterizedTest(name = "({0}, {1}): {2}") + @CsvSource({ + "0, 0, SAFE", + "-12, 0, WARNING", + "-12.01, 0, OUTSIDE", + "-21.99, 0, OUTSIDE", + "-22, 0, LETHAL", + "33, 0, LETHAL", + "0, 57, LETHAL", + "-62, 0, LETHAL", + "-62.01, 0, BEYOND", + "73.01, 0, BEYOND", + "0, -81.01, BEYOND", + "0, 97.01, BEYOND", + "-40, 97.01, BEYOND", + "-30, 60, LETHAL", + "-13, 48, OUTSIDE" + }) + void bufferBandsAreMeasuredAlongTheFurthestAxis(double x, double z, Region.Zone expected) { + Region buffered = new Region("overworld", -12, 23, -31, 47, 10, 50); + assertEquals(expected, buffered.zoneAt(x, z, 3)); + } + + @Test + void lethalDepthWithoutBufferWidthKillsForever() { + Region endless = new Region("overworld", 0, 10, 0, 10, 0, Region.ENDLESS_BUFFER); + assertEquals(Region.Zone.WARNING, endless.zoneAt(10, 5, 0)); + assertEquals(Region.Zone.LETHAL, endless.zoneAt(10.01, 5, 0)); + assertEquals(Region.Zone.LETHAL, endless.zoneAt(1_000_000, 5, 0)); + assertEquals(Double.POSITIVE_INFINITY, endless.lethalBandWidth()); + } + + @Test + void bufferWidthWithoutLethalDepthOnlyDamagesUpToTheLimit() { + Region damaging = new Region("overworld", 0, 10, 0, 10, Region.NO_LETHAL_DEPTH, 100); + assertEquals(Region.Zone.OUTSIDE, damaging.zoneAt(110, 5, 0)); + assertEquals(Region.Zone.BEYOND, damaging.zoneAt(110.01, 5, 0)); + assertEquals(0, damaging.lethalBandWidth()); + } + + @Test + void lethalBandWidthIsTheGapBetweenDepthAndBufferAndNeverNegative() { + assertEquals(900, new Region("w", 0, 10, 0, 10, 100, 1000).lethalBandWidth()); + assertEquals(0, new Region("w", 0, 10, 0, 10, 100, 100).lethalBandWidth()); + Region inverted = new Region("w", 0, 10, 0, 10, 200, 100); + assertEquals(0, inverted.lethalBandWidth()); + assertEquals(Region.Zone.OUTSIDE, inverted.zoneAt(110, 5, 0)); + assertEquals(Region.Zone.BEYOND, inverted.zoneAt(110.01, 5, 0)); + } } diff --git a/src/test/java/net/tfminecraft/worldborder/border/RespawnGuardTest.java b/src/test/java/net/tfminecraft/worldborder/border/RespawnGuardTest.java new file mode 100644 index 0000000..8b7c49b --- /dev/null +++ b/src/test/java/net/tfminecraft/worldborder/border/RespawnGuardTest.java @@ -0,0 +1,149 @@ +package net.tfminecraft.worldborder.border; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import net.tfminecraft.worldborder.WorldBorder; +import net.tfminecraft.worldborder.cache.Cache; + +class RespawnGuardTest { + + private final WorldBorder savedPlugin = WorldBorder.plugin; + private final int savedInset = Cache.graceInset; + private final List savedWorlds = Cache.worlds; + private final Map savedBorders = Cache.borders; + + private final RespawnGuard guard = new RespawnGuard(); + private Logger logger; + private World world; + private Location spawn; + private Player player; + + @BeforeEach + void setUp() { + WorldBorder.plugin = mock(WorldBorder.class); + logger = mock(Logger.class); + when(WorldBorder.plugin.getLogger()).thenReturn(logger); + Cache.graceInset = 10; + Cache.worlds = new ArrayList<>(); + Cache.borders = new LinkedHashMap<>(); + Cache.borders.put("map", new Region("map", 0, 100, 0, 100, 50, 500)); + world = mock(World.class); + when(world.getName()).thenReturn("map"); + spawn = new Location(world, 50, 64, 50); + when(world.getSpawnLocation()).thenReturn(spawn); + player = mock(Player.class); + when(player.getName()).thenReturn("Wanderer"); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + } + + @AfterEach + void tearDown() { + WorldBorder.plugin = savedPlugin; + Cache.graceInset = savedInset; + Cache.worlds = savedWorlds; + Cache.borders = savedBorders; + } + + private PlayerRespawnEvent respawnAt(Location location) { + PlayerRespawnEvent event = mock(PlayerRespawnEvent.class); + when(event.getPlayer()).thenReturn(player); + when(event.getRespawnLocation()).thenReturn(location); + return event; + } + + @Test + void respawnInTheLethalBandMovesToTheWorldSpawn() { + PlayerRespawnEvent event = respawnAt(new Location(world, 300, 64, 50)); + + guard.onRespawn(event); + + verify(event).setRespawnLocation(spawn); + verify(logger).info("Moved Wanderer's respawn out of the lethal band (300, 50) to the spawn of map."); + } + + @Test + void lethalWorldSpawnFallsBackToTheMiddleOfTheBorder() { + when(world.getSpawnLocation()).thenReturn(new Location(world, 300, 64, 50)); + when(world.getHighestBlockYAt(50, 50)).thenReturn(70); + PlayerRespawnEvent event = respawnAt(new Location(world, 300, 64, 50)); + + guard.onRespawn(event); + + verify(event).setRespawnLocation(new Location(world, 50.5, 71, 50.5)); + verify(logger).info("Moved Wanderer's respawn out of the lethal band (300, 50) to the centre of the map border."); + } + + @Test + void middleOfAnExtremeBorderDoesNotOverflow() { + Cache.borders.put("map", new Region("map", Integer.MAX_VALUE - 10, Integer.MAX_VALUE, + Integer.MAX_VALUE - 10, Integer.MAX_VALUE, 0, Region.ENDLESS_BUFFER)); + when(world.getSpawnLocation()).thenReturn(new Location(world, 0, 64, 0)); + int middle = Integer.MAX_VALUE - 5; + when(world.getHighestBlockYAt(middle, middle)).thenReturn(10); + PlayerRespawnEvent event = respawnAt(new Location(world, 0, 64, 0)); + + guard.onRespawn(event); + + verify(event).setRespawnLocation(new Location(world, middle + 0.5, 11, middle + 0.5)); + } + + @ParameterizedTest + @ValueSource(doubles = {50, 5, 120, 700}) + void respawnOutsideTheLethalBandIsLeftAlone(double x) { + PlayerRespawnEvent event = respawnAt(new Location(world, x, 64, 50)); + + guard.onRespawn(event); + + verify(event, never()).setRespawnLocation(any()); + verify(logger, never()).info(anyString()); + } + + @Test + void exemptPlayersKeepTheirRespawn() { + when(player.getGameMode()).thenReturn(GameMode.CREATIVE); + PlayerRespawnEvent creative = respawnAt(new Location(world, 300, 64, 50)); + guard.onRespawn(creative); + verify(creative, never()).setRespawnLocation(any()); + + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + when(player.hasPermission("worldborder.bypass")).thenReturn(true); + PlayerRespawnEvent bypassed = respawnAt(new Location(world, 300, 64, 50)); + guard.onRespawn(bypassed); + verify(bypassed, never()).setRespawnLocation(any()); + } + + @Test + void worldsWithoutABorderOrWithoutAWorldAreLeftAlone() { + World other = mock(World.class); + when(other.getName()).thenReturn("elsewhere"); + PlayerRespawnEvent unbordered = respawnAt(new Location(other, 300, 64, 50)); + guard.onRespawn(unbordered); + verify(unbordered, never()).setRespawnLocation(any()); + + PlayerRespawnEvent worldless = respawnAt(new Location(null, 300, 64, 50)); + guard.onRespawn(worldless); + verify(worldless, never()).setRespawnLocation(any()); + } +} diff --git a/src/test/java/net/tfminecraft/worldborder/border/TeleportGuardTest.java b/src/test/java/net/tfminecraft/worldborder/border/TeleportGuardTest.java new file mode 100644 index 0000000..e0a4bd1 --- /dev/null +++ b/src/test/java/net/tfminecraft/worldborder/border/TeleportGuardTest.java @@ -0,0 +1,160 @@ +package net.tfminecraft.worldborder.border; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Logger; + +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.player.PlayerPortalEvent; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.bukkit.event.player.PlayerTeleportEvent.TeleportCause; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import net.tfminecraft.worldborder.WorldBorder; +import net.tfminecraft.worldborder.cache.Cache; + +class TeleportGuardTest { + + private final WorldBorder savedPlugin = WorldBorder.plugin; + private final int savedInset = Cache.graceInset; + private final List savedWorlds = Cache.worlds; + private final Map savedBorders = Cache.borders; + + private final TeleportGuard guard = new TeleportGuard(); + private Logger logger; + private World world; + private Player player; + + @BeforeEach + void setUp() { + WorldBorder.plugin = mock(WorldBorder.class); + logger = mock(Logger.class); + when(WorldBorder.plugin.getLogger()).thenReturn(logger); + Cache.graceInset = 10; + Cache.worlds = new ArrayList<>(); + Cache.borders = new LinkedHashMap<>(); + // Lethal from 50 to 500 blocks past x/z 0-100. + Cache.borders.put("map", new Region("map", 0, 100, 0, 100, 50, 500)); + world = mock(World.class); + when(world.getName()).thenReturn("map"); + player = mock(Player.class); + when(player.getName()).thenReturn("Jumper"); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + } + + @AfterEach + void tearDown() { + WorldBorder.plugin = savedPlugin; + Cache.graceInset = savedInset; + Cache.worlds = savedWorlds; + Cache.borders = savedBorders; + } + + private PlayerTeleportEvent teleport(double fromX, double toX, TeleportCause cause) { + return new PlayerTeleportEvent(player, new Location(world, fromX, 64, 50), + new Location(world, toX, 64, 50), cause); + } + + @ParameterizedTest + @EnumSource(value = TeleportCause.class, + names = {"ENDER_PEARL", "CONSUMABLE_EFFECT", "NETHER_PORTAL", "END_GATEWAY"}) + void playerTeleportsCannotLandInTheLethalBandOrPastTheBuffer(TeleportCause cause) { + PlayerTeleportEvent intoBand = teleport(50, 300, cause); + guard.onTeleport(intoBand); + assertTrue(intoBand.isCancelled()); + + PlayerTeleportEvent overBand = teleport(50, 2000, cause); + guard.onTeleport(overBand); + assertTrue(overBand.isCancelled()); + verify(logger).info("Stopped Jumper's " + cause + " teleport to BEYOND (2000, 50) in map."); + } + + @Test + void portalEventsAreCheckedToo() { + PlayerPortalEvent portal = new PlayerPortalEvent(player, new Location(world, 50, 64, 50), + new Location(world, 2000, 64, 50), TeleportCause.NETHER_PORTAL); + guard.onPortal(portal); + assertTrue(portal.isCancelled()); + } + + @ParameterizedTest + @EnumSource(value = TeleportCause.class, + names = {"COMMAND", "PLUGIN", "SPECTATE", "DISMOUNT", "EXIT_BED", "UNKNOWN", "END_PORTAL"}) + void staffPluginAndEndExitTeleportsReachFarBuilds(TeleportCause cause) { + PlayerTeleportEvent event = teleport(50, 2000, cause); + guard.onTeleport(event); + assertFalse(event.isCancelled()); + } + + @Test + void pearlsInsideTheMapOrIntoTheDamageBandAreAllowed() { + PlayerTeleportEvent inside = teleport(50, 60, TeleportCause.ENDER_PEARL); + guard.onTeleport(inside); + assertFalse(inside.isCancelled()); + + PlayerTeleportEvent damageBand = teleport(90, 130, TeleportCause.ENDER_PEARL); + guard.onTeleport(damageBand); + assertFalse(damageBand.isCancelled()); + verify(logger, never()).info(anyString()); + } + + @Test + void playersAlreadyPastTheBufferCanPearlAroundThere() { + PlayerTeleportEvent aroundBuild = teleport(2000, 2030, TeleportCause.ENDER_PEARL); + guard.onTeleport(aroundBuild); + assertFalse(aroundBuild.isCancelled()); + } + + @Test + void comingFromAnotherWorldStillCounts() { + World other = mock(World.class); + when(other.getName()).thenReturn("nether"); + PlayerTeleportEvent portal = new PlayerTeleportEvent(player, new Location(other, 2000, 64, 50), + new Location(world, 2000, 64, 50), TeleportCause.NETHER_PORTAL); + guard.onTeleport(portal); + assertTrue(portal.isCancelled()); + } + + @Test + void exemptPlayersAndUnborderedOrMissingDestinationsAreLeftAlone() { + when(player.getGameMode()).thenReturn(GameMode.CREATIVE); + PlayerTeleportEvent creative = teleport(50, 2000, TeleportCause.ENDER_PEARL); + guard.onTeleport(creative); + assertFalse(creative.isCancelled()); + when(player.getGameMode()).thenReturn(GameMode.SURVIVAL); + + World other = mock(World.class); + when(other.getName()).thenReturn("elsewhere"); + PlayerTeleportEvent unbordered = new PlayerTeleportEvent(player, new Location(world, 50, 64, 50), + new Location(other, 2000, 64, 50), TeleportCause.ENDER_PEARL); + guard.onTeleport(unbordered); + assertFalse(unbordered.isCancelled()); + + PlayerTeleportEvent worldless = new PlayerTeleportEvent(player, new Location(world, 50, 64, 50), + new Location(null, 2000, 64, 50), TeleportCause.ENDER_PEARL); + guard.onTeleport(worldless); + assertFalse(worldless.isCancelled()); + + PlayerPortalEvent nowhere = new PlayerPortalEvent(player, new Location(world, 50, 64, 50), null, + TeleportCause.NETHER_PORTAL); + guard.onPortal(nowhere); + assertFalse(nowhere.isCancelled()); + } +} diff --git a/src/test/java/net/tfminecraft/worldborder/command/CommandManagerTest.java b/src/test/java/net/tfminecraft/worldborder/command/CommandManagerTest.java index 20795ff..91dd639 100644 --- a/src/test/java/net/tfminecraft/worldborder/command/CommandManagerTest.java +++ b/src/test/java/net/tfminecraft/worldborder/command/CommandManagerTest.java @@ -193,6 +193,41 @@ void debugReportsTheCurrentZoneAndBorder(double x, double z, String zone, String messages.verify(player).sendMessage("§7Position: §f" + position); messages.verify(player).sendMessage("§7Zone: " + zone); messages.verify(player).sendMessage("§7Border: §fx -100-100, z -100-100"); + messages.verify(player).sendMessage("§7Buffer: §fno lethal band, no outer limit"); + } + + @ParameterizedTest + @CsvSource({ + "200, 100, 'no lethal band, ends 100 blocks out'", + "100, 100, 'lethal from 100 blocks out, ends 100 blocks out'", + "0, 0, 'lethal from 0 blocks out, no outer limit'" + }) + void debugOnlyDescribesALethalBandThatCanHappen(int lethalDepth, int bufferWidth, String buffer) { + Cache.borders.put("world", new Region("world", -100, 100, -100, 100, lethalDepth, bufferWidth)); + Player player = player("world", 0, 0); + + assertTrue(execute(player, "debug")); + + verify(player).sendMessage("§7Buffer: §f" + buffer); + } + + @ParameterizedTest + @CsvSource({ + "150, '§cOUTSIDE'", + "160, '§4LETHAL'", + "300, '§4LETHAL'", + "300.5, '§bBEYOND'" + }) + void debugReportsTheBufferBands(double x, String zone) { + Cache.borders.put("world", new Region("world", -100, 100, -100, 100, 60, 200)); + Player player = player("world", x, 0); + + assertTrue(execute(player, "debug")); + + InOrder messages = inOrder(player); + messages.verify(player).sendMessage("§7Zone: " + zone); + messages.verify(player).sendMessage("§7Border: §fx -100-100, z -100-100"); + messages.verify(player).sendMessage("§7Buffer: §flethal from 60 blocks out, ends 200 blocks out"); } @Test diff --git a/src/test/java/net/tfminecraft/worldborder/loader/ConfigLoaderTest.java b/src/test/java/net/tfminecraft/worldborder/loader/ConfigLoaderTest.java index 2547292..cfbacf9 100644 --- a/src/test/java/net/tfminecraft/worldborder/loader/ConfigLoaderTest.java +++ b/src/test/java/net/tfminecraft/worldborder/loader/ConfigLoaderTest.java @@ -29,6 +29,7 @@ class ConfigLoaderTest { private int previousInset; private String previousTitle; private String previousSubtitle; + private double previousMaxTravelSpeed; private List previousWorlds; private java.util.Map previousBorders; @@ -40,6 +41,7 @@ void setUp() { previousInset = Cache.graceInset; previousTitle = Cache.titleWarning; previousSubtitle = Cache.subtitleWarning; + previousMaxTravelSpeed = Cache.maxTravelSpeed; previousWorlds = Cache.worlds; previousBorders = Cache.borders; WorldBorder.plugin = mock(WorldBorder.class); @@ -60,6 +62,7 @@ void tearDown() { Cache.graceInset = previousInset; Cache.titleWarning = previousTitle; Cache.subtitleWarning = previousSubtitle; + Cache.maxTravelSpeed = previousMaxTravelSpeed; Cache.worlds = previousWorlds; Cache.borders = previousBorders; } @@ -78,6 +81,7 @@ void absentSettingsUseDefaultsAndRemovePreviousWorlds() throws Exception { assertEquals(20, Cache.graceInset); assertEquals("default title", Cache.titleWarning); assertEquals("default subtitle", Cache.subtitleWarning); + assertEquals(350.0, Cache.maxTravelSpeed); assertTrue(Cache.worlds.isEmpty()); assertTrue(Cache.borders.isEmpty()); } @@ -108,6 +112,8 @@ void loadsScalarsWorldFilterAndInclusiveCoordinates() throws Exception { assertEquals(20, border.maxX); assertEquals(-30, border.minZ); assertEquals(40, border.maxZ); + assertEquals(Region.NO_LETHAL_DEPTH, border.lethalDepth); + assertEquals(Region.ENDLESS_BUFFER, border.bufferWidth); Region point = Cache.borders.get("point"); assertEquals(Region.Zone.WARNING, point.zoneAt(1, 2, 0)); verifyNoInteractions(logger); @@ -132,6 +138,66 @@ void clampsNegativeSettingsAndSkipsEachInvertedAxis() throws Exception { verify(logger).warning(contains("world 'bad-z'")); } + @Test + void loadsBufferSettingsPerWorld() throws Exception { + load(""" + tick-interval-ticks: 20 + max-travel-speed: 50 + borders: + map: {min-x: 200, max-x: 6200, min-z: 200, max-z: 6200, lethal-depth: 100, buffer-width: 1000} + endless: {min-x: 0, max-x: 1, min-z: 0, max-z: 1, lethal-depth: 0} + """); + assertEquals(50.0, Cache.maxTravelSpeed); + Region map = Cache.borders.get("map"); + assertEquals(100, map.lethalDepth); + assertEquals(1000, map.bufferWidth); + Region endless = Cache.borders.get("endless"); + assertEquals(0, endless.lethalDepth); + assertEquals(Region.ENDLESS_BUFFER, endless.bufferWidth); + verifyNoInteractions(logger); + } + + @Test + void clampsNegativeBufferSettingsToDisabled() throws Exception { + load(""" + max-travel-speed: -5 + borders: + map: {min-x: 0, max-x: 1, min-z: 0, max-z: 1, lethal-depth: -7, buffer-width: -3} + """); + assertEquals(0.0, Cache.maxTravelSpeed); + Region map = Cache.borders.get("map"); + assertEquals(Region.NO_LETHAL_DEPTH, map.lethalDepth); + assertEquals(Region.ENDLESS_BUFFER, map.bufferWidth); + verifyNoInteractions(logger); + } + + @Test + void warnsWhenTheBufferCanBeCrossed() throws Exception { + load(""" + tick-interval-ticks: 20 + max-travel-speed: 100 + borders: + no-lethal: {min-x: 0, max-x: 1, min-z: 0, max-z: 1, buffer-width: 500} + narrow: {min-x: 0, max-x: 1, min-z: 0, max-z: 1, lethal-depth: 450, buffer-width: 500} + exact: {min-x: 0, max-x: 1, min-z: 0, max-z: 1, lethal-depth: 400, buffer-width: 500} + """); + assertEquals(List.of("no-lethal", "narrow", "exact"), new ArrayList<>(Cache.borders.keySet())); + verify(logger).warning(contains("world 'no-lethal' has buffer-width 500 but no lethal-depth")); + verify(logger).warning(contains("world 'narrow' has a lethal band of 50.0 blocks")); + verify(logger).warning(contains("it needs at least 100.0 blocks")); + verify(logger, never()).warning(contains("'exact'")); + } + + @Test + void warnsWhenLethalDepthIsPastBufferWidthEvenWithoutASpeed() throws Exception { + load(""" + max-travel-speed: 0 + borders: + inverted: {min-x: 0, max-x: 1, min-z: 0, max-z: 1, lethal-depth: 200, buffer-width: 100} + """); + verify(logger).warning(contains("world 'inverted' has lethal-depth 200 past buffer-width 100")); + } + @Test void missingFileLeavesActiveConfigurationUntouched() { Cache.tickIntervalTicks = 37;