From 80ec671318731ba147747b20ac5a8fbdc5ba5c3d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:20:37 +0100 Subject: [PATCH 1/6] Add a lethal buffer so builds far past the border are safe Past lethal-depth blocks outside the border, players are killed outright (health set to zero, so armour, totems and damage-cancelling plugins can't carry them across). Past buffer-width blocks nothing happens, so builds out there are safe for players teleported to them. Both are per world and off by default, so existing configs behave as before. The loader warns when the lethal band is narrow enough to cross between two checks at max-travel-speed. Co-Authored-By: Claude Opus 5.5 --- README.md | 1 + .../worldborder/border/BorderManager.java | 8 ++- .../worldborder/border/Region.java | 49 +++++++++++++++- .../tfminecraft/worldborder/cache/Cache.java | 5 ++ .../worldborder/command/CommandManager.java | 13 +++++ .../worldborder/loader/ConfigLoader.java | 35 +++++++++++- src/main/resources/config.yml | 17 ++++++ .../worldborder/border/BorderManagerTest.java | 45 +++++++++++++++ .../worldborder/border/RegionTest.java | 54 +++++++++++++++++- .../command/CommandManagerTest.java | 20 +++++++ .../worldborder/loader/ConfigLoaderTest.java | 56 +++++++++++++++++++ 11 files changed, 298 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8529fba..541751d 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. - **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/border/BorderManager.java b/src/main/java/net/tfminecraft/worldborder/border/BorderManager.java index 27e4455..4520ef3 100644 --- a/src/main/java/net/tfminecraft/worldborder/border/BorderManager.java +++ b/src/main/java/net/tfminecraft/worldborder/border/BorderManager.java @@ -80,7 +80,13 @@ 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); + } + case SAFE, BEYOND -> clearWarning(player); } } } 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/cache/Cache.java b/src/main/java/net/tfminecraft/worldborder/cache/Cache.java index 86a76f8..7bb0fc9 100644 --- a/src/main/java/net/tfminecraft/worldborder/cache/Cache.java +++ b/src/main/java/net/tfminecraft/worldborder/cache/Cache.java @@ -19,6 +19,11 @@ 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. + */ + public static double maxTravelSpeed = 100.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..b52b5cb 100644 --- a/src/main/java/net/tfminecraft/worldborder/command/CommandManager.java +++ b/src/main/java/net/tfminecraft/worldborder/command/CommandManager.java @@ -68,6 +68,17 @@ 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) { + String lethal = region.lethalDepth == Region.NO_LETHAL_DEPTH + ? "no lethal band" + : "lethal from " + region.lethalDepth + " blocks out"; + 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 +93,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..9f297bd 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", 100.0)); Cache.worlds = new java.util.ArrayList<>(config.getStringList("worlds")); @@ -48,9 +49,41 @@ 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; + } + 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..2c9a8f1 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -6,14 +6,31 @@ grace-inset: 20 title-warning: "§cTurn back" subtitle-warning: "§cYou feel continuing further would be very dangerous..." +# Fastest a player can travel (elytra, vehicles), 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). +max-travel-speed: 100.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. 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/border/BorderManagerTest.java b/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java index a3f5ede..6d96d37 100644 --- a/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java +++ b/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java @@ -263,6 +263,51 @@ 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()); + + move(player, 150, 50); + tick(); + verify(player).setHealth(0); + verify(player, times(1)).damage(anyDouble()); + verify(player, times(1)).resetTitle(); + } + + @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/command/CommandManagerTest.java b/src/test/java/net/tfminecraft/worldborder/command/CommandManagerTest.java index 20795ff..a4eb94f 100644 --- a/src/test/java/net/tfminecraft/worldborder/command/CommandManagerTest.java +++ b/src/test/java/net/tfminecraft/worldborder/command/CommandManagerTest.java @@ -193,6 +193,26 @@ 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({ + "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..7478cd0 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(100.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,56 @@ 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 missingFileLeavesActiveConfigurationUntouched() { Cache.tickIntervalTicks = 37; From 841cb907bd946bd1e5dd2ea350625ba266b741a5 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:30:16 +0100 Subject: [PATCH 2/6] Default max-travel-speed to the server's elytra movement limit Dev testing showed Paper rejects moves above roughly 17 blocks a tick for elytra flight, so 350 blocks a second is about the fastest anyone can get across a lethal band. 100 understated what a cheating client can do. Co-Authored-By: Claude Opus 5.5 --- .../java/net/tfminecraft/worldborder/cache/Cache.java | 6 ++++-- .../tfminecraft/worldborder/loader/ConfigLoader.java | 2 +- src/main/resources/config.yml | 10 ++++++---- .../worldborder/loader/ConfigLoaderTest.java | 2 +- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/tfminecraft/worldborder/cache/Cache.java b/src/main/java/net/tfminecraft/worldborder/cache/Cache.java index 7bb0fc9..4af6b26 100644 --- a/src/main/java/net/tfminecraft/worldborder/cache/Cache.java +++ b/src/main/java/net/tfminecraft/worldborder/cache/Cache.java @@ -21,9 +21,11 @@ private Cache() {} 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. + * lethal band is narrow enough to cross between two border checks. The default + * is roughly the most the server's movement check accepts from an elytra flight + * (about 17 blocks a tick), well above anything legitimate. */ - public static double maxTravelSpeed = 100.0; + 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/loader/ConfigLoader.java b/src/main/java/net/tfminecraft/worldborder/loader/ConfigLoader.java index 9f297bd..748425a 100644 --- a/src/main/java/net/tfminecraft/worldborder/loader/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/worldborder/loader/ConfigLoader.java @@ -29,7 +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", 100.0)); + Cache.maxTravelSpeed = Math.max(0.0, config.getDouble("max-travel-speed", 350.0)); Cache.worlds = new java.util.ArrayList<>(config.getStringList("worlds")); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 2c9a8f1..12859e2 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -6,10 +6,12 @@ grace-inset: 20 title-warning: "§cTurn back" subtitle-warning: "§cYou feel continuing further would be very dangerous..." -# Fastest a player can travel (elytra, vehicles), 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). -max-travel-speed: 100.0 +# 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 accepts from an elytra flight (about 17 blocks a tick); +# real elytra flight is about 35-70 and vehicles about 25. +max-travel-speed: 350.0 # Worlds this plugin applies to. Empty = all worlds. worlds: [] diff --git a/src/test/java/net/tfminecraft/worldborder/loader/ConfigLoaderTest.java b/src/test/java/net/tfminecraft/worldborder/loader/ConfigLoaderTest.java index 7478cd0..2702718 100644 --- a/src/test/java/net/tfminecraft/worldborder/loader/ConfigLoaderTest.java +++ b/src/test/java/net/tfminecraft/worldborder/loader/ConfigLoaderTest.java @@ -81,7 +81,7 @@ void absentSettingsUseDefaultsAndRemovePreviousWorlds() throws Exception { assertEquals(20, Cache.graceInset); assertEquals("default title", Cache.titleWarning); assertEquals("default subtitle", Cache.subtitleWarning); - assertEquals(100.0, Cache.maxTravelSpeed); + assertEquals(350.0, Cache.maxTravelSpeed); assertTrue(Cache.worlds.isEmpty()); assertTrue(Cache.borders.isEmpty()); } From 478ad906424ebaabb286ff322631821d2017ce32 Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:05:33 +0100 Subject: [PATCH 3/6] Send respawns in the lethal band to the world spawn A bed or anchor inside the lethal band would kill the player again on every border check. Before the lethal band, that spot only took regular damage, so a player could walk out; now they would be stuck in a death loop. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../tfminecraft/worldborder/WorldBorder.java | 2 + .../worldborder/border/BorderManager.java | 2 +- .../worldborder/border/RespawnGuard.java | 36 +++++ .../worldborder/border/RespawnGuardTest.java | 123 ++++++++++++++++++ 5 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 src/main/java/net/tfminecraft/worldborder/border/RespawnGuard.java create mode 100644 src/test/java/net/tfminecraft/worldborder/border/RespawnGuardTest.java diff --git a/README.md b/README.md index 541751d..4468cd0 100644 --- a/README.md +++ b/README.md @@ -11,7 +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. +- **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. Respawn points inside the lethal band send players to the world spawn instead. - **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..5642f10 100644 --- a/src/main/java/net/tfminecraft/worldborder/WorldBorder.java +++ b/src/main/java/net/tfminecraft/worldborder/WorldBorder.java @@ -9,6 +9,7 @@ import net.tfminecraft.worldborder.cache.Cache; import net.tfminecraft.worldborder.border.BorderManager; +import net.tfminecraft.worldborder.border.RespawnGuard; import net.tfminecraft.worldborder.command.CommandManager; import net.tfminecraft.worldborder.loader.ConfigLoader; @@ -33,6 +34,7 @@ public void onEnable() { } else { getLogger().severe("Command 'worldborder' missing from plugin.yml"); } + getServer().getPluginManager().registerEvents(new RespawnGuard(), 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 4520ef3..e2d3bda 100644 --- a/src/main/java/net/tfminecraft/worldborder/border/BorderManager.java +++ b/src/main/java/net/tfminecraft/worldborder/border/BorderManager.java @@ -108,7 +108,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/RespawnGuard.java b/src/main/java/net/tfminecraft/worldborder/border/RespawnGuard.java new file mode 100644 index 0000000..5380d05 --- /dev/null +++ b/src/main/java/net/tfminecraft/worldborder/border/RespawnGuard.java @@ -0,0 +1,36 @@ +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; + } + event.setRespawnLocation(world.getSpawnLocation()); + WorldBorder.plugin.getLogger().info("Moved " + event.getPlayer().getName() + + "'s respawn out of the lethal band (" + to.getBlockX() + ", " + to.getBlockZ() + + ") to the spawn of " + world.getName() + "."); + } +} 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..19cd8c8 --- /dev/null +++ b/src/test/java/net/tfminecraft/worldborder/border/RespawnGuardTest.java @@ -0,0 +1,123 @@ +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."); + } + + @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()); + } +} From ca2a0cdcc0caf1c922203f87203a049d0a26eb0c Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:25:27 +0100 Subject: [PATCH 4/6] Address review: debug band text, lethal world spawn, README caveat - /worldborder debug only describes a lethal band that can happen, so a lethal-depth past buffer-width shows "no lethal band". - If the world spawn is itself in the lethal band, the respawn guard uses the middle of the border instead (long maths, so extreme bounds can't overflow). - The README states the lethal band has to be wider than one check's worth of travel. - A test sends a respawn through the registered listener. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../worldborder/border/RespawnGuard.java | 13 ++++++++-- .../worldborder/command/CommandManager.java | 8 +++--- .../worldborder/WorldBorderTest.java | 23 ++++++++++++++++ .../worldborder/border/RespawnGuardTest.java | 26 +++++++++++++++++++ .../command/CommandManagerTest.java | 15 +++++++++++ 6 files changed, 81 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 4468cd0..f6e3bee 100644 --- a/README.md +++ b/README.md @@ -11,7 +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. Respawn points inside the lethal band send players to the world spawn instead. +- **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. - **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/border/RespawnGuard.java b/src/main/java/net/tfminecraft/worldborder/border/RespawnGuard.java index 5380d05..b94cec8 100644 --- a/src/main/java/net/tfminecraft/worldborder/border/RespawnGuard.java +++ b/src/main/java/net/tfminecraft/worldborder/border/RespawnGuard.java @@ -28,9 +28,18 @@ public void onRespawn(PlayerRespawnEvent event) { if (region == null || region.zoneAt(to.getX(), to.getZ(), Cache.graceInset) != Region.Zone.LETHAL) { return; } - event.setRespawnLocation(world.getSpawnLocation()); + Location safe = world.getSpawnLocation(); + String where = "the spawn of " + world.getName(); + if (region.zoneAt(safe.getX(), safe.getZ(), Cache.graceInset) == Region.Zone.LETHAL) { + // The world spawn is out in the band too, so use the middle of the border. + int x = (int) Math.floorDiv((long) region.minX + region.maxX, 2L); + int z = (int) Math.floorDiv((long) region.minZ + region.maxZ, 2L); + safe = new Location(world, x + 0.5, world.getHighestBlockYAt(x, z) + 1, z + 0.5); + where = "the centre of the " + world.getName() + " border"; + } + event.setRespawnLocation(safe); WorldBorder.plugin.getLogger().info("Moved " + event.getPlayer().getName() + "'s respawn out of the lethal band (" + to.getBlockX() + ", " + to.getBlockZ() - + ") to the spawn of " + world.getName() + "."); + + ") to " + where + "."); } } diff --git a/src/main/java/net/tfminecraft/worldborder/command/CommandManager.java b/src/main/java/net/tfminecraft/worldborder/command/CommandManager.java index b52b5cb..0b42cbf 100644 --- a/src/main/java/net/tfminecraft/worldborder/command/CommandManager.java +++ b/src/main/java/net/tfminecraft/worldborder/command/CommandManager.java @@ -72,9 +72,11 @@ private void sendDebug(Player player) { } private static String describeBuffer(Region region) { - String lethal = region.lethalDepth == Region.NO_LETHAL_DEPTH - ? "no lethal band" - : "lethal from " + region.lethalDepth + " blocks out"; + 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"; 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/RespawnGuardTest.java b/src/test/java/net/tfminecraft/worldborder/border/RespawnGuardTest.java index 19cd8c8..8b7c49b 100644 --- a/src/test/java/net/tfminecraft/worldborder/border/RespawnGuardTest.java +++ b/src/test/java/net/tfminecraft/worldborder/border/RespawnGuardTest.java @@ -83,6 +83,32 @@ void respawnInTheLethalBandMovesToTheWorldSpawn() { 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) { diff --git a/src/test/java/net/tfminecraft/worldborder/command/CommandManagerTest.java b/src/test/java/net/tfminecraft/worldborder/command/CommandManagerTest.java index a4eb94f..91dd639 100644 --- a/src/test/java/net/tfminecraft/worldborder/command/CommandManagerTest.java +++ b/src/test/java/net/tfminecraft/worldborder/command/CommandManagerTest.java @@ -196,6 +196,21 @@ void debugReportsTheCurrentZoneAndBorder(double x, double z, String zone, String 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'", From e78dfa8eb82e64f8aebfeca287f431db09fae84a Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:38:14 +0100 Subject: [PATCH 5/6] Stop pearls and portals jumping the lethal band The border is sampled every few ticks, so a teleport that lands past the lethal band (an ender pearl cannon, chorus fruit, a portal) skipped it. Player-set-off teleports (ENDER_PEARL, CONSUMABLE_EFFECT, NETHER_PORTAL, END_PORTAL, END_GATEWAY) can no longer land a player in the lethal band or past the buffer unless they are already past it; staff and plugin teleports still can. Also from the independent review: - If another plugin cancels the death in the lethal band, the player is moved to the world spawn instead of walking on. - lethal-depth past buffer-width always warns, whatever max-travel-speed is. - max-travel-speed is described as a guideline, not a hard limit. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../tfminecraft/worldborder/WorldBorder.java | 2 + .../worldborder/border/BorderManager.java | 8 + .../worldborder/border/RespawnGuard.java | 29 ++-- .../worldborder/border/TeleportGuard.java | 69 ++++++++ .../tfminecraft/worldborder/cache/Cache.java | 5 +- .../worldborder/loader/ConfigLoader.java | 7 + src/main/resources/config.yml | 10 +- .../worldborder/border/BorderManagerTest.java | 22 +++ .../worldborder/border/TeleportGuardTest.java | 160 ++++++++++++++++++ .../worldborder/loader/ConfigLoaderTest.java | 10 ++ 11 files changed, 308 insertions(+), 16 deletions(-) create mode 100644 src/main/java/net/tfminecraft/worldborder/border/TeleportGuard.java create mode 100644 src/test/java/net/tfminecraft/worldborder/border/TeleportGuardTest.java diff --git a/README.md b/README.md index f6e3bee..2c94c4f 100644 --- a/README.md +++ b/README.md @@ -11,7 +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. +- **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 and portals 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 5642f10..3fd7470 100644 --- a/src/main/java/net/tfminecraft/worldborder/WorldBorder.java +++ b/src/main/java/net/tfminecraft/worldborder/WorldBorder.java @@ -10,6 +10,7 @@ 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; @@ -35,6 +36,7 @@ public void onEnable() { 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 e2d3bda..1bda43c 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; @@ -85,6 +86,13 @@ private void tick() { // 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); + 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."); + } } case SAFE, BEYOND -> clearWarning(player); } diff --git a/src/main/java/net/tfminecraft/worldborder/border/RespawnGuard.java b/src/main/java/net/tfminecraft/worldborder/border/RespawnGuard.java index b94cec8..8fd9af2 100644 --- a/src/main/java/net/tfminecraft/worldborder/border/RespawnGuard.java +++ b/src/main/java/net/tfminecraft/worldborder/border/RespawnGuard.java @@ -28,18 +28,27 @@ public void onRespawn(PlayerRespawnEvent event) { if (region == null || region.zoneAt(to.getX(), to.getZ(), Cache.graceInset) != Region.Zone.LETHAL) { return; } - Location safe = world.getSpawnLocation(); - String where = "the spawn of " + world.getName(); - if (region.zoneAt(safe.getX(), safe.getZ(), Cache.graceInset) == Region.Zone.LETHAL) { - // The world spawn is out in the band too, so use the middle of the border. - int x = (int) Math.floorDiv((long) region.minX + region.maxX, 2L); - int z = (int) Math.floorDiv((long) region.minZ + region.maxZ, 2L); - safe = new Location(world, x + 0.5, world.getHighestBlockYAt(x, z) + 1, z + 0.5); - where = "the centre of the " + world.getName() + " border"; - } + 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 " + where + "."); + + ") 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..7318602 --- /dev/null +++ b/src/main/java/net/tfminecraft/worldborder/border/TeleportGuard.java @@ -0,0 +1,69 @@ +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. + */ +public final class TeleportGuard implements Listener { + + static final Set PLAYER_TELEPORTS = EnumSet.of( + TeleportCause.ENDER_PEARL, + TeleportCause.CONSUMABLE_EFFECT, + TeleportCause.NETHER_PORTAL, + TeleportCause.END_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 4af6b26..3ba0eb9 100644 --- a/src/main/java/net/tfminecraft/worldborder/cache/Cache.java +++ b/src/main/java/net/tfminecraft/worldborder/cache/Cache.java @@ -22,8 +22,9 @@ private Cache() {} /** * 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 accepts from an elytra flight - * (about 17 blocks a tick), well above anything legitimate. + * 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; diff --git a/src/main/java/net/tfminecraft/worldborder/loader/ConfigLoader.java b/src/main/java/net/tfminecraft/worldborder/loader/ConfigLoader.java index 748425a..14d0edb 100644 --- a/src/main/java/net/tfminecraft/worldborder/loader/ConfigLoader.java +++ b/src/main/java/net/tfminecraft/worldborder/loader/ConfigLoader.java @@ -77,6 +77,13 @@ private void warnIfBufferCanBeCrossed(Region region) { + " 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( diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 12859e2..071389f 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -9,8 +9,10 @@ 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 accepts from an elytra flight (about 17 blocks a tick); -# real elytra flight is about 35-70 and vehicles about 25. +# 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. @@ -27,7 +29,9 @@ worlds: [] # 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. +# across it between checks. Ender pearls, chorus fruit and portals 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 diff --git a/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java b/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java index 6d96d37..ba2b8c1 100644 --- a/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java +++ b/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java @@ -7,6 +7,7 @@ import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; 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; @@ -274,11 +275,32 @@ void lethalBandKillsOutrightAndClearsTheWarning() { 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"); + + 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 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..fcb548b --- /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_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"}) + void staffAndPluginTeleportsReachFarBuilds(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/loader/ConfigLoaderTest.java b/src/test/java/net/tfminecraft/worldborder/loader/ConfigLoaderTest.java index 2702718..cfbacf9 100644 --- a/src/test/java/net/tfminecraft/worldborder/loader/ConfigLoaderTest.java +++ b/src/test/java/net/tfminecraft/worldborder/loader/ConfigLoaderTest.java @@ -188,6 +188,16 @@ void warnsWhenTheBufferCanBeCrossed() throws Exception { 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; From 092d9e769ce821c22645fe05733f2507eb048d1d Mon Sep 17 00:00:00 2001 From: XxFran10xX <318299142+XxFran10xX@users.noreply.github.com> Date: Fri, 30 Oct 2026 21:42:50 +0100 Subject: [PATCH 6/6] Let End exits reach far beds; only log a move that happened - END_PORTAL is no longer guarded: leaving the End only goes to a bed or the world spawn, and a bed past the buffer means the player was sent out there. Guarding it would trap such a player on the exit portal. - After a cancelled death in the lethal band, "Moved ..." is only logged when the teleport worked; otherwise a warning says the move failed. Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- .../worldborder/border/BorderManager.java | 10 +++++++--- .../worldborder/border/TeleportGuard.java | 3 ++- src/main/resources/config.yml | 6 +++--- .../worldborder/border/BorderManagerTest.java | 15 +++++++++++++++ .../worldborder/border/TeleportGuardTest.java | 6 +++--- 6 files changed, 31 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 2c94c4f..ce26cb5 100644 --- a/README.md +++ b/README.md @@ -11,7 +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 and portals cannot jump a player over the band. +- **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/border/BorderManager.java b/src/main/java/net/tfminecraft/worldborder/border/BorderManager.java index 1bda43c..91d5ea9 100644 --- a/src/main/java/net/tfminecraft/worldborder/border/BorderManager.java +++ b/src/main/java/net/tfminecraft/worldborder/border/BorderManager.java @@ -89,9 +89,13 @@ private void tick() { if (!player.isDead()) { // Another plugin cancelled the death; don't let them walk on in the band. Location safe = RespawnGuard.safeSpot(player.getWorld(), region); - 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."); + 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); diff --git a/src/main/java/net/tfminecraft/worldborder/border/TeleportGuard.java b/src/main/java/net/tfminecraft/worldborder/border/TeleportGuard.java index 7318602..b8e405a 100644 --- a/src/main/java/net/tfminecraft/worldborder/border/TeleportGuard.java +++ b/src/main/java/net/tfminecraft/worldborder/border/TeleportGuard.java @@ -20,6 +20,8 @@ * (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 { @@ -27,7 +29,6 @@ public final class TeleportGuard implements Listener { TeleportCause.ENDER_PEARL, TeleportCause.CONSUMABLE_EFFECT, TeleportCause.NETHER_PORTAL, - TeleportCause.END_PORTAL, TeleportCause.END_GATEWAY); @EventHandler(priority = EventPriority.HIGH, ignoreCancelled = true) diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 071389f..fbfd73d 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -29,9 +29,9 @@ worlds: [] # 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 and portals can't land -# a player in the lethal band or past the buffer (unless they are already past -# it); staff and plugin teleports can. +# 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 diff --git a/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java b/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java index ba2b8c1..d1f7170 100644 --- a/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java +++ b/src/test/java/net/tfminecraft/worldborder/border/BorderManagerTest.java @@ -6,6 +6,7 @@ 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; @@ -294,6 +295,7 @@ void cancelledDeathInTheLethalBandMovesThePlayerToTheWorldSpawn() { when(world.getSpawnLocation()).thenReturn(spawn); Player player = playerAt(150, 50); when(player.getName()).thenReturn("Revived"); + when(player.teleport(spawn)).thenReturn(true); tick(); @@ -303,6 +305,19 @@ void cancelledDeathInTheLethalBandMovesThePlayerToTheWorldSpawn() { "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)); diff --git a/src/test/java/net/tfminecraft/worldborder/border/TeleportGuardTest.java b/src/test/java/net/tfminecraft/worldborder/border/TeleportGuardTest.java index fcb548b..e0a4bd1 100644 --- a/src/test/java/net/tfminecraft/worldborder/border/TeleportGuardTest.java +++ b/src/test/java/net/tfminecraft/worldborder/border/TeleportGuardTest.java @@ -74,7 +74,7 @@ private PlayerTeleportEvent teleport(double fromX, double toX, TeleportCause cau @ParameterizedTest @EnumSource(value = TeleportCause.class, - names = {"ENDER_PEARL", "CONSUMABLE_EFFECT", "NETHER_PORTAL", "END_PORTAL", "END_GATEWAY"}) + names = {"ENDER_PEARL", "CONSUMABLE_EFFECT", "NETHER_PORTAL", "END_GATEWAY"}) void playerTeleportsCannotLandInTheLethalBandOrPastTheBuffer(TeleportCause cause) { PlayerTeleportEvent intoBand = teleport(50, 300, cause); guard.onTeleport(intoBand); @@ -96,8 +96,8 @@ void portalEventsAreCheckedToo() { @ParameterizedTest @EnumSource(value = TeleportCause.class, - names = {"COMMAND", "PLUGIN", "SPECTATE", "DISMOUNT", "EXIT_BED", "UNKNOWN"}) - void staffAndPluginTeleportsReachFarBuilds(TeleportCause cause) { + 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());