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..cd7212bf7 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.apply-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 setApplyRespawnRadius(boolean applyRespawnRadius) { + return configHandle.set(configNodes.applyRespawnRadius, applyRespawnRadius); + } + + @ApiStatus.AvailableSince("5.9") + public boolean getApplyRespawnRadius() { + return configHandle.get(configNodes.applyRespawnRadius); + } + /** * {@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..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,6 +363,19 @@ private N node(N node) { .name("enforce-respawn-at-world-spawn") .build()); + 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, 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("apply-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 6d2582680..73d90c293 100644 --- a/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java +++ b/src/main/java/org/mvplugins/multiverse/core/listeners/MVPlayerListener.java @@ -43,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; @@ -72,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( @@ -88,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; @@ -103,6 +106,7 @@ final class MVPlayerListener implements CoreListener { this.dimensionFinder = dimensionFinder; this.corePermissionsChecker = corePermissionsChecker; this.asyncSafetyTeleporter = asyncSafetyTeleporter; + this.spawnRadiusLocator = spawnRadiusLocator; } private WorldManager getWorldManager() { @@ -189,6 +193,9 @@ private Option getMostAccurateRespawnLocation(LoadedMultiverseWorld mv Logging.fine("Respawn location is within same world as respawn-world, not overriding."); return Option.none(); } + 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 new file mode 100644 index 000000000..042b4a18a --- /dev/null +++ b/src/main/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocator.java @@ -0,0 +1,184 @@ +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; +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 + * 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 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. + * @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) { + World world = spawnLocation.getWorld(); + if (world == null) { + return spawnLocation; + } + int respawnRadius = getRespawnRadius(world); + 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(); + 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..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,6 +54,25 @@ class ConfigTest : TestWithMockBukkit() { assertEquals(128, config.customPortalSearchRadius) assertEquals(true, config.isAutoPurgeEntities) assertEquals(false, config.isShowingDonateMessage) + assertEquals(false, config.applyRespawnRadius) + } + + @Test + fun `Respawn radius is disabled when migrating an existing config`() { + val existingConfig = getResourceAsText("/configs/fresh_config.yml") + ?.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.applyRespawnRadius) + } + + @Test + fun `Respawn radius is enabled for a fresh config`() { + 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 new file mode 100644 index 000000000..fe857b512 --- /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.setApplyRespawnRadius(false).isSuccess) + + repeat(10) { + assertEquals(mvWorld.spawnLocation, respawnPlayer().respawnLocation) + } + } + + @Test + fun `Player respawns around world spawn when respawn radius is enabled`() { + assertTrue(config.setApplyRespawnRadius(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/teleportation/SpawnRadiusLocatorTest.kt b/src/test/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocatorTest.kt new file mode 100644 index 000000000..564ca62e6 --- /dev/null +++ b/src/test/java/org/mvplugins/multiverse/core/teleportation/SpawnRadiusLocatorTest.kt @@ -0,0 +1,111 @@ +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.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 SpawnRadiusLocatorTest : TestWithMockBukkit() { + + private lateinit var spawnRadiusLocator: SpawnRadiusLocator + private lateinit var worldManager: WorldManager + private lateinit var blockSafety: BlockSafety + + @BeforeTest + fun setUpSpawnRadiusTest() { + spawnRadiusLocator = requireNotNull(serviceLocator.getActiveService(SpawnRadiusLocator::class.java)) + 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, spawnRadiusLocator.getLocationWithinSpawnRadius(mvWorld)) + } + + @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, spawnRadiusLocator.getLocationWithinSpawnRadius(mvWorld)) + } + + @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(mvWorld)) + } + + @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 { spawnRadiusLocator.getLocationWithinSpawnRadius(mvWorld) } + + 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 `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) + val spawnLocation = mvWorld.spawnLocation + val worldBorder = spawnLocation.world.worldBorder + worldBorder.center = spawnLocation + worldBorder.size = 7.0 + + repeat(20) { + val result = spawnRadiusLocator.getLocationWithinSpawnRadius(mvWorld) + assertTrue(worldBorder.isInside(result)) + } + } + + @Suppress("DEPRECATION") + 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..a9aa09f11 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 + apply-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