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 // -----------------------------------------------------------------------