From 6fc618d8eadf2df5a42073d86e7df1a97ae626ee Mon Sep 17 00:00:00 2001 From: spa77k <254093522+spa77k@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:46:15 +0900 Subject: [PATCH 1/3] Apply respawn radius game rule when respawning at world spawn --- .../core/listeners/MVPlayerListener.java | 138 +++++++++++++++++- .../core/listeners/MVPlayerListenerTest.kt | 85 +++++++++++ 2 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/mvplugins/multiverse/core/listeners/MVPlayerListenerTest.kt diff --git a/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java b/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java index 6d2582680..83bb1a265 100644 --- a/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java +++ b/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java @@ -8,15 +8,21 @@ package org.mvplugins.multiverse.core.listeners; import java.util.Objects; +import java.util.concurrent.ThreadLocalRandom; import com.dumptruckman.minecraft.util.Logging; import io.vavr.control.Option; import jakarta.inject.Inject; import jakarta.inject.Provider; +import org.bukkit.GameMode; +import org.bukkit.GameRule; +import org.bukkit.HeightMap; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Server; import org.bukkit.World; +import org.bukkit.WorldBorder; +import org.bukkit.block.Block; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.bukkit.event.EventPriority; @@ -26,6 +32,7 @@ import org.bukkit.event.player.PlayerRespawnEvent; import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.Nullable; import org.jvnet.hk2.annotations.Service; import org.mvplugins.multiverse.core.MultiverseCore; @@ -58,6 +65,18 @@ */ @Service final class MVPlayerListener implements CoreListener { + /** + * How many locations to try when spreading a respawn around the world spawn, same as vanilla. + */ + private static final int MAX_RESPAWN_RADIUS_CANDIDATES = 1024; + + /** + * How many chunks around the spawn chunk may be loaded while looking for a respawn location. Candidates are + * checked synchronously while the respawn event is handled, so candidates in other chunks are only used if + * those chunks are already loaded. + */ + private static final int RESPAWN_RADIUS_LOADABLE_CHUNK_RANGE = 1; + private final Plugin plugin; private final CoreConfig config; private final Provider worldManagerProvider; @@ -189,7 +208,124 @@ private Option getMostAccurateRespawnLocation(LoadedMultiverseWorld mv Logging.fine("Respawn location is within same world as respawn-world, not overriding."); return Option.none(); } - return Option.of(mvWorld.getSpawnLocation()); + return Option.of(getSpawnLocationWithRespawnRadius(mvWorld, mvWorld.getSpawnLocation())); + } + + /** + * Spreads the respawn location around the world spawn like vanilla does. Multiverse would otherwise always + * respawn players exactly at the configured spawn location, bypassing the respawn radius game rule. + * + * @param mvWorld The world the player is respawning in. + * @param spawnLocation The configured spawn location of that world. + * @return A safe location within the respawn radius of the spawn, or the spawn location itself if none is found. + */ + Location getSpawnLocationWithRespawnRadius(LoadedMultiverseWorld mvWorld, Location spawnLocation) { + World world = spawnLocation.getWorld(); + if (world == null) { + return spawnLocation; + } + int respawnRadius = getRespawnRadius(world); + if (respawnRadius <= 0 || mvWorld.getGameMode() == GameMode.ADVENTURE) { + Logging.finer("Not spreading respawn location for world '%s' with respawn radius %d.", + mvWorld.getName(), respawnRadius); + return spawnLocation; + } + int spawnX = spawnLocation.getBlockX(); + int spawnZ = spawnLocation.getBlockZ(); + int radius = Math.max(1, Math.min(respawnRadius, getDistanceToBorder(world.getWorldBorder(), spawnX, spawnZ))); + Logging.finer("Spreading respawn location for world '%s' with radius %d.", mvWorld.getName(), radius); + + // Go through the candidates in a random order without repeats, like vanilla does. + int sideLength = radius * 2 + 1; + long candidateCount = (long) sideLength * sideLength; + long step = getCoprime(candidateCount); + long offset = ThreadLocalRandom.current().nextLong(candidateCount); + long attempts = Math.min(candidateCount, MAX_RESPAWN_RADIUS_CANDIDATES); + for (long i = 0; i < attempts; i++) { + long index = (offset + i * step) % candidateCount; + int x = spawnX + (int) (index % sideLength) - radius; + int z = spawnZ + (int) (index / sideLength) - radius; + if (!canCheckRespawnCandidate(world, spawnX, spawnZ, x, z)) { + continue; + } + Location candidate = getRespawnCandidate(world, spawnLocation, x, z); + if (candidate != null) { + Logging.fine("Using respawn location '%s' within radius %d of spawn for world '%s'.", + candidate, radius, mvWorld.getName()); + return candidate; + } + } + Logging.finer("No safe respawn location found within radius %d of spawn for world '%s'.", + radius, mvWorld.getName()); + return spawnLocation; + } + + private boolean canCheckRespawnCandidate(World world, int spawnX, int spawnZ, int x, int z) { + int chunkX = x >> 4; + int chunkZ = z >> 4; + return (Math.abs(chunkX - (spawnX >> 4)) <= RESPAWN_RADIUS_LOADABLE_CHUNK_RANGE + && Math.abs(chunkZ - (spawnZ >> 4)) <= RESPAWN_RADIUS_LOADABLE_CHUNK_RANGE) + || world.isChunkLoaded(chunkX, chunkZ); + } + + private @Nullable Location getRespawnCandidate(World world, Location spawnLocation, int x, int z) { + if (world.hasCeiling()) { + // The highest block would be the ceiling, so search up and down from the spawn height instead. + Location location = new Location(world, x + 0.5, spawnLocation.getBlockY(), z + 0.5, + spawnLocation.getYaw(), spawnLocation.getPitch()); + Location safeLocation = blockSafety.findSafeSpawnLocation(location, 0, + config.getSafeLocationVerticalSearchRadius()); + return safeLocation != null && !isInLiquid(safeLocation) ? safeLocation : null; + } + int y = world.getHighestBlockYAt(x, z, HeightMap.MOTION_BLOCKING) + 1; + if (y + 1 >= world.getMaxHeight()) { + return null; + } + Location location = new Location(world, x + 0.5, y, z + 0.5, + spawnLocation.getYaw(), spawnLocation.getPitch()); + return blockSafety.canSpawnAtLocationSafely(location) && !isInLiquid(location) ? location : null; + } + + private boolean isInLiquid(Location location) { + Block block = location.getBlock(); + return block.isLiquid() || block.getRelative(0, 1, 0).isLiquid(); + } + + private long getCoprime(long number) { + long coprime = 17; + while (gcd(coprime, number) != 1) { + coprime++; + } + return coprime; + } + + private long gcd(long a, long b) { + return b == 0 ? a : gcd(b, a % b); + } + + private int getRespawnRadius(World world) { + // Older servers only have the spawnRadius game rule, while newer ones renamed it to respawn_radius. + GameRule respawnRadiusRule = GameRule.getByName("respawn_radius"); + if (respawnRadiusRule == null) { + respawnRadiusRule = GameRule.getByName("spawnRadius"); + } + if (respawnRadiusRule == null) { + return 0; + } + Object value = world.getGameRuleValue(respawnRadiusRule); + return value instanceof Integer ? Math.max(0, (Integer) value) : 0; + } + + private int getDistanceToBorder(WorldBorder worldBorder, int x, int z) { + double halfSize = worldBorder.getSize() / 2.0; + double minX = worldBorder.getCenter().getX() - halfSize; + double maxX = worldBorder.getCenter().getX() + halfSize; + double minZ = worldBorder.getCenter().getZ() - halfSize; + double maxZ = worldBorder.getCenter().getZ() + halfSize; + double distance = Math.min( + Math.min(x - minX, maxX - x), + Math.min(z - minZ, maxZ - z)); + return (int) Math.floor(distance); } @EventMethod diff --git a/src/test/java/org/mvplugins/multiverse/core/listeners/MVPlayerListenerTest.kt b/src/test/java/org/mvplugins/multiverse/core/listeners/MVPlayerListenerTest.kt new file mode 100644 index 000000000..da118fc2c --- /dev/null +++ b/src/test/java/org/mvplugins/multiverse/core/listeners/MVPlayerListenerTest.kt @@ -0,0 +1,85 @@ +package org.mvplugins.multiverse.core.listeners + +import org.bukkit.GameMode +import org.mvplugins.multiverse.core.TestWithMockBukkit +import org.mvplugins.multiverse.core.teleportation.BlockSafety +import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld +import org.mvplugins.multiverse.core.world.WorldManager +import org.mvplugins.multiverse.core.world.options.CreateWorldOptions +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class MVPlayerListenerTest : TestWithMockBukkit() { + + private lateinit var listener: MVPlayerListener + private lateinit var worldManager: WorldManager + private lateinit var blockSafety: BlockSafety + + @BeforeTest + fun setUpRespawnTest() { + listener = serviceLocator.getAllActiveServices(CoreListener::class.java) + .filterIsInstance() + .first() + worldManager = requireNotNull(serviceLocator.getActiveService(WorldManager::class.java)) + blockSafety = requireNotNull(serviceLocator.getActiveService(BlockSafety::class.java)) + } + + @Test + fun `Respawn radius of zero keeps the spawn location`() { + val mvWorld = createWorld("respawn_radius_zero", 0) + val spawnLocation = mvWorld.spawnLocation + + assertEquals(spawnLocation, listener.getSpawnLocationWithRespawnRadius(mvWorld, spawnLocation)) + } + + @Test + fun `Respawn radius is not applied in adventure mode`() { + val mvWorld = createWorld("respawn_radius_adventure", 5) + assertTrue(mvWorld.setGameMode(GameMode.ADVENTURE).isSuccess) + val spawnLocation = mvWorld.spawnLocation + + assertEquals(spawnLocation, listener.getSpawnLocationWithRespawnRadius(mvWorld, spawnLocation)) + } + + @Test + fun `Respawn location is spread to safe locations within the radius`() { + val radius = 5 + val mvWorld = createWorld("respawn_radius_five", radius) + val spawnLocation = mvWorld.spawnLocation + + val results = (1..20).map { listener.getSpawnLocationWithRespawnRadius(mvWorld, spawnLocation) } + + results.forEach { result -> + assertEquals(spawnLocation.world, assertNotNull(result.world)) + assertTrue(result.blockX - spawnLocation.blockX in -radius..radius) + assertTrue(result.blockZ - spawnLocation.blockZ in -radius..radius) + assertTrue(blockSafety.canSpawnAtLocationSafely(result)) + } + assertTrue(results.any { it.blockX != spawnLocation.blockX || it.blockZ != spawnLocation.blockZ }) + } + + @Test + fun `Respawn radius is limited by the world border`() { + val mvWorld = createWorld("respawn_radius_border", 50) + val spawnLocation = mvWorld.spawnLocation + val worldBorder = spawnLocation.world.worldBorder + worldBorder.center = spawnLocation + worldBorder.size = 7.0 + + repeat(20) { + val result = listener.getSpawnLocationWithRespawnRadius(mvWorld, spawnLocation) + assertTrue(worldBorder.isInside(result)) + } + } + + @Suppress("DEPRECATION") + private fun createWorld(name: String, respawnRadius: Int): LoadedMultiverseWorld { + assertTrue(worldManager.createWorld(CreateWorldOptions.worldName(name)).isSuccess) + val world = requireNotNull(worldManager.getLoadedWorld(name).getOrNull()) + assertTrue(world.bukkitWorld.get().setGameRuleValue("respawn_radius", respawnRadius.toString())) + return world + } +} From e552da84e78b5193a41b3f785026648dffca5253 Mon Sep 17 00:00:00 2001 From: spa77k <254093522+spa77k@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:19:51 +0900 Subject: [PATCH 2/3] Move respawn radius logic to SpawnRadiusLocator and add use-respawn-radius config option --- .../multiverse/core/config/CoreConfig.java | 13 ++ .../core/config/CoreConfigNodes.java | 12 ++ .../core/listeners/MVPlayerListener.java | 145 +--------------- .../teleportation/SpawnRadiusLocator.java | 161 ++++++++++++++++++ .../multiverse/core/config/ConfigTest.kt | 19 +++ .../core/listeners/RespawnRadiusTest.kt | 55 ++++++ .../SpawnRadiusLocatorTest.kt} | 38 +++-- src/test/resources/configs/fresh_config.yml | 3 +- 8 files changed, 294 insertions(+), 152 deletions(-) create mode 100644 src/main/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocator.java create mode 100644 src/test/java/org/mvplugins/multiverse/core/listeners/RespawnRadiusTest.kt rename src/test/java/org/mvplugins/multiverse/core/{listeners/MVPlayerListenerTest.kt => teleportation/SpawnRadiusLocatorTest.kt} (65%) diff --git a/src/main/java/org/mvplugins/multiverse/core/config/CoreConfig.java b/src/main/java/org/mvplugins/multiverse/core/config/CoreConfig.java index 3140cb126..f6c4d380f 100644 --- a/src/main/java/org/mvplugins/multiverse/core/config/CoreConfig.java +++ b/src/main/java/org/mvplugins/multiverse/core/config/CoreConfig.java @@ -83,6 +83,9 @@ public final class CoreConfig { .addVersionMigrator(VersionMigrator.builder(5.2) .addAction(MoveMigratorAction.of("spawn.default-respawn-to-world-spawn", "world.enforce-respawn-at-world-spawn")) .build()) + .addVersionMigrator(VersionMigrator.builder(5.3) + .addAction(SetMigratorAction.of("spawn.use-respawn-radius", false)) + .build()) .build()) .build(); this.stringPropertyHandle = new StringPropertyHandle(configHandle); @@ -443,6 +446,16 @@ public boolean getEnforceRespawnAtWorldSpawn() { return configHandle.get(configNodes.enforceRespawnAtWorldSpawn); } + @ApiStatus.AvailableSince("5.9") + public Try setUseRespawnRadius(boolean useRespawnRadius) { + return configHandle.set(configNodes.useRespawnRadius, useRespawnRadius); + } + + @ApiStatus.AvailableSince("5.9") + public boolean getUseRespawnRadius() { + return configHandle.get(configNodes.useRespawnRadius); + } + /** * {@inheritDoc} */ diff --git a/src/main/java/org/mvplugins/multiverse/core/config/CoreConfigNodes.java b/src/main/java/org/mvplugins/multiverse/core/config/CoreConfigNodes.java index e40103841..fd4564a1d 100644 --- a/src/main/java/org/mvplugins/multiverse/core/config/CoreConfigNodes.java +++ b/src/main/java/org/mvplugins/multiverse/core/config/CoreConfigNodes.java @@ -363,6 +363,18 @@ private N node(N node) { .name("enforce-respawn-at-world-spawn") .build()); + final ConfigNode useRespawnRadius = node(ConfigNode.builder("spawn.use-respawn-radius", Boolean.class) + .comment("") + .comment("When this option is enabled and players respawn at the world's spawn location,") + .comment("they will be spread to a random safe location around it based on the `respawn_radius` game rule") + .comment("(`spawnRadius` on older versions), mimicking the vanilla behavior.") + .comment("Same as vanilla, this does not apply if the respawn radius is 0 or the world's game mode is adventure.") + .comment("----") + .comment("Set this to false if you want players to always respawn exactly at the world's spawn location.") + .defaultValue(true) + .name("use-respawn-radius") + .build()); + private final ConfigHeaderNode portalHeader = node(ConfigHeaderNode.builder("portal") .comment("") .comment("") diff --git a/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java b/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java index 83bb1a265..bb3942af0 100644 --- a/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java +++ b/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java @@ -8,21 +8,15 @@ package org.mvplugins.multiverse.core.listeners; import java.util.Objects; -import java.util.concurrent.ThreadLocalRandom; import com.dumptruckman.minecraft.util.Logging; import io.vavr.control.Option; import jakarta.inject.Inject; import jakarta.inject.Provider; -import org.bukkit.GameMode; -import org.bukkit.GameRule; -import org.bukkit.HeightMap; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.Server; import org.bukkit.World; -import org.bukkit.WorldBorder; -import org.bukkit.block.Block; import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; import org.bukkit.event.EventPriority; @@ -32,7 +26,6 @@ import org.bukkit.event.player.PlayerRespawnEvent; import org.bukkit.event.player.PlayerTeleportEvent; import org.bukkit.plugin.Plugin; -import org.jetbrains.annotations.Nullable; import org.jvnet.hk2.annotations.Service; import org.mvplugins.multiverse.core.MultiverseCore; @@ -50,6 +43,7 @@ import org.mvplugins.multiverse.core.permissions.CorePermissionsChecker; import org.mvplugins.multiverse.core.teleportation.AsyncSafetyTeleporter; import org.mvplugins.multiverse.core.teleportation.BlockSafety; +import org.mvplugins.multiverse.core.teleportation.SpawnRadiusLocator; import org.mvplugins.multiverse.core.teleportation.TeleportQueue; import org.mvplugins.multiverse.core.utils.result.ResultChain; import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld; @@ -65,18 +59,6 @@ */ @Service final class MVPlayerListener implements CoreListener { - /** - * How many locations to try when spreading a respawn around the world spawn, same as vanilla. - */ - private static final int MAX_RESPAWN_RADIUS_CANDIDATES = 1024; - - /** - * How many chunks around the spawn chunk may be loaded while looking for a respawn location. Candidates are - * checked synchronously while the respawn event is handled, so candidates in other chunks are only used if - * those chunks are already loaded. - */ - private static final int RESPAWN_RADIUS_LOADABLE_CHUNK_RANGE = 1; - private final Plugin plugin; private final CoreConfig config; private final Provider worldManagerProvider; @@ -91,6 +73,7 @@ final class MVPlayerListener implements CoreListener { private final DimensionFinder dimensionFinder; private final CorePermissionsChecker corePermissionsChecker; private final AsyncSafetyTeleporter asyncSafetyTeleporter; + private final SpawnRadiusLocator spawnRadiusLocator; @Inject MVPlayerListener( @@ -107,7 +90,8 @@ final class MVPlayerListener implements CoreListener { EnforcementHandler enforcementHandler, DimensionFinder dimensionFinder, CorePermissionsChecker corePermissionsChecker, - AsyncSafetyTeleporter asyncSafetyTeleporter) { + AsyncSafetyTeleporter asyncSafetyTeleporter, + SpawnRadiusLocator spawnRadiusLocator) { this.plugin = plugin; this.config = config; this.worldManagerProvider = worldManagerProvider; @@ -122,6 +106,7 @@ final class MVPlayerListener implements CoreListener { this.dimensionFinder = dimensionFinder; this.corePermissionsChecker = corePermissionsChecker; this.asyncSafetyTeleporter = asyncSafetyTeleporter; + this.spawnRadiusLocator = spawnRadiusLocator; } private WorldManager getWorldManager() { @@ -208,124 +193,10 @@ private Option getMostAccurateRespawnLocation(LoadedMultiverseWorld mv Logging.fine("Respawn location is within same world as respawn-world, not overriding."); return Option.none(); } - return Option.of(getSpawnLocationWithRespawnRadius(mvWorld, mvWorld.getSpawnLocation())); - } - - /** - * Spreads the respawn location around the world spawn like vanilla does. Multiverse would otherwise always - * respawn players exactly at the configured spawn location, bypassing the respawn radius game rule. - * - * @param mvWorld The world the player is respawning in. - * @param spawnLocation The configured spawn location of that world. - * @return A safe location within the respawn radius of the spawn, or the spawn location itself if none is found. - */ - Location getSpawnLocationWithRespawnRadius(LoadedMultiverseWorld mvWorld, Location spawnLocation) { - World world = spawnLocation.getWorld(); - if (world == null) { - return spawnLocation; - } - int respawnRadius = getRespawnRadius(world); - if (respawnRadius <= 0 || mvWorld.getGameMode() == GameMode.ADVENTURE) { - Logging.finer("Not spreading respawn location for world '%s' with respawn radius %d.", - mvWorld.getName(), respawnRadius); - return spawnLocation; - } - int spawnX = spawnLocation.getBlockX(); - int spawnZ = spawnLocation.getBlockZ(); - int radius = Math.max(1, Math.min(respawnRadius, getDistanceToBorder(world.getWorldBorder(), spawnX, spawnZ))); - Logging.finer("Spreading respawn location for world '%s' with radius %d.", mvWorld.getName(), radius); - - // Go through the candidates in a random order without repeats, like vanilla does. - int sideLength = radius * 2 + 1; - long candidateCount = (long) sideLength * sideLength; - long step = getCoprime(candidateCount); - long offset = ThreadLocalRandom.current().nextLong(candidateCount); - long attempts = Math.min(candidateCount, MAX_RESPAWN_RADIUS_CANDIDATES); - for (long i = 0; i < attempts; i++) { - long index = (offset + i * step) % candidateCount; - int x = spawnX + (int) (index % sideLength) - radius; - int z = spawnZ + (int) (index / sideLength) - radius; - if (!canCheckRespawnCandidate(world, spawnX, spawnZ, x, z)) { - continue; - } - Location candidate = getRespawnCandidate(world, spawnLocation, x, z); - if (candidate != null) { - Logging.fine("Using respawn location '%s' within radius %d of spawn for world '%s'.", - candidate, radius, mvWorld.getName()); - return candidate; - } - } - Logging.finer("No safe respawn location found within radius %d of spawn for world '%s'.", - radius, mvWorld.getName()); - return spawnLocation; - } - - private boolean canCheckRespawnCandidate(World world, int spawnX, int spawnZ, int x, int z) { - int chunkX = x >> 4; - int chunkZ = z >> 4; - return (Math.abs(chunkX - (spawnX >> 4)) <= RESPAWN_RADIUS_LOADABLE_CHUNK_RANGE - && Math.abs(chunkZ - (spawnZ >> 4)) <= RESPAWN_RADIUS_LOADABLE_CHUNK_RANGE) - || world.isChunkLoaded(chunkX, chunkZ); - } - - private @Nullable Location getRespawnCandidate(World world, Location spawnLocation, int x, int z) { - if (world.hasCeiling()) { - // The highest block would be the ceiling, so search up and down from the spawn height instead. - Location location = new Location(world, x + 0.5, spawnLocation.getBlockY(), z + 0.5, - spawnLocation.getYaw(), spawnLocation.getPitch()); - Location safeLocation = blockSafety.findSafeSpawnLocation(location, 0, - config.getSafeLocationVerticalSearchRadius()); - return safeLocation != null && !isInLiquid(safeLocation) ? safeLocation : null; - } - int y = world.getHighestBlockYAt(x, z, HeightMap.MOTION_BLOCKING) + 1; - if (y + 1 >= world.getMaxHeight()) { - return null; - } - Location location = new Location(world, x + 0.5, y, z + 0.5, - spawnLocation.getYaw(), spawnLocation.getPitch()); - return blockSafety.canSpawnAtLocationSafely(location) && !isInLiquid(location) ? location : null; - } - - private boolean isInLiquid(Location location) { - Block block = location.getBlock(); - return block.isLiquid() || block.getRelative(0, 1, 0).isLiquid(); - } - - private long getCoprime(long number) { - long coprime = 17; - while (gcd(coprime, number) != 1) { - coprime++; + if (config.getUseRespawnRadius()) { + return Option.of(spawnRadiusLocator.getLocationWithinSpawnRadius(mvWorld.getSpawnLocation(), mvWorld.getGameMode())); } - return coprime; - } - - private long gcd(long a, long b) { - return b == 0 ? a : gcd(b, a % b); - } - - private int getRespawnRadius(World world) { - // Older servers only have the spawnRadius game rule, while newer ones renamed it to respawn_radius. - GameRule respawnRadiusRule = GameRule.getByName("respawn_radius"); - if (respawnRadiusRule == null) { - respawnRadiusRule = GameRule.getByName("spawnRadius"); - } - if (respawnRadiusRule == null) { - return 0; - } - Object value = world.getGameRuleValue(respawnRadiusRule); - return value instanceof Integer ? Math.max(0, (Integer) value) : 0; - } - - private int getDistanceToBorder(WorldBorder worldBorder, int x, int z) { - double halfSize = worldBorder.getSize() / 2.0; - double minX = worldBorder.getCenter().getX() - halfSize; - double maxX = worldBorder.getCenter().getX() + halfSize; - double minZ = worldBorder.getCenter().getZ() - halfSize; - double maxZ = worldBorder.getCenter().getZ() + halfSize; - double distance = Math.min( - Math.min(x - minX, maxX - x), - Math.min(z - minZ, maxZ - z)); - return (int) Math.floor(distance); + return Option.of(mvWorld.getSpawnLocation()); } @EventMethod diff --git a/src/main/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocator.java b/src/main/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocator.java new file mode 100644 index 000000000..b3f6a02c6 --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocator.java @@ -0,0 +1,161 @@ +package org.mvplugins.multiverse.core.teleportation; + +import java.util.concurrent.ThreadLocalRandom; + +import com.dumptruckman.minecraft.util.Logging; +import jakarta.inject.Inject; +import org.bukkit.GameMode; +import org.bukkit.GameRule; +import org.bukkit.HeightMap; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.WorldBorder; +import org.bukkit.block.Block; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import org.jvnet.hk2.annotations.Service; + +/** + * Finds a location around a world's spawn based on the respawn radius game rule, similar to how vanilla spreads + * players around the world spawn when they respawn. + */ +@ApiStatus.AvailableSince("5.9") +@Service +public final class SpawnRadiusLocator { + /** + * How many locations to try when spreading around the spawn, same as vanilla. + */ + private static final int MAX_CANDIDATES = 1024; + + /** + * How many chunks around the spawn chunk may be loaded while looking for a location. Candidates are checked + * synchronously, so candidates in other chunks are only used if those chunks are already loaded. + */ + private static final int LOADABLE_CHUNK_RANGE = 1; + + private final BlockSafety blockSafety; + private final LocationManipulation locationManipulation; + + @Inject + SpawnRadiusLocator( + @NotNull BlockSafety blockSafety, + @NotNull LocationManipulation locationManipulation) { + this.blockSafety = blockSafety; + this.locationManipulation = locationManipulation; + } + + /** + * Gets a random safe location within the respawn radius game rule of the given spawn location. Same as vanilla, + * no spreading is done if the radius is 0, the game mode is adventure or the world has no sky light (e.g. the + * nether and the end). + * + * @param spawnLocation The spawn location to spread around. + * @param gameMode The game mode of the world. + * @return A safe location within the respawn radius of the spawn, or the spawn location itself if none is found. + */ + @ApiStatus.AvailableSince("5.9") + public @NotNull Location getLocationWithinSpawnRadius(@NotNull Location spawnLocation, @NotNull GameMode gameMode) { + World world = spawnLocation.getWorld(); + if (world == null) { + return spawnLocation; + } + int respawnRadius = getRespawnRadius(world); + if (respawnRadius <= 0 || gameMode == GameMode.ADVENTURE || !world.hasSkyLight()) { + Logging.finer("Not spreading spawn location for world '%s' with respawn radius %d, game mode %s" + + " and sky light %s.", world.getName(), respawnRadius, gameMode, world.hasSkyLight()); + return spawnLocation; + } + int spawnX = spawnLocation.getBlockX(); + int spawnZ = spawnLocation.getBlockZ(); + int radius = Math.max(1, Math.min(respawnRadius, getDistanceToBorder(world.getWorldBorder(), spawnX, spawnZ))); + Logging.finer("Spreading spawn location for world '%s' with radius %d.", world.getName(), radius); + + // Go through the candidates in a random order without repeats. A random step spreads the attempts over the + // whole area, so they are not limited to a few rows when the radius is large. + int sideLength = radius * 2 + 1; + long candidateCount = (long) sideLength * sideLength; + long step = getRandomCoprime(candidateCount); + long offset = ThreadLocalRandom.current().nextLong(candidateCount); + long attempts = Math.min(candidateCount, MAX_CANDIDATES); + for (long i = 0; i < attempts; i++) { + long index = (offset + i * step) % candidateCount; + int x = spawnX + (int) (index % sideLength) - radius; + int z = spawnZ + (int) (index / sideLength) - radius; + if (!canCheckCandidate(world, spawnX, spawnZ, x, z)) { + continue; + } + Location candidate = getCandidate(world, spawnLocation, x, z); + if (candidate != null) { + Logging.fine("Using location '%s' within radius %d of spawn for world '%s'.", + locationManipulation.locationToString(candidate), radius, world.getName()); + return candidate; + } + } + Logging.finer("No safe location found within radius %d of spawn for world '%s'.", radius, world.getName()); + return spawnLocation; + } + + private boolean canCheckCandidate(World world, int spawnX, int spawnZ, int x, int z) { + int chunkX = x >> 4; + int chunkZ = z >> 4; + return (Math.abs(chunkX - (spawnX >> 4)) <= LOADABLE_CHUNK_RANGE + && Math.abs(chunkZ - (spawnZ >> 4)) <= LOADABLE_CHUNK_RANGE) + || world.isChunkLoaded(chunkX, chunkZ); + } + + private @Nullable Location getCandidate(World world, Location spawnLocation, int x, int z) { + int y = world.getHighestBlockYAt(x, z, HeightMap.MOTION_BLOCKING) + 1; + if (y + 1 >= world.getMaxHeight()) { + return null; + } + Location location = new Location(world, x + 0.5, y, z + 0.5, + spawnLocation.getYaw(), spawnLocation.getPitch()); + return blockSafety.canSpawnAtLocationSafely(location) && !isInLiquid(location) ? location : null; + } + + private boolean isInLiquid(Location location) { + Block block = location.getBlock(); + return block.isLiquid() || block.getRelative(0, 1, 0).isLiquid(); + } + + private long getRandomCoprime(long number) { + if (number <= 2) { + return 1; + } + long coprime = ThreadLocalRandom.current().nextLong(1, number); + while (gcd(coprime, number) != 1) { + coprime = coprime % (number - 1) + 1; + } + return coprime; + } + + private long gcd(long a, long b) { + return b == 0 ? a : gcd(b, a % b); + } + + private int getRespawnRadius(World world) { + // Older servers only have the spawnRadius game rule, while newer ones renamed it to respawn_radius. + GameRule respawnRadiusRule = GameRule.getByName("respawn_radius"); + if (respawnRadiusRule == null) { + respawnRadiusRule = GameRule.getByName("spawnRadius"); + } + if (respawnRadiusRule == null) { + return 0; + } + Object value = world.getGameRuleValue(respawnRadiusRule); + return value instanceof Integer ? Math.max(0, (Integer) value) : 0; + } + + private int getDistanceToBorder(WorldBorder worldBorder, int x, int z) { + double halfSize = worldBorder.getSize() / 2.0; + double minX = worldBorder.getCenter().getX() - halfSize; + double maxX = worldBorder.getCenter().getX() + halfSize; + double minZ = worldBorder.getCenter().getZ() - halfSize; + double maxZ = worldBorder.getCenter().getZ() + halfSize; + double distance = Math.min( + Math.min(x - minX, maxX - x), + Math.min(z - minZ, maxZ - z)); + return (int) Math.floor(distance); + } +} diff --git a/src/test/java/org/mvplugins/multiverse/core/config/ConfigTest.kt b/src/test/java/org/mvplugins/multiverse/core/config/ConfigTest.kt index 9e9e90887..d11a2fffb 100644 --- a/src/test/java/org/mvplugins/multiverse/core/config/ConfigTest.kt +++ b/src/test/java/org/mvplugins/multiverse/core/config/ConfigTest.kt @@ -54,6 +54,25 @@ class ConfigTest : TestWithMockBukkit() { assertEquals(128, config.customPortalSearchRadius) assertEquals(true, config.isAutoPurgeEntities) assertEquals(false, config.isShowingDonateMessage) + assertEquals(false, config.useRespawnRadius) + } + + @Test + fun `Respawn radius is disabled when migrating an existing config`() { + val existingConfig = getResourceAsText("/configs/fresh_config.yml") + ?.replace(" use-respawn-radius: true\n", "") + ?.replace("version: 5.3", "version: 5.2") + assertNotNull(existingConfig) + configFile.writeText(existingConfig) + assertTrue(config.load().isSuccess) + assertTrue(config.save().isSuccess) + + assertEquals(false, config.useRespawnRadius) + } + + @Test + fun `Respawn radius is enabled for a fresh config`() { + assertEquals(true, config.useRespawnRadius) } @Test diff --git a/src/test/java/org/mvplugins/multiverse/core/listeners/RespawnRadiusTest.kt b/src/test/java/org/mvplugins/multiverse/core/listeners/RespawnRadiusTest.kt new file mode 100644 index 000000000..ae3c596bd --- /dev/null +++ b/src/test/java/org/mvplugins/multiverse/core/listeners/RespawnRadiusTest.kt @@ -0,0 +1,55 @@ +package org.mvplugins.multiverse.core.listeners + +import org.mvplugins.multiverse.core.TestWithMockBukkit +import org.mvplugins.multiverse.core.config.CoreConfig +import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld +import org.mvplugins.multiverse.core.world.WorldManager +import org.mvplugins.multiverse.core.world.options.CreateWorldOptions +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class RespawnRadiusTest : TestWithMockBukkit() { + + private lateinit var config: CoreConfig + private lateinit var mvWorld: LoadedMultiverseWorld + + @BeforeTest + fun setUpRespawnRadiusTest() { + config = requireNotNull(serviceLocator.getActiveService(CoreConfig::class.java)) + val worldManager = requireNotNull(serviceLocator.getActiveService(WorldManager::class.java)) + assertTrue(worldManager.createWorld(CreateWorldOptions.worldName("respawn_world")).isSuccess) + mvWorld = requireNotNull(worldManager.getLoadedWorld("respawn_world").getOrNull()) + @Suppress("DEPRECATION") + assertTrue(mvWorld.bukkitWorld.get().setGameRuleValue("respawn_radius", "5")) + } + + @Test + fun `Player respawns exactly at world spawn when respawn radius is disabled`() { + assertTrue(config.setUseRespawnRadius(false).isSuccess) + + repeat(10) { + assertEquals(mvWorld.spawnLocation, respawnPlayer().respawnLocation) + } + } + + @Test + fun `Player respawns around world spawn when respawn radius is enabled`() { + assertTrue(config.setUseRespawnRadius(true).isSuccess) + val spawnLocation = mvWorld.spawnLocation + + val results = (1..20).map { respawnPlayer().respawnLocation } + + results.forEach { result -> + assertTrue(result.blockX - spawnLocation.blockX in -5..5) + assertTrue(result.blockZ - spawnLocation.blockZ in -5..5) + } + assertTrue(results.any { it.blockX != spawnLocation.blockX || it.blockZ != spawnLocation.blockZ }) + } + + private fun respawnPlayer() = server.addPlayer().run { + teleport(mvWorld.spawnLocation) + respawn() + } +} diff --git a/src/test/java/org/mvplugins/multiverse/core/listeners/MVPlayerListenerTest.kt b/src/test/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocatorTest.kt similarity index 65% rename from src/test/java/org/mvplugins/multiverse/core/listeners/MVPlayerListenerTest.kt rename to src/test/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocatorTest.kt index da118fc2c..ea2c7e9f6 100644 --- a/src/test/java/org/mvplugins/multiverse/core/listeners/MVPlayerListenerTest.kt +++ b/src/test/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocatorTest.kt @@ -1,8 +1,8 @@ -package org.mvplugins.multiverse.core.listeners +package org.mvplugins.multiverse.core.teleportation import org.bukkit.GameMode +import org.bukkit.World import org.mvplugins.multiverse.core.TestWithMockBukkit -import org.mvplugins.multiverse.core.teleportation.BlockSafety import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld import org.mvplugins.multiverse.core.world.WorldManager import org.mvplugins.multiverse.core.world.options.CreateWorldOptions @@ -12,17 +12,15 @@ import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertTrue -class MVPlayerListenerTest : TestWithMockBukkit() { +class SpawnRadiusLocatorTest : TestWithMockBukkit() { - private lateinit var listener: MVPlayerListener + private lateinit var spawnRadiusLocator: SpawnRadiusLocator private lateinit var worldManager: WorldManager private lateinit var blockSafety: BlockSafety @BeforeTest - fun setUpRespawnTest() { - listener = serviceLocator.getAllActiveServices(CoreListener::class.java) - .filterIsInstance() - .first() + fun setUpSpawnRadiusTest() { + spawnRadiusLocator = requireNotNull(serviceLocator.getActiveService(SpawnRadiusLocator::class.java)) worldManager = requireNotNull(serviceLocator.getActiveService(WorldManager::class.java)) blockSafety = requireNotNull(serviceLocator.getActiveService(BlockSafety::class.java)) } @@ -32,7 +30,7 @@ class MVPlayerListenerTest : TestWithMockBukkit() { val mvWorld = createWorld("respawn_radius_zero", 0) val spawnLocation = mvWorld.spawnLocation - assertEquals(spawnLocation, listener.getSpawnLocationWithRespawnRadius(mvWorld, spawnLocation)) + assertEquals(spawnLocation, spawnRadiusLocator.getLocationWithinSpawnRadius(spawnLocation, mvWorld.gameMode)) } @Test @@ -41,7 +39,15 @@ class MVPlayerListenerTest : TestWithMockBukkit() { assertTrue(mvWorld.setGameMode(GameMode.ADVENTURE).isSuccess) val spawnLocation = mvWorld.spawnLocation - assertEquals(spawnLocation, listener.getSpawnLocationWithRespawnRadius(mvWorld, spawnLocation)) + assertEquals(spawnLocation, spawnRadiusLocator.getLocationWithinSpawnRadius(spawnLocation, mvWorld.gameMode)) + } + + @Test + fun `Respawn radius is not applied in worlds without sky light`() { + val mvWorld = createWorld("respawn_radius_nether", 5, World.Environment.NETHER) + val spawnLocation = mvWorld.spawnLocation + + assertEquals(spawnLocation, spawnRadiusLocator.getLocationWithinSpawnRadius(spawnLocation, mvWorld.gameMode)) } @Test @@ -50,7 +56,7 @@ class MVPlayerListenerTest : TestWithMockBukkit() { val mvWorld = createWorld("respawn_radius_five", radius) val spawnLocation = mvWorld.spawnLocation - val results = (1..20).map { listener.getSpawnLocationWithRespawnRadius(mvWorld, spawnLocation) } + val results = (1..20).map { spawnRadiusLocator.getLocationWithinSpawnRadius(spawnLocation, mvWorld.gameMode) } results.forEach { result -> assertEquals(spawnLocation.world, assertNotNull(result.world)) @@ -70,14 +76,18 @@ class MVPlayerListenerTest : TestWithMockBukkit() { worldBorder.size = 7.0 repeat(20) { - val result = listener.getSpawnLocationWithRespawnRadius(mvWorld, spawnLocation) + val result = spawnRadiusLocator.getLocationWithinSpawnRadius(spawnLocation, mvWorld.gameMode) assertTrue(worldBorder.isInside(result)) } } @Suppress("DEPRECATION") - private fun createWorld(name: String, respawnRadius: Int): LoadedMultiverseWorld { - assertTrue(worldManager.createWorld(CreateWorldOptions.worldName(name)).isSuccess) + private fun createWorld( + name: String, + respawnRadius: Int, + environment: World.Environment = World.Environment.NORMAL, + ): LoadedMultiverseWorld { + assertTrue(worldManager.createWorld(CreateWorldOptions.worldName(name).environment(environment)).isSuccess) val world = requireNotNull(worldManager.getLoadedWorld(name).getOrNull()) assertTrue(world.bukkitWorld.get().setGameRuleValue("respawn_radius", respawnRadius.toString())) return world diff --git a/src/test/resources/configs/fresh_config.yml b/src/test/resources/configs/fresh_config.yml index c63c10261..074d4ceab 100644 --- a/src/test/resources/configs/fresh_config.yml +++ b/src/test/resources/configs/fresh_config.yml @@ -29,6 +29,7 @@ spawn: default-respawn-in-overworld: true default-respawn-within-same-world: true enforce-respawn-at-world-spawn: true + use-respawn-radius: true portal: use-custom-portal-search: false @@ -67,4 +68,4 @@ misc: silent-start: false show-donation-message: true -version: 5.2 +version: 5.3 From 93a3a53a41a7f6d18ef86060751602a31c9562e5 Mon Sep 17 00:00:00 2001 From: spa77k <254093522+spa77k@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:03:34 +0900 Subject: [PATCH 3/3] Rename option to apply-respawn-radius and pass the world to SpawnRadiusLocator --- .../multiverse/core/config/CoreConfig.java | 10 ++--- .../core/config/CoreConfigNodes.java | 7 ++-- .../core/listeners/MVPlayerListener.java | 4 +- .../teleportation/SpawnRadiusLocator.java | 39 +++++++++++++++---- .../multiverse/core/config/ConfigTest.kt | 8 ++-- .../core/listeners/RespawnRadiusTest.kt | 4 +- .../teleportation/SpawnRadiusLocatorTest.kt | 26 ++++++++++--- src/test/resources/configs/fresh_config.yml | 2 +- 8 files changed, 70 insertions(+), 30 deletions(-) diff --git a/src/main/java/org/mvplugins/multiverse/core/config/CoreConfig.java b/src/main/java/org/mvplugins/multiverse/core/config/CoreConfig.java index f6c4d380f..cd7212bf7 100644 --- a/src/main/java/org/mvplugins/multiverse/core/config/CoreConfig.java +++ b/src/main/java/org/mvplugins/multiverse/core/config/CoreConfig.java @@ -84,7 +84,7 @@ public final class CoreConfig { .addAction(MoveMigratorAction.of("spawn.default-respawn-to-world-spawn", "world.enforce-respawn-at-world-spawn")) .build()) .addVersionMigrator(VersionMigrator.builder(5.3) - .addAction(SetMigratorAction.of("spawn.use-respawn-radius", false)) + .addAction(SetMigratorAction.of("spawn.apply-respawn-radius", false)) .build()) .build()) .build(); @@ -447,13 +447,13 @@ public boolean getEnforceRespawnAtWorldSpawn() { } @ApiStatus.AvailableSince("5.9") - public Try setUseRespawnRadius(boolean useRespawnRadius) { - return configHandle.set(configNodes.useRespawnRadius, useRespawnRadius); + public Try setApplyRespawnRadius(boolean applyRespawnRadius) { + return configHandle.set(configNodes.applyRespawnRadius, applyRespawnRadius); } @ApiStatus.AvailableSince("5.9") - public boolean getUseRespawnRadius() { - return configHandle.get(configNodes.useRespawnRadius); + public boolean getApplyRespawnRadius() { + return configHandle.get(configNodes.applyRespawnRadius); } /** diff --git a/src/main/java/org/mvplugins/multiverse/core/config/CoreConfigNodes.java b/src/main/java/org/mvplugins/multiverse/core/config/CoreConfigNodes.java index fd4564a1d..ee4b23ec4 100644 --- a/src/main/java/org/mvplugins/multiverse/core/config/CoreConfigNodes.java +++ b/src/main/java/org/mvplugins/multiverse/core/config/CoreConfigNodes.java @@ -363,16 +363,17 @@ private N node(N node) { .name("enforce-respawn-at-world-spawn") .build()); - final ConfigNode useRespawnRadius = node(ConfigNode.builder("spawn.use-respawn-radius", Boolean.class) + final ConfigNode applyRespawnRadius = node(ConfigNode.builder("spawn.apply-respawn-radius", Boolean.class) .comment("") .comment("When this option is enabled and players respawn at the world's spawn location,") .comment("they will be spread to a random safe location around it based on the `respawn_radius` game rule") .comment("(`spawnRadius` on older versions), mimicking the vanilla behavior.") - .comment("Same as vanilla, this does not apply if the respawn radius is 0 or the world's game mode is adventure.") + .comment("Same as vanilla, this does not apply if the respawn radius is 0, the world's game mode is adventure") + .comment("or the world has no sky light (e.g. the nether and the end).") .comment("----") .comment("Set this to false if you want players to always respawn exactly at the world's spawn location.") .defaultValue(true) - .name("use-respawn-radius") + .name("apply-respawn-radius") .build()); private final ConfigHeaderNode portalHeader = node(ConfigHeaderNode.builder("portal") diff --git a/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java b/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java index bb3942af0..73d90c293 100644 --- a/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java +++ b/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java @@ -193,8 +193,8 @@ private Option getMostAccurateRespawnLocation(LoadedMultiverseWorld mv Logging.fine("Respawn location is within same world as respawn-world, not overriding."); return Option.none(); } - if (config.getUseRespawnRadius()) { - return Option.of(spawnRadiusLocator.getLocationWithinSpawnRadius(mvWorld.getSpawnLocation(), mvWorld.getGameMode())); + if (config.getApplyRespawnRadius()) { + return Option.of(spawnRadiusLocator.getLocationWithinSpawnRadius(mvWorld)); } return Option.of(mvWorld.getSpawnLocation()); } diff --git a/src/main/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocator.java b/src/main/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocator.java index b3f6a02c6..042b4a18a 100644 --- a/src/main/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocator.java +++ b/src/main/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocator.java @@ -15,6 +15,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import org.jvnet.hk2.annotations.Service; +import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld; /** * Finds a location around a world's spawn based on the respawn radius game rule, similar to how vanilla spreads @@ -46,24 +47,46 @@ public final class SpawnRadiusLocator { } /** - * Gets a random safe location within the respawn radius game rule of the given spawn location. Same as vanilla, - * no spreading is done if the radius is 0, the game mode is adventure or the world has no sky light (e.g. the - * nether and the end). + * Gets a random safe location within the respawn radius game rule around the given world's spawn location. Same + * as vanilla, no spreading is done if the radius is 0, the world's game mode is adventure or the world has no sky + * light (e.g. the nether and the end). + * + * @param world The world to get the location around its spawn for. + * @return A safe location within the respawn radius of the spawn, or the spawn location itself if none is found. + */ + @ApiStatus.AvailableSince("5.9") + public @NotNull Location getLocationWithinSpawnRadius(@NotNull LoadedMultiverseWorld world) { + Location spawnLocation = world.getSpawnLocation(); + World bukkitWorld = spawnLocation.getWorld(); + if (bukkitWorld == null) { + return spawnLocation; + } + if (world.getGameMode() == GameMode.ADVENTURE || !bukkitWorld.hasSkyLight()) { + Logging.finer("Not spreading spawn location for world '%s' with game mode %s and sky light %s.", + world.getName(), world.getGameMode(), bukkitWorld.hasSkyLight()); + return spawnLocation; + } + return getLocationWithinSpawnRadius(spawnLocation); + } + + /** + * Gets a random safe location within the respawn radius game rule of the given spawn location. Unlike + * {@link #getLocationWithinSpawnRadius(LoadedMultiverseWorld)}, the game mode and sky light of the world are not + * checked, so the location is spread whenever the radius is greater than 0. * * @param spawnLocation The spawn location to spread around. - * @param gameMode The game mode of the world. * @return A safe location within the respawn radius of the spawn, or the spawn location itself if none is found. */ @ApiStatus.AvailableSince("5.9") - public @NotNull Location getLocationWithinSpawnRadius(@NotNull Location spawnLocation, @NotNull GameMode gameMode) { + public @NotNull Location getLocationWithinSpawnRadius(@NotNull Location spawnLocation) { World world = spawnLocation.getWorld(); if (world == null) { return spawnLocation; } int respawnRadius = getRespawnRadius(world); - if (respawnRadius <= 0 || gameMode == GameMode.ADVENTURE || !world.hasSkyLight()) { - Logging.finer("Not spreading spawn location for world '%s' with respawn radius %d, game mode %s" - + " and sky light %s.", world.getName(), respawnRadius, gameMode, world.hasSkyLight()); + if (respawnRadius <= 0) { + Logging.finer("Not spreading spawn location for world '%s' with respawn radius %d.", + world.getName(), respawnRadius); return spawnLocation; } int spawnX = spawnLocation.getBlockX(); diff --git a/src/test/java/org/mvplugins/multiverse/core/config/ConfigTest.kt b/src/test/java/org/mvplugins/multiverse/core/config/ConfigTest.kt index d11a2fffb..2fb892490 100644 --- a/src/test/java/org/mvplugins/multiverse/core/config/ConfigTest.kt +++ b/src/test/java/org/mvplugins/multiverse/core/config/ConfigTest.kt @@ -54,25 +54,25 @@ class ConfigTest : TestWithMockBukkit() { assertEquals(128, config.customPortalSearchRadius) assertEquals(true, config.isAutoPurgeEntities) assertEquals(false, config.isShowingDonateMessage) - assertEquals(false, config.useRespawnRadius) + assertEquals(false, config.applyRespawnRadius) } @Test fun `Respawn radius is disabled when migrating an existing config`() { val existingConfig = getResourceAsText("/configs/fresh_config.yml") - ?.replace(" use-respawn-radius: true\n", "") + ?.replace(" apply-respawn-radius: true\n", "") ?.replace("version: 5.3", "version: 5.2") assertNotNull(existingConfig) configFile.writeText(existingConfig) assertTrue(config.load().isSuccess) assertTrue(config.save().isSuccess) - assertEquals(false, config.useRespawnRadius) + assertEquals(false, config.applyRespawnRadius) } @Test fun `Respawn radius is enabled for a fresh config`() { - assertEquals(true, config.useRespawnRadius) + assertEquals(true, config.applyRespawnRadius) } @Test diff --git a/src/test/java/org/mvplugins/multiverse/core/listeners/RespawnRadiusTest.kt b/src/test/java/org/mvplugins/multiverse/core/listeners/RespawnRadiusTest.kt index ae3c596bd..fe857b512 100644 --- a/src/test/java/org/mvplugins/multiverse/core/listeners/RespawnRadiusTest.kt +++ b/src/test/java/org/mvplugins/multiverse/core/listeners/RespawnRadiusTest.kt @@ -27,7 +27,7 @@ class RespawnRadiusTest : TestWithMockBukkit() { @Test fun `Player respawns exactly at world spawn when respawn radius is disabled`() { - assertTrue(config.setUseRespawnRadius(false).isSuccess) + assertTrue(config.setApplyRespawnRadius(false).isSuccess) repeat(10) { assertEquals(mvWorld.spawnLocation, respawnPlayer().respawnLocation) @@ -36,7 +36,7 @@ class RespawnRadiusTest : TestWithMockBukkit() { @Test fun `Player respawns around world spawn when respawn radius is enabled`() { - assertTrue(config.setUseRespawnRadius(true).isSuccess) + assertTrue(config.setApplyRespawnRadius(true).isSuccess) val spawnLocation = mvWorld.spawnLocation val results = (1..20).map { respawnPlayer().respawnLocation } diff --git a/src/test/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocatorTest.kt b/src/test/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocatorTest.kt index ea2c7e9f6..564ca62e6 100644 --- a/src/test/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocatorTest.kt +++ b/src/test/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocatorTest.kt @@ -30,7 +30,7 @@ class SpawnRadiusLocatorTest : TestWithMockBukkit() { val mvWorld = createWorld("respawn_radius_zero", 0) val spawnLocation = mvWorld.spawnLocation - assertEquals(spawnLocation, spawnRadiusLocator.getLocationWithinSpawnRadius(spawnLocation, mvWorld.gameMode)) + assertEquals(spawnLocation, spawnRadiusLocator.getLocationWithinSpawnRadius(mvWorld)) } @Test @@ -39,7 +39,7 @@ class SpawnRadiusLocatorTest : TestWithMockBukkit() { assertTrue(mvWorld.setGameMode(GameMode.ADVENTURE).isSuccess) val spawnLocation = mvWorld.spawnLocation - assertEquals(spawnLocation, spawnRadiusLocator.getLocationWithinSpawnRadius(spawnLocation, mvWorld.gameMode)) + assertEquals(spawnLocation, spawnRadiusLocator.getLocationWithinSpawnRadius(mvWorld)) } @Test @@ -47,7 +47,7 @@ class SpawnRadiusLocatorTest : TestWithMockBukkit() { val mvWorld = createWorld("respawn_radius_nether", 5, World.Environment.NETHER) val spawnLocation = mvWorld.spawnLocation - assertEquals(spawnLocation, spawnRadiusLocator.getLocationWithinSpawnRadius(spawnLocation, mvWorld.gameMode)) + assertEquals(spawnLocation, spawnRadiusLocator.getLocationWithinSpawnRadius(mvWorld)) } @Test @@ -56,7 +56,7 @@ class SpawnRadiusLocatorTest : TestWithMockBukkit() { val mvWorld = createWorld("respawn_radius_five", radius) val spawnLocation = mvWorld.spawnLocation - val results = (1..20).map { spawnRadiusLocator.getLocationWithinSpawnRadius(spawnLocation, mvWorld.gameMode) } + val results = (1..20).map { spawnRadiusLocator.getLocationWithinSpawnRadius(mvWorld) } results.forEach { result -> assertEquals(spawnLocation.world, assertNotNull(result.world)) @@ -67,6 +67,22 @@ class SpawnRadiusLocatorTest : TestWithMockBukkit() { assertTrue(results.any { it.blockX != spawnLocation.blockX || it.blockZ != spawnLocation.blockZ }) } + @Test + fun `Spreading from a location does not check the game mode`() { + val radius = 5 + val mvWorld = createWorld("respawn_radius_location", radius) + assertTrue(mvWorld.setGameMode(GameMode.ADVENTURE).isSuccess) + val spawnLocation = mvWorld.spawnLocation + + val results = (1..20).map { spawnRadiusLocator.getLocationWithinSpawnRadius(spawnLocation) } + + results.forEach { result -> + assertTrue(result.blockX - spawnLocation.blockX in -radius..radius) + assertTrue(result.blockZ - spawnLocation.blockZ in -radius..radius) + } + assertTrue(results.any { it.blockX != spawnLocation.blockX || it.blockZ != spawnLocation.blockZ }) + } + @Test fun `Respawn radius is limited by the world border`() { val mvWorld = createWorld("respawn_radius_border", 50) @@ -76,7 +92,7 @@ class SpawnRadiusLocatorTest : TestWithMockBukkit() { worldBorder.size = 7.0 repeat(20) { - val result = spawnRadiusLocator.getLocationWithinSpawnRadius(spawnLocation, mvWorld.gameMode) + val result = spawnRadiusLocator.getLocationWithinSpawnRadius(mvWorld) assertTrue(worldBorder.isInside(result)) } } diff --git a/src/test/resources/configs/fresh_config.yml b/src/test/resources/configs/fresh_config.yml index 074d4ceab..a9aa09f11 100644 --- a/src/test/resources/configs/fresh_config.yml +++ b/src/test/resources/configs/fresh_config.yml @@ -29,7 +29,7 @@ spawn: default-respawn-in-overworld: true default-respawn-within-same-world: true enforce-respawn-at-world-spawn: true - use-respawn-radius: true + apply-respawn-radius: true portal: use-custom-portal-search: false