diff --git a/src/main/java/de/btegermany/terraplusminus/Terraplusminus.java b/src/main/java/de/btegermany/terraplusminus/Terraplusminus.java index fa80c51..d9a4b86 100644 --- a/src/main/java/de/btegermany/terraplusminus/Terraplusminus.java +++ b/src/main/java/de/btegermany/terraplusminus/Terraplusminus.java @@ -11,7 +11,10 @@ import de.btegermany.terraplusminus.utils.FileBuilder; import de.btegermany.terraplusminus.utils.PlayerHashMapManagement; import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.command.*; import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.entity.Entity; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.world.WorldInitEvent; @@ -21,6 +24,9 @@ import java.io.*; import java.util.logging.Level; +import java.util.logging.Logger; + +import static java.lang.String.format; public final class Terraplusminus extends JavaPlugin implements Listener { public static FileConfiguration config; @@ -76,9 +82,9 @@ public void onEnable() { // -------------------------- // Registering commands - getCommand("tpll").setExecutor(new TpllCommand()); - getCommand("where").setExecutor(new WhereCommand()); - getCommand("offset").setExecutor(new OffsetCommand()); + this.registerCommand("tpll", new TpllCommand()); + this.registerCommand("where", new WhereCommand()); + this.registerCommand("offset", new OffsetCommand()); // -------------------------- Bukkit.getLogger().log(Level.INFO, "[T+-] Terraplusminus successfully enabled"); @@ -190,4 +196,27 @@ private void updateConfig() { } } + private void registerCommand(String commandName, CommandExecutor executor) { + Logger logger = this.getLogger(); + PluginCommand command = this.getCommand(commandName); + if (command == null) { + logger.warning(format("Could not register command %s. Command is unknown to bukkit, has it been properly registered in plugin.yml?", commandName)); + return; + } + command.setExecutor(executor); + logger.fine(format("Registered command executor for /%s command", commandName)); + if (executor instanceof TabCompleter) { + command.setTabCompleter((TabCompleter) executor); + logger.fine(format("Registered tab completer for /%s command", commandName)); + } + } + + public static boolean isTerraWorld(World world) { + return world.getGenerator() instanceof RealWorldGenerator; + } + + public static boolean isInTerraWorld(Entity entity) { + return isTerraWorld(entity.getWorld()); + } + } \ No newline at end of file diff --git a/src/main/java/de/btegermany/terraplusminus/commands/CommandHelper.java b/src/main/java/de/btegermany/terraplusminus/commands/CommandHelper.java new file mode 100644 index 0000000..251540e --- /dev/null +++ b/src/main/java/de/btegermany/terraplusminus/commands/CommandHelper.java @@ -0,0 +1,97 @@ +package de.btegermany.terraplusminus.commands; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.NotNull; + +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static java.util.Comparator.comparing; +import static java.util.Objects.requireNonNullElseGet; + +/** + * Utility class to help with commands. + * + * @author Smyler + */ +public final class CommandHelper { + + /** + * Parses a command target selector like '@a'. + * Currently supported selectors: + * + * Target selector arguments are not supported. + * It's a shame that Bukkit does not expose the vanilla command system... + * + * @param sender the {@link CommandSender command sender} + * @param selector the selector string + * @return the collection of {@link Entity entities} that match the selector + * + * @throws InvalidTargetSelectorException if the selector is invalid, either syntactically or in the specific context + */ + public static Collection parseTargetSelector(@NotNull CommandSender sender, String selector) throws InvalidTargetSelectorException { + if (selector.startsWith("@") && selector.length() >= 2) { + char selectorChar = selector.charAt(1); + if (selectorChar == 'a') { + return Bukkit.getOnlinePlayers().stream() + .map(p -> (Entity)p) + .collect(Collectors.toList()); + } else if (selectorChar == 'p' && sender instanceof Entity) { + Entity entitySender = (Entity) sender; + Location senderLocation = entitySender.getLocation(); + return Collections.singleton( + Bukkit.getOnlinePlayers().stream() + .filter(p -> p != sender) + .min(comparing(p -> p.getLocation().distanceSquared(senderLocation))) + .orElseThrow(InvalidTargetSelectorException::new) + ); + } + } else { + Player player = Bukkit.getPlayerExact(selector); + if (player == null || !player.isOnline()) { + throw new InvalidTargetSelectorException(); + } else { + return List.of(player); + } + } + + throw new InvalidTargetSelectorException(); + + } + + /** + * Checks if a collection of entities is comprised solely of a single command sender (which may appear multiple times). + * This is useful for permission checks where one might allow a sender to execute a command on themselves, + * but not on other entities. + * + * @param sender the {@link CommandSender sender} executing the command + * @param targets the {@link Collection collection} of targets + * + * @return whether a {@link CommandSender sender} is the only entity in a {@link Collection collection} of targets + */ + public static boolean senderIsSoleTarget(CommandSender sender, @NotNull Collection targets) { + return !targets.stream().allMatch(target -> target == sender); + } + + public static String formatTargetName(Entity target) { + return requireNonNullElseGet(target.getCustomName(), target::getName); + } + + public static class InvalidTargetSelectorException extends Exception { + + } + + private CommandHelper() { + throw new IllegalStateException(); + } + +} diff --git a/src/main/java/de/btegermany/terraplusminus/commands/OffsetCommand.java b/src/main/java/de/btegermany/terraplusminus/commands/OffsetCommand.java index 64e7a4d..3077421 100644 --- a/src/main/java/de/btegermany/terraplusminus/commands/OffsetCommand.java +++ b/src/main/java/de/btegermany/terraplusminus/commands/OffsetCommand.java @@ -1,27 +1,92 @@ package de.btegermany.terraplusminus.commands; import de.btegermany.terraplusminus.Terraplusminus; +import de.btegermany.terraplusminus.gen.RealWorldGenerator; +import net.buildtheearth.terraminusminus.projection.GeographicProjection; +import net.buildtheearth.terraminusminus.projection.transform.OffsetProjectionTransform; +import org.bukkit.Bukkit; +import org.bukkit.World; import org.bukkit.command.Command; -import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; import org.bukkit.entity.Player; +import org.bukkit.generator.ChunkGenerator; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.List; + +import static java.lang.Math.round; +import static java.util.Collections.emptyList; +import static java.util.stream.Collectors.toList; +import static org.bukkit.ChatColor.*; + +public class OffsetCommand implements TabExecutor { -public class OffsetCommand implements CommandExecutor { @Override - public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] strings) { - if (command.getName().equalsIgnoreCase("offset")) { - Player player = (Player) commandSender; - if (player.hasPermission("t+-.offset")) { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7Offsets:"); - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7 | X: §8" + Terraplusminus.config.getInt("terrain_offset.x")); - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7 | Y: §8" + Terraplusminus.config.getInt("terrain_offset.y")); - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7 | Z: §8" + Terraplusminus.config.getInt("terrain_offset.z")); - } else { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7No permission for /offset"); - return true; - } + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String alias, @NotNull String... arguments) { + + String prefix = Terraplusminus.config.getString("prefix"); + + if (!sender.hasPermission("t+-.offset")) { + sender.sendMessage(Terraplusminus.config.getString("prefix") + GRAY + "No permission for /offset"); + return true; + } + + World world; + if (arguments.length == 1) { + String worldName = arguments[0]; + world = Bukkit.getWorld(worldName); + } else if (arguments.length == 0 && sender instanceof Player) { + Player player = (Player) sender; + world = player.getWorld(); + } else { + return false; + } + + if (world == null) { + sender.sendMessage(prefix + RED + "No such world"); + return true; + } + + ChunkGenerator generator = world.getGenerator(); + if (!(generator instanceof RealWorldGenerator)) { + sender.sendMessage(prefix + GRAY + world.getName() + RED + " is not a Terra+- world"); + return true; + } + RealWorldGenerator realWorldGenerator = (RealWorldGenerator) generator; + GeographicProjection projection = realWorldGenerator.getSettings().projection(); + + int offsetY = realWorldGenerator.getYOffset(); + int offsetX, offsetZ; + if (projection instanceof OffsetProjectionTransform) { + OffsetProjectionTransform transform = (OffsetProjectionTransform) projection; + // We assume there is ony one offset transform, or that it is the only one the user cares about + // We can safely round to int as we are only dealing with blocks at that scale + offsetX = (int) round(transform.dx()); + offsetZ = (int) round(transform.dy()); + } else { + offsetX = offsetZ = 0; } + + sender.sendMessage(prefix + GRAY + "Offsets for world \"" + DARK_GRAY + world.getName() + GRAY + "\":"); + sender.sendMessage(prefix + GRAY + " | X: " + DARK_GRAY + offsetX); + sender.sendMessage(prefix + GRAY + " | Y: " + DARK_GRAY + offsetY); + sender.sendMessage(prefix + GRAY + " | Z: " + DARK_GRAY + offsetZ); return true; } + + @Nullable + @Override + public List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String alias, @NotNull String[] args) { + if (args.length > 1 || !sender.hasPermission("t+-.offset")) { + return emptyList(); + } + + return Bukkit.getWorlds().stream() + .filter(Terraplusminus::isTerraWorld) + .map(World::getName) + .collect(toList()); + } + } diff --git a/src/main/java/de/btegermany/terraplusminus/commands/TpllCommand.java b/src/main/java/de/btegermany/terraplusminus/commands/TpllCommand.java index e585019..e496301 100644 --- a/src/main/java/de/btegermany/terraplusminus/commands/TpllCommand.java +++ b/src/main/java/de/btegermany/terraplusminus/commands/TpllCommand.java @@ -3,226 +3,367 @@ import com.google.common.io.ByteArrayDataOutput; import com.google.common.io.ByteStreams; import de.btegermany.terraplusminus.Terraplusminus; -import de.btegermany.terraplusminus.data.TerraConnector; +import de.btegermany.terraplusminus.gen.RealWorldGenerator; import de.btegermany.terraplusminus.utils.PluginMessageUtil; -import io.papermc.lib.PaperLib; -import net.buildtheearth.terraminusminus.generator.EarthGeneratorSettings; +import net.buildtheearth.terraminusminus.projection.GeographicProjection; import net.buildtheearth.terraminusminus.projection.OutOfProjectionBoundsException; +import net.buildtheearth.terraminusminus.util.geo.LatLng; +import org.bukkit.Bukkit; import org.bukkit.Location; -import org.bukkit.command.Command; -import org.bukkit.command.CommandExecutor; -import org.bukkit.command.CommandSender; +import org.bukkit.World; +import org.bukkit.command.*; +import org.bukkit.entity.Entity; import org.bukkit.entity.Player; +import org.bukkit.generator.ChunkGenerator; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; -public class TpllCommand implements CommandExecutor { +import java.text.DecimalFormat; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; - private final EarthGeneratorSettings bteGeneratorSettings = EarthGeneratorSettings.parse(EarthGeneratorSettings.BTE_DEFAULT_SETTINGS); +import static com.google.common.base.Strings.isNullOrEmpty; +import static de.btegermany.terraplusminus.Terraplusminus.isInTerraWorld; +import static de.btegermany.terraplusminus.commands.CommandHelper.*; +import static io.papermc.lib.PaperLib.isChunkGenerated; +import static io.papermc.lib.PaperLib.teleportAsync; +import static java.lang.Double.isNaN; +import static java.lang.Double.parseDouble; +import static java.lang.String.join; +import static java.util.Arrays.copyOfRange; +import static java.util.Collections.emptyList; +import static java.util.Collections.singletonList; +import static java.util.stream.Collectors.toList; +import static net.buildtheearth.terraminusminus.util.geo.CoordinateParseUtils.parseVerbatimCoordinates; +import static org.bukkit.ChatColor.*; + + +public class TpllCommand implements TabExecutor { + + private static final DecimalFormat DECIMAL_FORMATTER = new DecimalFormat("##.#####"); @Override - public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] args) { - if (command.getName().equalsIgnoreCase("tpll")) { - //If sender is not a player cancel the command. - if (!(commandSender instanceof Player)) { - commandSender.sendMessage("This command can only be used by players!"); - return true; + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String alias, @NotNull String[] arguments) { + + if (!sender.hasPermission("t+-.tpll")) { + sender.sendMessage(RED + "You do not have permission to use that command"); + return true; + } + + final String prefix = Terraplusminus.config.getString("prefix"); // Used in feedback messages + + // Option to pass through tpll to other bukkit plugins. + String passthroughTpll = Terraplusminus.config.getString("passthrough_tpll"); + if (!isNullOrEmpty(passthroughTpll)) { + Terraplusminus.instance.getServer().dispatchCommand( + sender, + passthroughTpll + ":tpll " + join(" ", arguments) + ); + return true; + } + + int consumedArguments = 0; // Number of consumed arguments, starting left + + // Parse targets first. + // They were either explicitly provided in the first argument, or it's just the sender + Collection targets = null; + if (arguments.length > 1) { + try { + targets = parseTargetSelector(sender, arguments[consumedArguments]); + consumedArguments++; + } catch (InvalidTargetSelectorException ignored) { + // Not a valid selector, probably coordinates } - Player player = (Player) commandSender; - - // Entity selector - - // detect if command starts with @ or with a player name - - if ((args[0].startsWith("@") || !isDouble(args[0].replace(",", ""))) && player.hasPermission("t+-.forcetpll")) { - if (args[0].equals("@a")) { - StringBuilder playerList = new StringBuilder(); - Terraplusminus.instance.getServer().getOnlinePlayers().forEach(p -> { - p.chat("/tpll " + String.join(" ", args).substring(2)); - if (Terraplusminus.instance.getServer().getOnlinePlayers().size() > 1) { - playerList.append(p.getName()).append(", "); - } else { - playerList.append(p.getName()).append(" "); - } - }); - // delete last comma if no player follows - if (playerList.length() > 0 && playerList.charAt(playerList.length() - 2) == ',') { - playerList.deleteCharAt(playerList.length() - 2); - } - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7Teleported §9" + playerList + "§7to" + String.join(" ", args).substring(2)); - return true; - } else if (args[0].equals("@p")) { - // find nearest player but not the player itself - Player nearestPlayer = null; - double nearestDistance = Double.MAX_VALUE; - for (Player p : Terraplusminus.instance.getServer().getOnlinePlayers()) { - if (p.getLocation().distanceSquared(player.getLocation()) < nearestDistance && (!p.equals(player) || Terraplusminus.instance.getServer().getOnlinePlayers().size() == 1)) { - nearestPlayer = p; - nearestDistance = p.getLocation().distanceSquared(player.getLocation()); - } - } - if (nearestPlayer != null) { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7Teleported §9" + nearestPlayer.getName() + " §7to" + String.join(" ", args).substring(2)); - nearestPlayer.chat("/tpll " + String.join(" ", args).substring(2)); - } - return true; - } else { - Player target = null; - //check if target player is online - for (Player p : Terraplusminus.instance.getServer().getOnlinePlayers()) { - if (p.getName().equals(args[0])) { - target = p; - } - } - - if (target == null) { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§cNo player found with name §9" + args[0]); - return true; - } - - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7Teleported §9" + target.getName() + " §7to " + args[1] + " " + args[2]); - target.chat("/tpll " + String.join(" ", args).replace(target.getName(), "")); - return true; - } + } + if (targets == null) { + if (sender instanceof Player) { + targets = Collections.singleton((Player) sender); + } else { + return false; // Invalid command, non player senders must specify a valid target } + } else if (!sender.hasPermission("t+-.forcetpll") && !senderIsSoleTarget(sender, targets)){ + // Sender specified a target which is not themselves + sender.sendMessage( + prefix + + RED + "You do not have permission to use TPLL on others" + ); + return true; + } - //Option to passthrough tpll to other bukkit plugins. - String passthroughTpll = Terraplusminus.config.getString("passthrough_tpll"); - if (!passthroughTpll.isEmpty()) { - //Check if any args are parsed. - if (args.length == 0) { - player.chat("/" + passthroughTpll + ":tpll"); - } else { - player.chat("/" + passthroughTpll + ":tpll " + String.join(" ", args)); - } - return true; + // Now, parse destination latitude and longitude. + // This is trickier, as they may span multiple arguments as we delegate parsing to Terra--. + // We start left and arguments, stopping as soon as we found the first valid set of coordinates. + LatLng searchingLocation = null; + final LatLng geoLocation; // We are passing it to a lambda later, so it needs to be final + int i = consumedArguments; + for (; i < arguments.length && searchingLocation == null; i++) { + String rawArgumentString = join(" ", copyOfRange(arguments, consumedArguments, i + 1)); + searchingLocation = parseVerbatimCoordinates(rawArgumentString); + } + if (searchingLocation == null) { + // No valid position, command is invalid + return false; + } else { + geoLocation = searchingLocation; + } + consumedArguments = i; + + // Parse optional destination altitude + // If arguments remain after that, the command is invalid + double altitude; + if (consumedArguments == arguments.length - 1) { + // An altitude was passed in + try { + altitude = parseDouble(arguments[arguments.length - 1]); + } catch (NumberFormatException e) { + // Altitude is not valid + return false; } + } else if (consumedArguments == arguments.length){ + // We will calculate the world height for each target latter (they may be in different worlds) + altitude = Double.NaN; + } else { + // Arguments would remain, syntax is wrong + return false; + } - // - - if (args.length >= 2) { - if (player.hasPermission("t+-.tpll")) { - - int xOffset = Terraplusminus.config.getInt("terrain_offset.x"); - int yOffset = Terraplusminus.config.getInt("terrain_offset.y"); - int zOffset = Terraplusminus.config.getInt("terrain_offset.z"); - Double minLat = Terraplusminus.config.getDouble("min_latitude"); - Double maxLat = Terraplusminus.config.getDouble("max_latitude"); - Double minLon = Terraplusminus.config.getDouble("min_longitude"); - Double maxLon = Terraplusminus.config.getDouble("max_longitude"); - - double[] coordinates = new double[2]; - coordinates[1] = Double.parseDouble(args[0].replace(",", "").replace("°", "")); - coordinates[0] = Double.parseDouble(args[1].replace("°", "")); - - double[] mcCoordinates = new double[0]; - try { - mcCoordinates = bteGeneratorSettings.projection().fromGeo(coordinates[0], coordinates[1]); - } catch (OutOfProjectionBoundsException e) { - e.printStackTrace(); - } - - if (minLat != 0 && maxLat != 0 && minLon != 0 && maxLon != 0 && !player.hasPermission("t+-.admin")) { - if (coordinates[1] < minLat || coordinates[0] < minLon || coordinates[1] > maxLat || coordinates[0] > maxLon) { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§cYou cannot tpll to these coordinates, because this area is being worked on by another build team."); - return true; - } - } - - TerraConnector terraConnector = new TerraConnector(); - - double height; - if (args.length >= 3) { - height = Double.parseDouble(args[2]) + yOffset; - } else { - height = terraConnector.getHeight((int) mcCoordinates[0], (int) mcCoordinates[1]).join() + yOffset; - } - if (height > player.getWorld().getMaxHeight()) { - if (Terraplusminus.config.getBoolean("linked_servers.enabled")) { - - //send player uuid and coordinates to bungee - - ByteArrayDataOutput out = ByteStreams.newDataOutput(); - out.writeUTF(player.getUniqueId().toString()); - - if (PluginMessageUtil.getNextServerName() != null) { - out.writeUTF(PluginMessageUtil.getNextServerName()); - } else { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§cPlease contact server administrator. Your config is not set up correctly."); - return true; - } + Map worldMap = new HashMap<>(); + Map> targetMap = new HashMap<>(); - out.writeUTF(coordinates[1] + ", " + coordinates[0]); - player.sendPluginMessage(Terraplusminus.instance, "bungeecord:terraplusminus", out.toByteArray()); + // Group targets by world + for (Entity target: targets) { + World world = target.getWorld(); + UUID worldId = world.getUID(); + worldMap.put(worldId, world); + targetMap.computeIfAbsent(worldId, u -> new ArrayList<>()).add(target); + } + + // Dispatch targets + for (Map.Entry> entry: targetMap.entrySet()) { + UUID worldId = entry.getKey(); + World world = worldMap.get(worldId); + Collection worldTargets = entry.getValue(); + try { + this.dispatchTargetsInWorld(sender, worldTargets, world, geoLocation, altitude); + } catch (CommandException e) { + sender.sendMessage(prefix + RED + "Could not teleport " + this.formatTargetList(targets) + ", " + e.getMessage()); + } + } - player.sendMessage(Terraplusminus.config.getString("prefix") + "§cSending to another server..."); - return true; - } else { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§cYou cannot tpll to these coordinates, because the world is not high enough at the moment."); - return true; - } - } else if (height <= player.getWorld().getMinHeight()) { - if (Terraplusminus.config.getBoolean("linked_servers.enabled")) { + return true; + } - //send player uuid and coordinates to bungee + private void dispatchTargetsInWorld(CommandSender sender, Collection targets, World world, final LatLng geolocation, double altitude) throws CommandException { - ByteArrayDataOutput out = ByteStreams.newDataOutput(); - out.writeUTF(player.getUniqueId().toString()); + // Get X and Z + Location destination = this.projectGeolocation(world, geolocation, altitude); - if (PluginMessageUtil.getLastServerName() != null) { - out.writeUTF(PluginMessageUtil.getLastServerName()); - } else { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§cPlease contact server administrator. Your config is not set up correctly."); - return true; - } + double y = destination.getY(); + boolean tooLow = y < world.getMinHeight(); + boolean tooHigh = y >= world.getMaxHeight(); - out.writeUTF(coordinates[1] + ", " + coordinates[0]); - player.sendPluginMessage(Terraplusminus.instance, "bungeecord:terraplusminus", out.toByteArray()); - - player.sendMessage(Terraplusminus.config.getString("prefix") + "§cSending to another server..."); - return true; - } else { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§cYou cannot tpll to these coordinates, because the world is not low enough at the moment."); - return true; - } - } - Location location = new Location(player.getWorld(), mcCoordinates[0] + xOffset, height, mcCoordinates[1] + zOffset, player.getLocation().getYaw(), player.getLocation().getPitch()); - - if (PaperLib.isChunkGenerated(location)) { - if (args.length >= 3) { - location = new Location(player.getWorld(), mcCoordinates[0] + xOffset, height, mcCoordinates[1] + zOffset, player.getLocation().getYaw(), player.getLocation().getPitch()); - } else { - location = new Location(player.getWorld(), mcCoordinates[0] + xOffset, player.getWorld().getHighestBlockYAt((int) mcCoordinates[0] + xOffset, (int) mcCoordinates[1] + zOffset) + 1, mcCoordinates[1] + zOffset, player.getLocation().getYaw(), player.getLocation().getPitch()); - } - } else { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7Location is generating. Please wait a moment..."); - } - PaperLib.teleportAsync(player, location); - - - if (args.length >= 3) { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7Teleported to " + coordinates[1] + ", " + coordinates[0] + ", " + height + "."); - } else { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7Teleported to " + coordinates[1] + ", " + coordinates[0] + "."); - } - - return true; - } else { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7No permission for /tpll"); - return true; - } + String prefix = Terraplusminus.config.getString("prefix"); + + if (tooLow || tooHigh) { + // Not within world bounds, we either send to a different server or fail + if (!Terraplusminus.config.getBoolean("linked_servers.enabled")) { + throw new CommandException("the world is not " + (tooLow ? "low" : "high") + " enough at the moment"); + } + final String server; + if (tooLow) { + server = PluginMessageUtil.getLastServerName(); } else { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7Usage: /tpll "); - return true; + server = PluginMessageUtil.getNextServerName(); + } + if (server == null) { + throw new CommandException("the server configuration is not set up properly, please contact the server administrator"); } + targets.forEach(t -> this.dispatchToOtherServer(sender, t, server, geolocation)); + } else if (!sender.hasPermission("t+-.admin") && !this.isWithinTeleportationBounds(world, geolocation)) { + throw new CommandException("you cannot tpll to these coordinates, because this area is being worked on by another build team"); + } else { + if (!isChunkGenerated(destination)) { + sender.sendMessage(prefix + "Generating destination in world " + world.getName() + "..."); + } + final Map> futures = new HashMap<>(); + targets.forEach( + target -> futures.put(target, teleportAsync(target, destination)) + ); + + // Wait for all teleportations to complete + CompletableFuture.allOf(futures.values().toArray(CompletableFuture[]::new)).thenAccept(unused -> { + List success = futures.entrySet().stream() + .filter(e -> { + try { + return e.getValue().get(); + } catch (InterruptedException | ExecutionException ex) { + throw new IllegalStateException(); + } + }) + .map(Map.Entry::getKey) + .collect(toList()); + List failures = futures.entrySet().stream() + .filter(e -> { + try { + return !e.getValue().get(); + } catch (InterruptedException | ExecutionException ex) { + throw new IllegalStateException(); + } + }) + .map(Map.Entry::getKey) + .collect(toList()); + if (!failures.isEmpty()) { + sender.sendMessage(prefix + RED + "Failed to teleport " + BLUE + this.formatTargetList(success) + RED + " to " + this.formatDestination(geolocation)); + } + if (!success.isEmpty()) { + sender.sendMessage(prefix + GRAY + "Teleported " + BLUE + this.formatTargetList(success) + GRAY + " to " + this.formatDestination(geolocation)); + } + }); } - return true; } - public boolean isDouble(String str) { + private Location projectGeolocation(World world, LatLng geolocation, double altitude) throws CommandException { + + ChunkGenerator chunkGenerator = world.getGenerator(); + if (!(chunkGenerator instanceof RealWorldGenerator)) { + throw new CommandException("world " + world.getName() + " is not a Terra+- world"); + } + + RealWorldGenerator generator = (RealWorldGenerator) chunkGenerator; + + // We update this later + final Location destination = new Location(world, 0, 0, 0); + + // Set destination X and Z + GeographicProjection projection = generator.getSettings().projection(); try { - Double.parseDouble(str); - return true; - } catch (NumberFormatException e) { - return false; + double[] xz = projection.fromGeo(geolocation.getLng(), geolocation.getLat()); + destination.setX(xz[0]); + destination.setZ(xz[1]); + } catch (OutOfProjectionBoundsException e) { + throw new CommandException("destination is out of projection bounds"); } + + // Set destination Y + if (isNaN(altitude)) { // Is set to NaN when not explicitly provided by the command sender + destination.setY(world.getHighestBlockYAt(destination)); + } else { + destination.setY(altitude + generator.getYOffset()); + } + + return destination; + } + + @SuppressWarnings("unused") // Passing world as an argument because ideally we could set boundaries per-world + private boolean isWithinTeleportationBounds(World world, LatLng geolocation) { + + // Read the configuration + double minLat = Terraplusminus.config.getDouble("min_latitude"); + double maxLat = Terraplusminus.config.getDouble("max_latitude"); + double minLon = Terraplusminus.config.getDouble("min_longitude"); + double maxLon = Terraplusminus.config.getDouble("max_longitude"); + + // Keeping this for backward compatibility + // This is a bit abusive, there are legitimate use cases where the bounds could be 0. + // The best approach would probably be to simply make it optional in the config + if (minLat == 0d || maxLat == 0d || minLon == 0d || maxLon == 0d) { + return true; // No bounds configured + } + + // Actual boundary check (bounds inclusive) + double latitude = geolocation.getLat(); + double longitude = geolocation.getLng(); + return latitude >= minLat && longitude >= minLon & latitude >= maxLat && longitude >= maxLon; + } + + private void dispatchToOtherServer(CommandSender sender, Entity target, String server, LatLng geolocation) { + + // Make sure target is a player + String prefix = Terraplusminus.config.getString("prefix"); + if (!(target instanceof Player)) { + sender.sendMessage(prefix + + RED + "Cannot teleport " + GRAY + formatTargetName(target) + + RED + ": destination is outside of range and only players may be sent to linked servers." + ); + return; + } + Player player = (Player) target; + + + // Send the message to the proxy using the player's connexion + byte[] message = this.encodeTerraLinkDispatchMessage(player, server, geolocation); + player.sendPluginMessage(Terraplusminus.instance, "bungeecord:terraplusminus", message); + + // Send feedback to command sender and target + sender.sendMessage(prefix + + GRAY + "Sending " + DARK_GRAY + formatTargetName(target) + + GRAY + " to server " + DARK_GRAY + server + GRAY + "." + ); + player.sendMessage(prefix + + RED + "Sending to another server..." + ); + + } + + + private String formatTargetList(Collection targets) { + if (targets.isEmpty()) { + return ""; + } + List names = new ArrayList<>(); + targets.stream() + .map(CommandHelper::formatTargetName) + .sorted() + .forEach(names::add); + if (names.size() == 1) { + return names.get(0); + } + String last = names.remove(names.size() - 1); + return join(", ", names) + " and " + last; + } + + private String formatDestination(LatLng location) { + return + BLUE + DECIMAL_FORMATTER.format(location.getLat()) + + GRAY + ", " + + BLUE + DECIMAL_FORMATTER.format(location.getLng()); + } + + private byte[] encodeTerraLinkDispatchMessage(Player player, String serverName, LatLng destination) { + // Keeping it as is for backward compatibility, + // but we could cut down message length by more than half by sending doubles and longs directly + // That would also eliminate the cost of encoding numbers to UTF-8 and having to send string length information + // Also, relying on a beta functionality is not ideal when there are viable alternatives in the JDK + ByteArrayDataOutput out = ByteStreams.newDataOutput(); + out.writeUTF(player.getUniqueId().toString()); + out.writeUTF(serverName); + String coordinateString = destination.getLat() + ", " + destination.getLng(); + out.writeUTF(coordinateString); + return out.toByteArray(); + } + + @Nullable + @Override + public List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String alias, @NotNull String[] args) { + + if (!sender.hasPermission("t+-.tpll")) { + return emptyList(); + } + + // First argument: target + if (args.length == 1) { + if (sender.hasPermission("t+-.forcetpll")) { + return Bukkit.getOnlinePlayers().stream() + .filter(Terraplusminus::isInTerraWorld) + .map(Player::getName) + .collect(toList()); + } else if (sender instanceof Player && isInTerraWorld((Player) sender)) { + return singletonList(sender.getName()); + } + } + + // We can't tab-complete the location or altitude, so we are done here + return emptyList(); } } diff --git a/src/main/java/de/btegermany/terraplusminus/commands/WhereCommand.java b/src/main/java/de/btegermany/terraplusminus/commands/WhereCommand.java index 6b25cef..b89d75a 100644 --- a/src/main/java/de/btegermany/terraplusminus/commands/WhereCommand.java +++ b/src/main/java/de/btegermany/terraplusminus/commands/WhereCommand.java @@ -1,54 +1,116 @@ package de.btegermany.terraplusminus.commands; import de.btegermany.terraplusminus.Terraplusminus; -import net.buildtheearth.terraminusminus.generator.EarthGeneratorSettings; +import de.btegermany.terraplusminus.gen.RealWorldGenerator; +import net.buildtheearth.terraminusminus.projection.GeographicProjection; import net.buildtheearth.terraminusminus.projection.OutOfProjectionBoundsException; +import net.buildtheearth.terraminusminus.util.geo.LatLng; import net.md_5.bungee.api.chat.ClickEvent; -import net.md_5.bungee.api.chat.ComponentBuilder; import net.md_5.bungee.api.chat.HoverEvent; import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.chat.hover.content.Text; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; import org.bukkit.command.Command; -import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; +import org.bukkit.command.TabExecutor; +import org.bukkit.entity.Entity; import org.bukkit.entity.Player; +import org.bukkit.generator.ChunkGenerator; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; -public class WhereCommand implements CommandExecutor { +import java.text.DecimalFormat; +import java.util.Collection; +import java.util.List; - private final EarthGeneratorSettings bteGeneratorSettings = EarthGeneratorSettings.parse(EarthGeneratorSettings.BTE_DEFAULT_SETTINGS); +import static de.btegermany.terraplusminus.commands.CommandHelper.*; +import static java.util.Collections.*; +import static java.util.Objects.requireNonNullElse; +import static java.util.stream.Collectors.toList; +import static org.bukkit.ChatColor.*; + +public class WhereCommand implements TabExecutor { + + private static final DecimalFormat DECIMAL_FORMATTER = new DecimalFormat("##.#####"); @Override - public boolean onCommand(@NotNull CommandSender commandSender, @NotNull Command command, @NotNull String s, @NotNull String[] args) { - if (command.getName().equalsIgnoreCase("where")) { - if (!(commandSender instanceof Player)) { - commandSender.sendMessage("This command can only be used by players!"); - return true; - } - Player player = (Player) commandSender; - if (player.hasPermission("t+-.where")) { - int xOffset = Terraplusminus.config.getInt("terrain_offset.x"); - int zOffset = Terraplusminus.config.getInt("terrain_offset.z"); - - double[] mcCoordinates = new double[2]; - mcCoordinates[0] = player.getLocation().getX() - xOffset; - mcCoordinates[1] = player.getLocation().getZ() - zOffset; - System.out.println(mcCoordinates[0] + ", " + mcCoordinates[1]); - double[] coordinates = new double[0]; - try { - coordinates = bteGeneratorSettings.projection().toGeo(mcCoordinates[0], mcCoordinates[1]); - } catch (OutOfProjectionBoundsException e) { - e.printStackTrace(); + public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String s, @NotNull String[] args) { + String prefix = Terraplusminus.config.getString("prefix"); + + if (!sender.hasPermission("t+-.where")) { + sender.sendMessage(prefix + GRAY + "No permission for /where"); + return true; + } + + // Parse targets + final Collection targets; + if (args.length == 0 && sender instanceof Player) { + targets = singleton((Player) sender); + } else if (args.length == 1) { + try { + targets = parseTargetSelector(sender, args[0]); + if (!sender.hasPermission("t+-.admin") && !senderIsSoleTarget(sender, targets)) { + String message = requireNonNullElse(command.getPermissionMessage(), RED + "Missing permission"); + sender.sendMessage(message); + return true; } - TextComponent message = new TextComponent(Terraplusminus.config.getString("prefix") + "§7Your coordinates are:"); - message.addExtra("\n§8" + coordinates[1] + ", " + coordinates[0] + "§7."); - message.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, "https://maps.google.com/maps?t=k&q=loc:" + coordinates[1] + "+" + coordinates[0])); - message.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new ComponentBuilder("§7Click here to view in Google Maps.").create())); - player.spigot().sendMessage(message); - } else { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7No permission for /where"); - return true; + } catch (CommandHelper.InvalidTargetSelectorException ignored) { + return false; } + } else { + return false; + } + + // Do the magic + for (Entity target: targets) { + final TextComponent message = new TextComponent(prefix); + try { + LatLng geolocation = this.getGeolocation(target); + String googleMapsUrl = "https://www.google.com/maps/@" + geolocation.getLat() + "," + geolocation.getLng(); + String targetVerb = target == sender ? "Your": formatTargetName(target) + "'s"; + message.addExtra(GRAY + targetVerb + " coordinates are:\n" + DARK_GRAY + DECIMAL_FORMATTER.format(geolocation.getLat()) + ", " + DECIMAL_FORMATTER.format(geolocation.getLng()) + GRAY + "."); + message.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, googleMapsUrl)); + message.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new Text(GRAY + "Click here to view in Google Maps."))); + } catch (OutOfProjectionBoundsException e) { + String targetVerb = target == sender? "You are": formatTargetName(target) + " is"; + message.addExtra(RED + targetVerb + " not in a Terra+- world or outside projection bounds."); + } + sender.spigot().sendMessage(message); } return true; } + + private LatLng getGeolocation(Entity target) throws OutOfProjectionBoundsException { + World world = target.getWorld(); + ChunkGenerator chunkGenerator = world.getGenerator(); + if (!(chunkGenerator instanceof RealWorldGenerator)) { + throw OutOfProjectionBoundsException.get(); + } + RealWorldGenerator generator = (RealWorldGenerator) chunkGenerator; + GeographicProjection projection = generator.getSettings().projection(); + Location location = target.getLocation(); + double[] coordinates = projection.toGeo(location.getX(), location.getZ()); + return new LatLng(coordinates[1], coordinates[0]); + } + + @Nullable + @Override + public List onTabComplete(@NotNull CommandSender sender, @NotNull Command command, @NotNull String alias, @NotNull String[] args) { + + if (args.length > 1 || !sender.hasPermission("t+-.where")) { + return emptyList(); + } + + if (sender.hasPermission("t+-.admin")) { + return Bukkit.getOnlinePlayers().stream() + .map(Player::getName) + .collect(toList()); + } else if (sender instanceof Player) { + return singletonList(sender.getName()); + } + + return emptyList(); + } } diff --git a/src/main/java/de/btegermany/terraplusminus/events/PlayerMoveEvent.java b/src/main/java/de/btegermany/terraplusminus/events/PlayerMoveEvent.java index d6f343a..5da9345 100644 --- a/src/main/java/de/btegermany/terraplusminus/events/PlayerMoveEvent.java +++ b/src/main/java/de/btegermany/terraplusminus/events/PlayerMoveEvent.java @@ -4,6 +4,7 @@ import net.md_5.bungee.api.ChatMessageType; import net.md_5.bungee.api.chat.TextComponent; import org.bukkit.Bukkit; +import org.bukkit.ChatColor; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; @@ -12,6 +13,9 @@ import java.util.ArrayList; +import static java.lang.String.valueOf; +import static org.bukkit.ChatColor.BOLD; + public class PlayerMoveEvent implements Listener { @@ -33,7 +37,7 @@ void onPlayerMove(org.bukkit.event.player.PlayerMoveEvent event) { @Override public void run() { int height = p.getLocation().getBlockY() - yOffset; - p.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent("§l" + height + "m")); + p.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(BOLD + valueOf(height) + "m")); } }; runnable.runTaskTimer(plugin, 0, 20); diff --git a/src/main/java/de/btegermany/terraplusminus/gen/RealWorldGenerator.java b/src/main/java/de/btegermany/terraplusminus/gen/RealWorldGenerator.java index 0ab19dd..dc37f6c 100644 --- a/src/main/java/de/btegermany/terraplusminus/gen/RealWorldGenerator.java +++ b/src/main/java/de/btegermany/terraplusminus/gen/RealWorldGenerator.java @@ -5,6 +5,7 @@ import de.btegermany.terraplusminus.Terraplusminus; import de.btegermany.terraplusminus.gen.tree.TreePopulator; import de.btegermany.terraplusminus.utils.ConfigurationHelper; +import lombok.Getter; import net.buildtheearth.terraminusminus.generator.CachedChunkData; import net.buildtheearth.terraminusminus.generator.ChunkDataLoader; import net.buildtheearth.terraminusminus.generator.EarthGeneratorSettings; @@ -37,12 +38,16 @@ public class RealWorldGenerator extends ChunkGenerator { + + @Getter + private final EarthGeneratorSettings settings; + @Getter + private final int yOffset; private Location spawnLocation = null; - public LoadingCache> cache; + private final LoadingCache> cache; private final CustomBiomeProvider customBiomeProvider; - private final int yOffset; private final Material surfaceMaterial; private final Map materialMapping; @@ -66,13 +71,13 @@ public RealWorldGenerator() { ); this.yOffset = Terraplusminus.config.getInt("terrain_offset.y"); - settings = settings.withProjection(projection); + this.settings = settings.withProjection(projection); this.customBiomeProvider = new CustomBiomeProvider(); this.cache = CacheBuilder.newBuilder() .expireAfterAccess(5L, TimeUnit.MINUTES) .softValues() - .build(new ChunkDataLoader(settings)); + .build(new ChunkDataLoader(this.settings)); this.surfaceMaterial = ConfigurationHelper.getMaterial(Terraplusminus.config, "surface_material", GRASS_BLOCK); this.materialMapping = Map.of( diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 4c496ed..c87db84 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -11,12 +11,12 @@ load: STARTUP commands: tpll: description: Teleports you to longitude and latitude - usage: /tpll + usage: /tpll [target] aliases: [ tpc ] where: description: Gives you the longitude and latitude of your minecraft coordinates - usage: /where + usage: /where [target] offset: description: Displays the x,y and z offset of your world - usage: /offset + usage: /offset [world]