diff --git a/.gitignore b/.gitignore index 5222655..412b685 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /build/ /run/ /.idea/ +/bin/ diff --git a/README.md b/README.md index 9274089..46af32b 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,9 @@ Minecraft self-service teleport requests | `/tpa ` | `tpask` | Send request | `tpa.tpa` | | `/tpy ` | `tpaccept`, `tpyes` | Accept request | `tpa.tpy` | | `/tpn ` | `tpdeny`, `tpno` | Deny request | `tpa.tpn` | -| `/tpi []` | `tpignore` | Ignore requests per player | `tpa.tpi` | +| `/tpi ` | `tpignore` | Ignore requests per player | `tpa.tpi` | | `/tpt` | `tptoggle` | Ignore requests globally | `tpa.tpt` | +| `/tpc ` | `tpcancel` | Cancel outgoing requests | `tpa.tpc` | ## Statistics diff --git a/src/main/java/lol/hub/hubtp/Config.java b/src/main/java/lol/hub/hubtp/Config.java index 63bd41a..a80f5ec 100644 --- a/src/main/java/lol/hub/hubtp/Config.java +++ b/src/main/java/lol/hub/hubtp/Config.java @@ -17,11 +17,13 @@ public final class Config { private static boolean movementCheck; private static boolean includeLeashed; private static boolean includeLeashedInterdimensional; + private static boolean teleportMountedEntities; private static Path ignoresPath; private static boolean debug; private static synchronized void assertInitialized() { - if (!initialized) throw new IllegalStateException("Config access prior to initialization!"); + if (!initialized) + throw new IllegalStateException("Config access prior to initialization!"); } public static synchronized void load(Plugin plugin) { @@ -37,13 +39,13 @@ public static synchronized void load(Plugin plugin) { config.addDefault("movement-check", false); config.addDefault("include-leashed", true); config.addDefault("include-leashed-interdimensional", false); + config.addDefault("teleport-mounted-entities", true); config.addDefault("ignores-path", Ignores.defaultPath.apply(plugin)); config.addDefault("debug", false); config.addDefault("bStats", true); config.options().copyDefaults(true); plugin.saveConfig(); - allowMultiTargetRequest = config.getBoolean("allow-multi-target-request"); if (config.getInt("request-timeout-seconds") < 10) { @@ -80,7 +82,9 @@ public static synchronized void load(Plugin plugin) { includeLeashedInterdimensional = config.getBoolean("include-leashed-interdimensional"); - //noinspection DataFlowIssue + teleportMountedEntities = config.getBoolean("teleport-mounted-entities"); + + // noinspection DataFlowIssue if (config.getString("ignores-path") == null || config.getString("ignores-path").isBlank()) { config.set("ignores-path", Ignores.defaultPath.apply(plugin)); plugin.saveConfig(); @@ -90,7 +94,7 @@ public static synchronized void load(Plugin plugin) { config.set("ignores-path", Ignores.defaultPath.apply(plugin)); plugin.saveConfig(); } - //noinspection DataFlowIssue + // noinspection DataFlowIssue ignoresPath = Path.of(config.getString("ignores-path")); debug = config.getBoolean("debug"); @@ -148,6 +152,11 @@ public static boolean includeLeashedInterdimensional() { return includeLeashedInterdimensional; } + public static boolean teleportMountedEntities() { + assertInitialized(); + return teleportMountedEntities; + } + public static Path ignoresPath() { assertInitialized(); return ignoresPath; diff --git a/src/main/java/lol/hub/hubtp/Plugin.java b/src/main/java/lol/hub/hubtp/Plugin.java index 244ff00..fef338f 100644 --- a/src/main/java/lol/hub/hubtp/Plugin.java +++ b/src/main/java/lol/hub/hubtp/Plugin.java @@ -2,10 +2,12 @@ import com.tcoded.folialib.FoliaLib; import com.tcoded.folialib.impl.PlatformScheduler; +import io.papermc.paper.entity.TeleportFlag; import lol.hub.hubtp.commands.*; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.TextComponent; import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.Location; import org.bstats.bukkit.Metrics; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; @@ -20,9 +22,14 @@ import org.bukkit.plugin.java.JavaPlugin; import org.jetbrains.annotations.NotNull; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.CompletableFuture; import java.util.function.Function; import java.util.stream.Collectors; @@ -48,13 +55,13 @@ public class Plugin extends JavaPlugin { /** * Maps associating command labels with their constructor functions. */ - private final Map> commandMap = Map.of( - "tpa", pCmd -> new AskCmd(this, pCmd), - "tpy", pCmd -> new AcceptCmd(this, pCmd), - "tpn", pCmd -> new DenyCmd(this, pCmd), - "tpt", pCmd -> new ToggleCmd(this, pCmd), - "tpi", pCmd -> new IgnoreCmd(this, pCmd) - ); + private final Map> commandMap = Map.ofEntries( + Map.entry("tpa", pCmd -> new AskCmd(this, pCmd)), + Map.entry("tpy", pCmd -> new AcceptCmd(this, pCmd)), + Map.entry("tpn", pCmd -> new DenyCmd(this, pCmd)), + Map.entry("tpt", pCmd -> new ToggleCmd(this, pCmd)), + Map.entry("tpi", pCmd -> new IgnoreCmd(this, pCmd)), + Map.entry("tpc", pCmd -> new CancelCmd(this, pCmd))); /** * Gets the scheduler instance from FoliaLib. @@ -75,19 +82,20 @@ public void onLoad() { */ public Set getPluginCommands() { return getServer() - .getCommandMap() - .getKnownCommands() - .values() - .stream() - .filter(org.bukkit.command.Command::isRegistered) - .filter(cmd -> cmd instanceof PluginCommand) - .map(cmd -> (PluginCommand) cmd) - .filter(cmd -> cmd.getPlugin() == this) - .collect(Collectors.toUnmodifiableSet()); + .getCommandMap() + .getKnownCommands() + .values() + .stream() + .filter(org.bukkit.command.Command::isRegistered) + .filter(cmd -> cmd instanceof PluginCommand) + .map(cmd -> (PluginCommand) cmd) + .filter(cmd -> cmd.getPlugin() == this) + .collect(Collectors.toUnmodifiableSet()); } /** - * Called when the plugin is enabled. Sets up commands, metrics, and event listeners. + * Called when the plugin is enabled. Sets up commands, metrics, and event + * listeners. */ public void onEnable() { Log.set(this.getLogger()); @@ -114,8 +122,10 @@ public void onEnable() { getServer().getPluginManager().registerEvents(new Listener() { @EventHandler public void onPlayerMove(PlayerMoveEvent event) { - if (!Config.movementCheck()) return; - if (!event.hasChangedPosition()) return; + if (!Config.movementCheck()) + return; + if (!event.hasChangedPosition()) + return; RequestManager.cancelRequestsByRequester(event.getPlayer()); } }, this); @@ -140,7 +150,8 @@ private void registerTpCommand(String label, PluginCommand pCmd, Function 0) { - // Notify players about the pending teleport - tpTarget.sendMessage(Component.text("Teleporting ", NamedTextColor.GOLD) - .append(Component.text(tpRequester.getName())) - .append(Component.text(" in ")) - .append(Component.text(tpDelay)) - .append(Component.text(" seconds..."))); - - tpRequester.sendMessage(Component.text("Teleporting in ", NamedTextColor.GOLD) - .append(Component.text(tpDelay)) - .append(Component.text(" seconds..."))); - - this.getScheduler().runLaterAsync(() -> { - if (RequestManager.isRequestActive(tpTarget, tpRequester)) { - this.executeTPMove(tpTarget, tpRequester); - } + if (tpTarget == null || tpRequester == null) { + return; + } - }, (long) tpDelay * 20L); - } else { - // Immediate teleport + int tpDelay = Config.tpDelaySeconds(); + if (tpDelay > 0) { + // Notify players about the pending teleport + tpTarget.sendMessage(Component.text("Teleporting ", NamedTextColor.GOLD) + .append(Component.text(tpRequester.getName())) + .append(Component.text(" in ")) + .append(Component.text(tpDelay)) + .append(Component.text(" seconds..."))); + + tpRequester.sendMessage(Component.text("Teleporting in ", NamedTextColor.GOLD) + .append(Component.text(tpDelay)) + .append(Component.text(" seconds..."))); + + this.getScheduler().runLaterAsync(() -> { + if (RequestManager.isRequestActive(tpTarget, tpRequester)) { this.executeTPMove(tpTarget, tpRequester); } - - } else { - // Teleportation failed due to vehicles - TextComponent msg = Component.text("Teleport failed!", NamedTextColor.RED); - tpTarget.sendMessage(msg); - tpRequester.sendMessage(msg); - } + }, (long) tpDelay * 20L); + } else { + // Immediate teleport + this.executeTPMove(tpTarget, tpRequester); } } @@ -221,33 +225,179 @@ public void executeTPMove(Player tpTarget, Player tpRequester) { // Leash handling if enabled if (Config.includeLeashed() && shouldTpLeashed(tpTarget, tpRequester)) { tpRequester.getWorld() - .getNearbyEntities(tpRequester.getLocation(), 16, 16, 16).stream() - .filter(e -> e instanceof LivingEntity) - .map(e -> (LivingEntity) e) - .filter(LivingEntity::isLeashed) - .filter(e -> e.getLeashHolder().getUniqueId().equals(tpRequester.getUniqueId())) - .forEach(entity -> - entity.teleportAsync(tpTarget.getLocation(), PlayerTeleportEvent.TeleportCause.PLUGIN)); + .getNearbyEntities(tpRequester.getLocation(), 16, 16, 16).stream() + .filter(e -> e instanceof LivingEntity) + .map(e -> (LivingEntity) e) + .filter(LivingEntity::isLeashed) + .filter(e -> e.getLeashHolder().getUniqueId().equals(tpRequester.getUniqueId())) + .forEach(entity -> entity.teleportAsync(tpTarget.getLocation(), + PlayerTeleportEvent.TeleportCause.PLUGIN)); + } + + Location destination = tpTarget.getLocation().clone(); + boolean crossWorld = !tpRequester.getWorld().equals(tpTarget.getWorld()); + + // Execute teleport asynchronously, accounting for mounts + teleportRequesterWithVehicle(tpRequester, destination, crossWorld) + .thenAccept(result -> { + if (result) { + tpTarget.sendMessage( + Component.text(tpRequester.getName()) + .append(Component.text(" teleported to you!", NamedTextColor.GOLD))); + tpRequester.sendMessage( + Component.text("Teleported to ", NamedTextColor.GOLD) + .append(Component.text(tpTarget.getName())) + .append(Component.text("!"))); + } else { + TextComponent msg = Component.text( + "Teleportation failed.", + NamedTextColor.RED); + tpTarget.sendMessage(msg); + tpRequester.sendMessage(msg); + } + }); + } + + private CompletableFuture teleportRequesterWithVehicle(Player requester, Location destination, + boolean crossWorld) { + Entity mount = requester.getVehicle(); + + if (mount == null) { + return requester.teleportAsync(destination, PlayerTeleportEvent.TeleportCause.COMMAND); + } + + if (!Config.teleportMountedEntities()) { + requester.leaveVehicle(); + return requester.teleportAsync(destination, PlayerTeleportEvent.TeleportCause.COMMAND); + } + + List passengerSnapshots = collectPassengerTree(mount); + + if (crossWorld) { + requester.leaveVehicle(); + passengerSnapshots.stream() + .map(PassengerSnapshot::entity) + .forEach(Entity::leaveVehicle); + + List> teleportFutures = new ArrayList<>(); + teleportFutures.add(mount.teleportAsync(destination.clone(), + PlayerTeleportEvent.TeleportCause.PLUGIN)); + + for (PassengerSnapshot snapshot : passengerSnapshots) { + Entity passenger = snapshot.entity(); + Location target = destination.clone(); + PlayerTeleportEvent.TeleportCause cause = passenger instanceof Player + ? PlayerTeleportEvent.TeleportCause.COMMAND + : PlayerTeleportEvent.TeleportCause.PLUGIN; + teleportFutures.add(passenger.teleportAsync(target, cause)); + } + + return combineFutures(teleportFutures) + .thenApply(success -> { + if (success) { + remountPassengers(mount, passengerSnapshots); + } + return success; + }); } - // Execute teleport asynchronously - tpRequester.teleportAsync(tpTarget.getLocation(), PlayerTeleportEvent.TeleportCause.COMMAND) - .thenAccept(result -> { - if (result) { - tpTarget.sendMessage( - Component.text(tpRequester.getName()) - .append(Component.text(" teleported to you!", NamedTextColor.GOLD))); - tpRequester.sendMessage( - Component.text("Teleported to ", NamedTextColor.GOLD) - .append(Component.text(tpTarget.getName())) - .append(Component.text("!"))); - } else { - TextComponent msg = - Component.text("Teleportation failed. Please try again later or contact an admin.", NamedTextColor.RED); - tpTarget.sendMessage(msg); - tpRequester.sendMessage(msg); + List> teleportFutures = new ArrayList<>(); + teleportFutures.add(mount.teleportAsync(destination.clone(), + PlayerTeleportEvent.TeleportCause.PLUGIN, + TeleportFlag.EntityState.RETAIN_PASSENGERS)); + + for (PassengerSnapshot snapshot : passengerSnapshots) { + Entity passenger = snapshot.entity(); + PlayerTeleportEvent.TeleportCause cause = passenger instanceof Player + ? PlayerTeleportEvent.TeleportCause.COMMAND + : PlayerTeleportEvent.TeleportCause.PLUGIN; + teleportFutures.add(passenger.teleportAsync(destination.clone(), + cause, + TeleportFlag.EntityState.RETAIN_VEHICLE)); + } + + return combineFutures(teleportFutures) + .thenApply(success -> { + if (success) { + remountPassengers(mount, passengerSnapshots); + } + return success; + }); + } + + private static void remountPassengers(Entity root, List snapshots) { + Map> parentToChildren = new HashMap<>(); + + for (PassengerSnapshot snapshot : snapshots) { + Entity parent = snapshot.vehicle(); + Entity passenger = snapshot.entity(); + + if (parent == null || !parent.isValid() || !passenger.isValid()) { + continue; + } + + parentToChildren.computeIfAbsent(parent, key -> new ArrayList<>()).add(passenger); + } + + Deque queue = new ArrayDeque<>(); + queue.add(root); + + while (!queue.isEmpty()) { + Entity current = queue.poll(); + List children = parentToChildren.get(current); + + if (children == null) { + continue; + } + + for (Entity child : children) { + if (!child.isValid() || !current.isValid()) { + continue; + } + + if (child.getVehicle() != current) { + if (child.getVehicle() != null) { + child.leaveVehicle(); + } + current.addPassenger(child); } - }); + + queue.add(child); + } + } + } + + private static List collectPassengerTree(Entity root) { + List passengers = new ArrayList<>(); + Deque queue = new ArrayDeque<>(); + + for (Entity passenger : root.getPassengers()) { + queue.add(new PassengerSnapshot(passenger, root)); + } + + while (!queue.isEmpty()) { + PassengerSnapshot snapshot = queue.poll(); + passengers.add(snapshot); + + for (Entity child : snapshot.entity().getPassengers()) { + queue.add(new PassengerSnapshot(child, snapshot.entity())); + } + } + + return passengers; + } + + private record PassengerSnapshot(Entity entity, Entity vehicle) { + } + + private static CompletableFuture combineFutures(List> futures) { + if (futures.isEmpty()) { + return CompletableFuture.completedFuture(true); + } + + CompletableFuture[] futureArray = futures.toArray(new CompletableFuture[0]); + return CompletableFuture.allOf(futureArray) + .thenApply(ignored -> futures.stream().allMatch(future -> future.getNow(false))); } /** @@ -265,9 +415,11 @@ public boolean isRequestBlock(Player player) { } /** - * Determines if teleporting between two entities should consider leashed status. + * Determines if teleporting between two entities should consider leashed + * status. */ private static boolean shouldTpLeashed(Entity playerA, Entity playerB) { - return playerA.getWorld().getEnvironment() == playerB.getWorld().getEnvironment() || Config.includeLeashedInterdimensional(); + return playerA.getWorld().getEnvironment() == playerB.getWorld().getEnvironment() + || Config.includeLeashedInterdimensional(); } } diff --git a/src/main/java/lol/hub/hubtp/RequestManager.java b/src/main/java/lol/hub/hubtp/RequestManager.java index 8e6f79f..4afa7cf 100644 --- a/src/main/java/lol/hub/hubtp/RequestManager.java +++ b/src/main/java/lol/hub/hubtp/RequestManager.java @@ -5,8 +5,12 @@ import org.bukkit.Bukkit; import org.bukkit.entity.Player; +import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +import org.jetbrains.annotations.Nullable; public class RequestManager { private static final Map pendingRequests = new ConcurrentHashMap<>(); @@ -19,18 +23,16 @@ static void clearOldRequests(int timeoutValue) { Player requester = Bukkit.getPlayer(request.requester().uuid()); if (requester != null) { requester.sendMessage( - Component.text("Your teleport request to ", NamedTextColor.GOLD) - .append(Component.text(request.target().name())) - .append(Component.text(" timed out.")) - ); + Component.text("Your teleport request to ", NamedTextColor.GOLD) + .append(Component.text(request.target().name())) + .append(Component.text(" timed out."))); } Player target = Bukkit.getPlayer(request.target().uuid()); if (target != null) { target.sendMessage( - Component.text("The teleport request from ", NamedTextColor.GOLD) - .append(Component.text(request.requester().name())) - .append(Component.text(" timed out.")) - ); + Component.text("The teleport request from ", NamedTextColor.GOLD) + .append(Component.text(request.requester().name())) + .append(Component.text(" timed out."))); } } }); @@ -59,12 +61,15 @@ public static void cancelRequestsByTarget(Player target) { } public static void cancelRequestsByRequester(Player requester) { - for (Request request : pendingRequests.keySet()) { - if (request.requester().uuid().equals(requester.getUniqueId())) { - pendingRequests.remove(request); - // TODO: cancel info message - } - } + cancelRequestsByRequesterInternal(requester, null, false); + } + + public static List cancelAllRequestsByRequester(Player requester) { + return cancelRequestsByRequesterInternal(requester, null, true); + } + + public static List cancelRequestsByRequester(Player requester, String targetName) { + return cancelRequestsByRequesterInternal(requester, targetName, true); } public static boolean isRequestActive(Player target, Player requester) { @@ -84,4 +89,34 @@ public static boolean isRequestActiveByRequester(Player requester) { } return false; } + + private static List cancelRequestsByRequesterInternal(Player requester, @Nullable String targetName, + boolean notifyTargets) { + List toCancel = pendingRequests.keySet().stream() + .filter(request -> request.requester().uuid().equals(requester.getUniqueId())) + .filter(request -> targetName == null || request.target().name().equalsIgnoreCase(targetName)) + .collect(Collectors.toList()); + + if (toCancel.isEmpty()) { + return List.of(); + } + + Component targetMessage = Component.text(requester.getName(), NamedTextColor.GOLD) + .append(Component.text(" cancelled their teleport request.")); + + for (Request request : toCancel) { + pendingRequests.remove(request); + + if (!notifyTargets) { + continue; + } + + Player target = Bukkit.getPlayer(request.target().uuid()); + if (target != null) { + target.sendMessage(targetMessage); + } + } + + return List.copyOf(toCancel); + } } diff --git a/src/main/java/lol/hub/hubtp/commands/CancelCmd.java b/src/main/java/lol/hub/hubtp/commands/CancelCmd.java new file mode 100644 index 0000000..2788a96 --- /dev/null +++ b/src/main/java/lol/hub/hubtp/commands/CancelCmd.java @@ -0,0 +1,53 @@ +package lol.hub.hubtp.commands; + +import java.util.List; + +import lol.hub.hubtp.Plugin; +import lol.hub.hubtp.Request; +import lol.hub.hubtp.RequestManager; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import org.bukkit.command.PluginCommand; +import org.bukkit.entity.Player; + +public class CancelCmd extends TpCommand { + public CancelCmd(Plugin plugin, PluginCommand pluginCommand) { + super(plugin, pluginCommand, 0, 1); + } + + @Override + public void run(Player commandSender, String targetName) { + if (targetName == null) { + List cancelled = RequestManager.cancelAllRequestsByRequester(commandSender); + + if (cancelled.isEmpty()) { + commandSender.sendMessage(Component.text("You have no pending teleport requests to cancel.", + NamedTextColor.RED)); + return; + } + + String suffix = cancelled.size() == 1 ? " teleport request." : " teleport requests."; + commandSender.sendMessage( + Component.text("Cancelled ", NamedTextColor.GOLD) + .append(Component.text(cancelled.size(), NamedTextColor.GOLD)) + .append(Component.text(suffix, NamedTextColor.GOLD))); + return; + } + + List cancelled = RequestManager.cancelRequestsByRequester(commandSender, targetName); + + if (cancelled.isEmpty()) { + commandSender.sendMessage( + Component.text("You have no pending teleport request to ", NamedTextColor.RED) + .append(Component.text(targetName)) + .append(Component.text("."))); + return; + } + + String resolvedTargetName = cancelled.get(0).target().name(); + commandSender.sendMessage( + Component.text("Cancelled teleport request to ", NamedTextColor.GOLD) + .append(Component.text(resolvedTargetName, NamedTextColor.GOLD)) + .append(Component.text(".", NamedTextColor.GOLD))); + } +} diff --git a/src/main/java/lol/hub/hubtp/commands/TpCommand.java b/src/main/java/lol/hub/hubtp/commands/TpCommand.java index 580f7c9..3cb3198 100644 --- a/src/main/java/lol/hub/hubtp/commands/TpCommand.java +++ b/src/main/java/lol/hub/hubtp/commands/TpCommand.java @@ -9,20 +9,30 @@ public abstract class TpCommand { public final String usage; final Plugin plugin; - private final int argumentCount; + private final int minArguments; + private final int maxArguments; - public TpCommand(Plugin plugin, String usage, int argumentCount) { + public TpCommand(Plugin plugin, String usage, int minArguments, int maxArguments) { this.plugin = plugin; this.usage = usage; - this.argumentCount = argumentCount; + this.minArguments = minArguments; + this.maxArguments = maxArguments; + } + + public TpCommand(Plugin plugin, String usage, int argumentCount) { + this(plugin, usage, argumentCount, argumentCount); } public TpCommand(Plugin plugin, PluginCommand pluginCommand, int argumentCount) { - this(plugin, pluginCommand.getUsage(), argumentCount); + this(plugin, pluginCommand.getUsage(), argumentCount, argumentCount); + } + + public TpCommand(Plugin plugin, PluginCommand pluginCommand, int minArguments, int maxArguments) { + this(plugin, pluginCommand.getUsage(), minArguments, maxArguments); } - public int getArgumentCount() { - return argumentCount; + public boolean isArgumentCountValid(int count) { + return count >= minArguments && count <= maxArguments; } public abstract void run(Player commandSender, String targetName); diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 47d4506..0f760c6 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -38,6 +38,12 @@ commands: aliases: [ 'tptoggle' ] permission: 'tpa.tpt' permission-message: 'Insufficient permissions, you should harass your admin because of this!' + tpc: + description: 'Cancel outgoing requests' + usage: '/tpc ' + aliases: [ 'tpcancel' ] + permission: 'tpa.tpc' + permission-message: 'Insufficient permissions, you should harass your admin because of this!' permissions: tpa.tpa: @@ -55,3 +61,6 @@ permissions: tpa.tpt: description: 'Ignore requests globally' default: true + tpa.tpc: + description: 'Cancel outgoing requests' + default: true