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..a6231b2 --- /dev/null +++ b/src/main/java/de/btegermany/terraplusminus/commands/CommandHelper.java @@ -0,0 +1,80 @@ +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.Comparator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static java.util.Comparator.comparing; + +/** + * 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(); + + } + + public static class InvalidTargetSelectorException extends Exception { + + } + + private CommandHelper() { + throw new IllegalStateException(); + } + +} diff --git a/src/main/java/de/btegermany/terraplusminus/commands/TpllCommand.java b/src/main/java/de/btegermany/terraplusminus/commands/TpllCommand.java index 42d4e0a..ea0c913 100644 --- a/src/main/java/de/btegermany/terraplusminus/commands/TpllCommand.java +++ b/src/main/java/de/btegermany/terraplusminus/commands/TpllCommand.java @@ -3,235 +3,347 @@ 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.Location; +import org.bukkit.World; import org.bukkit.command.Command; +import org.bukkit.command.CommandException; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; +import org.bukkit.entity.Entity; import org.bukkit.entity.Player; import org.bukkit.generator.ChunkGenerator; import org.jetbrains.annotations.NotNull; -import static org.bukkit.ChatColor.RED; +import java.text.DecimalFormat; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import static com.google.common.base.Strings.isNullOrEmpty; +import static de.btegermany.terraplusminus.commands.CommandHelper.InvalidTargetSelectorException; +import static de.btegermany.terraplusminus.commands.CommandHelper.parseTargetSelector; +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.Objects.requireNonNullElseGet; +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 CommandExecutor { + 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 sender is not a player cancel the command. - if (!(commandSender instanceof Player)) { - commandSender.sendMessage("This command can only be used by players!"); + 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; } - 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; - } + final String prefix = Terraplusminus.config.getString("prefix"); // Used in feedback messages - 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; - } + // 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; } - //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"); + 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 + } + } + if (targets == null) { + if (sender instanceof Player) { + targets = Collections.singleton((Player) sender); } else { - player.chat("/" + passthroughTpll + ":tpll " + String.join(" ", args)); + return false; // Invalid command, non player senders must specify a valid target } + } else if ((targets.size() > 1 || !targets.contains(sender)) && !sender.hasPermission("t+-.forcetpll")){ + // Sender specified a target which is not themselves + sender.sendMessage( + prefix + + RED + "You do not have permission to use TPLL on others" + ); return true; } - // - - if (args.length >= 2) { - if (player.hasPermission("t+-.tpll")) { + // 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; + } - 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"); + Map worldMap = new HashMap<>(); + Map> targetMap = new HashMap<>(); - double[] coordinates = new double[2]; - coordinates[1] = Double.parseDouble(args[0].replace(",", "").replace("°", "")); - coordinates[0] = Double.parseDouble(args[1].replace("°", "")); + // 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); + } - ChunkGenerator generator = player.getWorld().getGenerator(); - if (!(generator instanceof RealWorldGenerator)) { - commandSender.sendMessage(RED + "Must be in a Terra 1 to 1 world!"); - return true; - } - RealWorldGenerator terraGenerator = (RealWorldGenerator) generator; - EarthGeneratorSettings generatorSettings = terraGenerator.getSettings(); - GeographicProjection projection = generatorSettings.projection(); - int yOffset = terraGenerator.getYOffset(); - - double[] mcCoordinates; - try { - mcCoordinates = projection.fromGeo(coordinates[0], coordinates[1]); - } catch (OutOfProjectionBoundsException e) { - commandSender.sendMessage(RED + "Location is not within projection bounds"); - return true; - } + // 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()); + } + } - 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") + RED + "You cannot tpll to these coordinates, because this area is being worked on by another build team."); - return true; - } - } + return true; + } - TerraConnector terraConnector = new TerraConnector(); + private void dispatchTargetsInWorld(CommandSender sender, Collection targets, World world, final LatLng geolocation, double altitude) throws CommandException { - 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; - } - - 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 high enough at the moment."); - return true; - } - } else if (height <= player.getWorld().getMinHeight()) { - 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.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; - } - - 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], height, mcCoordinates[1], player.getLocation().getYaw(), player.getLocation().getPitch()); - - if (PaperLib.isChunkGenerated(location)) { - if (args.length >= 3) { - location = new Location(player.getWorld(), mcCoordinates[0], height, mcCoordinates[1], player.getLocation().getYaw(), player.getLocation().getPitch()); - } else { - location = new Location(player.getWorld(), mcCoordinates[0], player.getWorld().getHighestBlockYAt((int) mcCoordinates[0], (int) mcCoordinates[1]) + 1, mcCoordinates[1], player.getLocation().getYaw(), player.getLocation().getPitch()); - } - } else { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7Location is generating. Please wait a moment..."); - } - PaperLib.teleportAsync(player, location); + // Get X and Z + Location destination = this.projectGeolocation(world, geolocation, altitude); + double y = destination.getY(); + boolean tooLow = y < world.getMinHeight(); + boolean tooHigh = y >= world.getMaxHeight(); - 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] + "."); - } + String prefix = Terraplusminus.config.getString("prefix"); - return true; + 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") + "§7No permission for /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 { - player.sendMessage(Terraplusminus.config.getString("prefix") + "§7Usage: /tpll "); - return true; + 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 §9" + this.formatTargetList(success) + RED + " to " + this.formatDestination(geolocation)); + } + if (!success.isEmpty()) { + sender.sendMessage(prefix + "§7Teleported §9" + this.formatTargetList(success) + " §7to " + this.formatDestination(geolocation)); + } + }); } } - 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 + this.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 + this.formatTargetName(target) + + GRAY + " to server " + DARK_GRAY + server + GRAY + "." + ); + player.sendMessage(prefix + + "§cSending to another server..." + ); + + } + + private String formatTargetName(Entity target) { + return requireNonNullElseGet(target.getCustomName(), target::getName); + } + + private String formatTargetList(Collection targets) { + if (targets.isEmpty()) { + return ""; + } + List names = new ArrayList<>(); + targets.stream() + .map(this::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(); + } + }