From b288ffa8e43f1b4f7c65fe76d62c2584022b339e Mon Sep 17 00:00:00 2001 From: tastybento Date: Sat, 17 Oct 2026 08:47:51 -0700 Subject: [PATCH] Safe End arrival when the obsidian platform is off With create-obsidian-platform: false the portal event was never cancelled, so the server teleported the player in the same tick to a spot that could have no floor, and the scheduled safe spot teleport then found the player already in the End and did nothing. Cancel the event when the End arrival is unsafe so the safe spot teleport runs, and cancel repeat portal events while that teleport is pending so the server cannot send the player to the vanilla End spawn instead. Applies to players and entities. Also teleport to the centre of the block when a safe spot pod is built. The target was the requested location plus 0.5, which lands on a block edge when the location is already off-centre. Co-Authored-By: Claude Opus 5.5 --- .../teleports/AbstractTeleportListener.java | 44 +++++++++ .../teleports/EntityTeleportListener.java | 12 +++ .../teleports/PlayerTeleportListener.java | 11 +++ .../teleport/ClosestSafeSpotTeleport.java | 9 +- .../util/teleport/SafeSpotTeleport.java | 9 +- .../teleports/PlayerTeleportListenerTest.java | 96 +++++++++++++++++++ .../teleport/ClosestSafeSpotTeleportTest.java | 20 ++++ 7 files changed, 197 insertions(+), 4 deletions(-) diff --git a/src/main/java/world/bentobox/bentobox/listeners/teleports/AbstractTeleportListener.java b/src/main/java/world/bentobox/bentobox/listeners/teleports/AbstractTeleportListener.java index 0cac9451c..7dd9dbbb9 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/teleports/AbstractTeleportListener.java +++ b/src/main/java/world/bentobox/bentobox/listeners/teleports/AbstractTeleportListener.java @@ -336,6 +336,50 @@ protected Location calculateLocation(Location fromLocation, } + /** + * Checks whether an arrival in The End would leave the player or entity with nothing to + * stand on. + *

+ * When the game mode does not make End portals (no obsidian platform), the server still + * performs the teleport itself, in the same tick, to the exact destination. If that spot + * has no ground the arrival falls into the void before the safe spot teleport can run. + * Callers cancel the event when this returns {@code true} so the safe spot teleport is used. + * + * @param overWorld the game mode's overworld. + * @param environment the portal's environment. + * @param destination the arrival location, may be {@code null}. + * @return {@code true} if the arrival is in The End without a platform and the spot is not safe. + */ + protected boolean isUnsafeEndArrival(World overWorld, World.Environment environment, @Nullable Location destination) + { + return World.Environment.THE_END.equals(environment) + && !this.isMakePortals(overWorld, environment) + && destination != null + && destination.getWorld() != null + && World.Environment.THE_END.equals(destination.getWorld().getEnvironment()) + && !this.plugin.getIslands().isSafeLocation(destination); + } + + + /** + * Checks whether a repeat portal event, fired while an earlier one is still being handled, + * belongs to an End trip without a platform. + *

+ * Without a platform the first event is either left to the server (the traveller is gone and + * no repeat follows) or cancelled in favour of a safe spot teleport that takes a few ticks. + * While the traveller still stands in the portal the server keeps firing portal events; any + * of them left uncancelled would send the traveller to the vanilla End spawn instead. + * + * @param overWorld the game mode's overworld. + * @param environment the portal's environment. + * @return {@code true} if repeat events for this trip must be cancelled. + */ + protected boolean isPendingEndTeleport(World overWorld, World.Environment environment) + { + return World.Environment.THE_END.equals(environment) && !this.isMakePortals(overWorld, environment); + } + + /** * Builds the End obsidian arrival platform at the given destination. *

diff --git a/src/main/java/world/bentobox/bentobox/listeners/teleports/EntityTeleportListener.java b/src/main/java/world/bentobox/bentobox/listeners/teleports/EntityTeleportListener.java index 5a06bfb15..8cfebf4cf 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/teleports/EntityTeleportListener.java +++ b/src/main/java/world/bentobox/bentobox/listeners/teleports/EntityTeleportListener.java @@ -212,6 +212,11 @@ private void portalProcess(EntityPortalEvent event, World.Environment environmen if (this.inTeleport.contains(event.getEntity().getUniqueId())) { // Entity is already in teleportation. + if (this.isPendingEndTeleport(overWorld, environment)) + { + // A safe spot teleport is still on its way; do not let the server teleport instead. + event.setCancelled(true); + } return; } this.inTeleport.add(event.getEntity().getUniqueId()); @@ -275,6 +280,13 @@ private void portalProcess(EntityPortalEvent event, World.Environment environmen return; } + if (!event.isCancelled() && this.isUnsafeEndArrival(overWorld, environment, event.getTo())) + { + // No End platform and nothing to stand on: stop the server teleport and let the + // safe spot teleport below find somewhere safe. + event.setCancelled(true); + } + if (!event.isCancelled()) { // Let the server teleport diff --git a/src/main/java/world/bentobox/bentobox/listeners/teleports/PlayerTeleportListener.java b/src/main/java/world/bentobox/bentobox/listeners/teleports/PlayerTeleportListener.java index 08f813075..ac545c5ff 100644 --- a/src/main/java/world/bentobox/bentobox/listeners/teleports/PlayerTeleportListener.java +++ b/src/main/java/world/bentobox/bentobox/listeners/teleports/PlayerTeleportListener.java @@ -284,6 +284,11 @@ private void portalProcess(PlayerPortalEvent event, World.Environment environmen if (this.inTeleport.contains(event.getPlayer().getUniqueId())) { // Player is already in teleportation. + if (this.isPendingEndTeleport(overWorld, environment)) + { + // A safe spot teleport is still on its way; do not let the server teleport instead. + event.setCancelled(true); + } return; } this.inTeleport.add(event.getPlayer().getUniqueId()); @@ -346,6 +351,12 @@ private void portalProcess(PlayerPortalEvent event, World.Environment environmen // If there is no island, then processor already created island. Nothing to do more. return; } + if (!event.isCancelled() && this.isUnsafeEndArrival(overWorld, environment, event.getTo())) + { + // No End platform and nothing to stand on: stop the server teleport and let the + // safe spot teleport below find somewhere safe. + event.setCancelled(true); + } if (!event.isCancelled() && event.getCanCreatePortal()) { // Let the server teleport diff --git a/src/main/java/world/bentobox/bentobox/util/teleport/ClosestSafeSpotTeleport.java b/src/main/java/world/bentobox/bentobox/util/teleport/ClosestSafeSpotTeleport.java index 4587f4863..65f336423 100644 --- a/src/main/java/world/bentobox/bentobox/util/teleport/ClosestSafeSpotTeleport.java +++ b/src/main/java/world/bentobox/bentobox/util/teleport/ClosestSafeSpotTeleport.java @@ -448,8 +448,13 @@ void makeAndTeleport(Material baseMaterial) this.location.getBlock().getRelative(BlockFace.UP).setType(Material.AIR, false); this.location.getBlock().getRelative(BlockFace.UP).getRelative(BlockFace.UP).setType(baseMaterial, false); - // Teleport player to the location of the empty space. - this.asyncTeleport(this.location.clone().add(new Vector(0.5D, 0D, 0.5D))); + // Teleport player to the centre of the empty space. Use the block coordinates, as the + // location may already be off-centre and adding 0.5 would land on the block's edge. + Location centre = this.location.clone(); + centre.setX(this.location.getBlockX() + 0.5D); + centre.setY(this.location.getBlockY()); + centre.setZ(this.location.getBlockZ() + 0.5D); + this.asyncTeleport(centre); } diff --git a/src/main/java/world/bentobox/bentobox/util/teleport/SafeSpotTeleport.java b/src/main/java/world/bentobox/bentobox/util/teleport/SafeSpotTeleport.java index 859ae13ee..fe3fc47e1 100644 --- a/src/main/java/world/bentobox/bentobox/util/teleport/SafeSpotTeleport.java +++ b/src/main/java/world/bentobox/bentobox/util/teleport/SafeSpotTeleport.java @@ -181,8 +181,13 @@ void makeAndTeleport(Material m) { location.getBlock().setType(Material.AIR, false); location.getBlock().getRelative(BlockFace.UP).setType(Material.AIR, false); location.getBlock().getRelative(BlockFace.UP).getRelative(BlockFace.UP).setType(m, false); - Util.teleportAsync(Objects.requireNonNull(entity), - Objects.requireNonNull(location.clone().add(new Vector(0.5D, 0D, 0.5D)))).thenRun(() -> { + // Centre on the block, as the location may already be off-centre and adding 0.5 would + // land on the block's edge. + Location centre = location.clone(); + centre.setX(location.getBlockX() + 0.5D); + centre.setY(location.getBlockY()); + centre.setZ(location.getBlockZ() + 0.5D); + Util.teleportAsync(Objects.requireNonNull(entity), centre).thenRun(() -> { if (runnable != null) Bukkit.getScheduler().runTask(plugin, runnable); result.complete(true); }); diff --git a/src/test/java/world/bentobox/bentobox/listeners/teleports/PlayerTeleportListenerTest.java b/src/test/java/world/bentobox/bentobox/listeners/teleports/PlayerTeleportListenerTest.java index 5f30119b0..01e5e8d07 100644 --- a/src/test/java/world/bentobox/bentobox/listeners/teleports/PlayerTeleportListenerTest.java +++ b/src/test/java/world/bentobox/bentobox/listeners/teleports/PlayerTeleportListenerTest.java @@ -46,6 +46,7 @@ import com.google.common.collect.ImmutableSet; import world.bentobox.bentobox.CommonTestSetup; +import world.bentobox.bentobox.api.addons.GameModeAddon; import world.bentobox.bentobox.api.configuration.WorldSettings; import world.bentobox.bentobox.database.objects.Island; import world.bentobox.bentobox.util.Util; @@ -260,6 +261,101 @@ void testPortalProcessEndVelocityReset() { verify(mockPlayer, times(1)).setFallDistance(0); } + /** + * Sets up an island End trip with no End platform (create-obsidian-platform: false), + * arriving at a location in a separate End world. + * + * @return the arrival location + */ + private Location setUpEndArrivalWithoutPlatform() { + when(Util.getWorld(location.getWorld())).thenReturn(world); + when(iwm.inWorld(world)).thenReturn(true); + World endWorld = mock(World.class); + when(endWorld.getEnvironment()).thenReturn(Environment.THE_END); + when(iwm.getEndWorld(world)).thenReturn(endWorld); + Location arrival = mock(Location.class); + when(arrival.getWorld()).thenReturn(endWorld); + // PlayerPortalEvent.setTo stores a clone + when(arrival.clone()).thenReturn(arrival); + when(location.toVector().toLocation(endWorld)).thenReturn(arrival); + return arrival; + } + + /** + * With no End platform, an arrival spot with nothing to stand on must not be left to the + * server, which would drop the player into the void. The event is cancelled so the safe + * spot teleport is used instead. + */ + @Test + void testPortalProcessEndNoPlatformUnsafeArrivalCancels() { + Location arrival = setUpEndArrivalWithoutPlatform(); + when(im.isSafeLocation(arrival)).thenReturn(false); + PlayerPortalEvent e = new PlayerPortalEvent(mockPlayer, location, location, TeleportCause.END_PORTAL, 0, false, + 0); + + ptl.onPlayerPortalEvent(e); + + assertTrue(e.isCancelled()); + assertSame(arrival, e.getTo()); + } + + /** + * With no End platform but solid ground at the arrival spot, the server does the teleport. + */ + @Test + void testPortalProcessEndNoPlatformSafeArrivalLetsServerTeleport() { + Location arrival = setUpEndArrivalWithoutPlatform(); + when(im.isSafeLocation(arrival)).thenReturn(true); + PlayerPortalEvent e = new PlayerPortalEvent(mockPlayer, location, location, TeleportCause.END_PORTAL, 0, false, + 0); + + ptl.onPlayerPortalEvent(e); + + assertFalse(e.isCancelled()); + assertSame(arrival, e.getTo()); + } + + /** + * With no End platform, a repeat portal event fired while the safe spot teleport is still + * pending must also be cancelled, or the server would send the player to the vanilla End spawn. + */ + @Test + void testPortalProcessEndNoPlatformRepeatEventCancels() { + Location arrival = setUpEndArrivalWithoutPlatform(); + when(im.isSafeLocation(arrival)).thenReturn(false); + PlayerPortalEvent first = new PlayerPortalEvent(mockPlayer, location, location, TeleportCause.END_PORTAL, 0, + false, 0); + ptl.onPlayerPortalEvent(first); + assertTrue(ptl.getInTeleport().contains(mockPlayer.getUniqueId())); + + PlayerPortalEvent repeat = new PlayerPortalEvent(mockPlayer, location, location, TeleportCause.END_PORTAL, 0, + false, 0); + ptl.onPlayerPortalEvent(repeat); + + assertTrue(repeat.isCancelled()); + } + + /** + * With the End platform on, a repeat portal event is left alone, as before. + */ + @Test + void testPortalProcessEndWithPlatformRepeatEventNotCancelled() { + when(Util.getWorld(location.getWorld())).thenReturn(world); + when(iwm.inWorld(world)).thenReturn(true); + GameModeAddon gm = mock(GameModeAddon.class); + WorldSettings ws = mock(WorldSettings.class); + when(ws.isMakeEndPortals()).thenReturn(true); + when(gm.getWorldSettings()).thenReturn(ws); + when(iwm.getAddon(world)).thenReturn(Optional.of(gm)); + ptl.getInTeleport().add(mockPlayer.getUniqueId()); + + PlayerPortalEvent repeat = new PlayerPortalEvent(mockPlayer, location, location, TeleportCause.END_PORTAL, 0, + false, 0); + ptl.onPlayerPortalEvent(repeat); + + assertFalse(repeat.isCancelled()); + } + /** * Verifies that {@link AbstractTeleportListener#createEndPlatform(Location)} lays a 5x5 * obsidian floor and clears 5x5x3 of air above it when the destination is in The End. diff --git a/src/test/java/world/bentobox/bentobox/util/teleport/ClosestSafeSpotTeleportTest.java b/src/test/java/world/bentobox/bentobox/util/teleport/ClosestSafeSpotTeleportTest.java index dfdea162a..ea762a6c8 100644 --- a/src/test/java/world/bentobox/bentobox/util/teleport/ClosestSafeSpotTeleportTest.java +++ b/src/test/java/world/bentobox/bentobox/util/teleport/ClosestSafeSpotTeleportTest.java @@ -641,6 +641,26 @@ void testMakeAndTeleport_endStone_setsExpectedBlocks() { verify(blockUpUp).setType(Material.END_STONE, false); } + /** + * {@code makeAndTeleport()} teleports to the centre of the pod's block even when the + * requested location is off-centre. Adding 0.5 to x = -0.5 would land on the block edge. + */ + @Test + void testMakeAndTeleport_offCentreLocation_teleportsToBlockCentre() { + Location offCentre = new Location(world, -0.5D, 49.3D, 3.7D, 90F, 10F); + Block podBlock = mock(Block.class); + when(world.getBlockAt(any(Location.class))).thenReturn(podBlock); + when(podBlock.getRelative(BlockFace.DOWN)).thenReturn(blockDown); + when(podBlock.getRelative(BlockFace.UP)).thenReturn(blockUp); + teleport = ClosestSafeSpotTeleport.builder(plugin).entity(mockPlayer).location(offCentre).build(); + + teleport.makeAndTeleport(Material.END_STONE); + + verify(blockDown).setType(Material.END_STONE, false); + mockedUtil.verify(() -> Util.teleportAsync(eq(mockPlayer), + eq(new Location(world, -0.5D, 49D, 3.5D, 90F, 10F)))); + } + // ----------------------------------------------------------------------- // gatherChunks() tests // -----------------------------------------------------------------------