diff --git a/src/main/java/lol/hub/hubtp/Config.java b/src/main/java/lol/hub/hubtp/Config.java index a80f5ec..e6423ca 100644 --- a/src/main/java/lol/hub/hubtp/Config.java +++ b/src/main/java/lol/hub/hubtp/Config.java @@ -18,6 +18,8 @@ public final class Config { private static boolean includeLeashed; private static boolean includeLeashedInterdimensional; private static boolean teleportMountedEntities; + private static boolean teleportIntoFreeSeat; + private static boolean teleportLeashedOfPassengers; private static Path ignoresPath; private static boolean debug; @@ -38,8 +40,10 @@ public static synchronized void load(Plugin plugin) { config.addDefault("tp-delay-seconds", 0); config.addDefault("movement-check", false); config.addDefault("include-leashed", true); - config.addDefault("include-leashed-interdimensional", false); + config.addDefault("include-leashed-interdimensional", true); config.addDefault("teleport-mounted-entities", true); + config.addDefault("teleport-into-free-seat", true); + config.addDefault("teleport-leashed-of-passengers", true); config.addDefault("ignores-path", Ignores.defaultPath.apply(plugin)); config.addDefault("debug", false); config.addDefault("bStats", true); @@ -84,6 +88,10 @@ public static synchronized void load(Plugin plugin) { teleportMountedEntities = config.getBoolean("teleport-mounted-entities"); + teleportIntoFreeSeat = config.getBoolean("teleport-into-free-seat"); + + teleportLeashedOfPassengers = config.getBoolean("teleport-leashed-of-passengers"); + // noinspection DataFlowIssue if (config.getString("ignores-path") == null || config.getString("ignores-path").isBlank()) { config.set("ignores-path", Ignores.defaultPath.apply(plugin)); @@ -157,6 +165,16 @@ public static boolean teleportMountedEntities() { return teleportMountedEntities; } + public static boolean teleportIntoFreeSeat() { + assertInitialized(); + return teleportIntoFreeSeat; + } + + public static boolean teleportLeashedOfPassengers() { + assertInitialized(); + return teleportLeashedOfPassengers; + } + public static Path ignoresPath() { assertInitialized(); return ignoresPath; diff --git a/src/main/java/lol/hub/hubtp/Ignores.java b/src/main/java/lol/hub/hubtp/Ignores.java index da559a4..f7b4d36 100644 --- a/src/main/java/lol/hub/hubtp/Ignores.java +++ b/src/main/java/lol/hub/hubtp/Ignores.java @@ -60,6 +60,7 @@ public static boolean get(UUID player, UUID target) { /** * @return true: success, false: maximum ignores reached */ + // this reads/writes the full file on every /tpi ...probably should be changed in the future public static boolean set(UUID player, UUID target, boolean ignore) { Set set = load(player); diff --git a/src/main/java/lol/hub/hubtp/Plugin.java b/src/main/java/lol/hub/hubtp/Plugin.java index 66bf05c..e98ea2c 100644 --- a/src/main/java/lol/hub/hubtp/Plugin.java +++ b/src/main/java/lol/hub/hubtp/Plugin.java @@ -9,12 +9,17 @@ import net.kyori.adventure.text.format.NamedTextColor; import org.bukkit.Location; import org.bstats.bukkit.Metrics; +import org.bukkit.Material; import org.bukkit.command.Command; import org.bukkit.command.CommandSender; import org.bukkit.command.PluginCommand; +import org.bukkit.entity.Boat; +import org.bukkit.entity.Camel; import org.bukkit.entity.Entity; +import org.bukkit.entity.Item; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; +import org.bukkit.entity.Vehicle; import org.bukkit.event.EventHandler; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerMoveEvent; @@ -222,42 +227,173 @@ public void executeTPMove(Player tpTarget, Player tpRequester) { String requesterName = tpRequester.getName(); Log.info("Teleporting " + requesterName + " to " + tpTarget.getName()); - // Leash handling if enabled + this.foliaLib.getScheduler().runAtEntity(tpRequester, requesterTask -> { + boolean freeSeatCandidate = tpRequester.getVehicle() == null + && Config.teleportIntoFreeSeat() + && tpTarget.getVehicle() != null; + + if (!freeSeatCandidate) { + doLeashScanAndTeleport(tpTarget, tpRequester); + return; + } + this.foliaLib.getScheduler().runAtEntity(tpTarget, targetTask -> { + Entity targetVeh = tpTarget.getVehicle(); + if (targetVeh instanceof Vehicle targetVehicle + && isDriverOf(tpTarget, targetVehicle) + && targetVehicle.getPassengers().size() < maxPassengersFor(targetVehicle)) { + teleportIntoFreeSeat(tpTarget, tpRequester, targetVehicle); + } else { + doLeashScanAndTeleport(tpTarget, tpRequester); + } + }); + }); + } + + private void doLeashScanAndTeleport(Player tpTarget, Player tpRequester) { if (Config.includeLeashed() && shouldTpLeashed(tpTarget, tpRequester)) { - tpRequester.getWorld() + List leashed = 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)); + .filter(e -> e.getLeashHolder() != null + && leashHolderMatches(e, tpRequester)) + .toList(); + for (LivingEntity mob : leashed) { + teleportLeashedMob(mob, tpTarget.getLocation()); + } } + doTeleportAndNotify(tpTarget, tpRequester); + } - Location destination = tpTarget.getLocation().clone(); - boolean crossWorld = !tpRequester.getWorld().equals(tpTarget.getWorld()); + private boolean leashHolderMatches(LivingEntity mob, Player requester) { + Entity holder = mob.getLeashHolder(); + if (holder == null) return false; + if (holder.getUniqueId().equals(requester.getUniqueId())) return true; + if (!Config.teleportLeashedOfPassengers()) return false; + Entity mount = requester.getVehicle(); + if (!(mount instanceof Vehicle v)) return false; + for (Entity passenger : v.getPassengers()) { + if (passenger.getUniqueId().equals(holder.getUniqueId())) return true; + } + return false; + } - // 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("!"))); + private void teleportLeashedMob(LivingEntity mob, Location destination) { + Entity holder = mob.getLeashHolder(); + mob.teleportAsync(destination, PlayerTeleportEvent.TeleportCause.PLUGIN) + .whenComplete((ok, ex) -> { + if (ok == null || !ok || ex != null || holder == null) return; + this.foliaLib.getScheduler().runAtEntity(holder, task -> { + if (!mob.isValid() || !holder.isValid()) return; + if (!mob.getWorld().equals(holder.getWorld())) return; + mob.setLeashHolder(holder); + // we drop a lead on teleport; clean it up so the player + // doesn't end up with two (one in item form, one attached to entity) + removeNearbyDroppedLeads(mob.getLocation(), 1); + }); + }); + } + + private void removeNearbyDroppedLeads(Location center, int count) { + for (Entity e : center.getWorld().getNearbyEntities(center, 2, 2, 2)) { + if (count <= 0) return; + if (e instanceof Item item && item.getItemStack().getType() == Material.LEAD) { + item.remove(); + count--; + } + } + } + + private void teleportIntoFreeSeat(Player tpTarget, Player tpRequester, Vehicle vehicle) { + Location dest = tpTarget.getLocation().clone(); + tpRequester.teleportAsync(dest, PlayerTeleportEvent.TeleportCause.COMMAND) + .whenComplete((ok, ex) -> { + if (ok != null && ok && ex == null) { + // addPassenger must run on the vehicle's owning region (Folia) + this.foliaLib.getScheduler().runAtEntity(vehicle, task -> { + if (vehicle.isValid() && tpRequester.isOnline() + && vehicle.getPassengers().size() < maxPassengersFor(vehicle)) { + vehicle.addPassenger(tpRequester); + } + }); + sendOnRegion(tpTarget, Component.text(tpRequester.getName()) + .append(Component.text(" teleported to you and hopped in.", NamedTextColor.GOLD))); + sendOnRegion(tpRequester, Component.text("Teleported to ", NamedTextColor.GOLD) + .append(Component.text(tpTarget.getName())) + .append(Component.text(" and hopped in.", NamedTextColor.GOLD))); } else { - TextComponent msg = Component.text( - "Teleportation failed.", - NamedTextColor.RED); - tpTarget.sendMessage(msg); - tpRequester.sendMessage(msg); + if (ex != null) { + Log.warn("Teleport-into-seat failed: " + ex.getMessage()); + } + TextComponent msg = Component.text("Teleportation failed.", NamedTextColor.RED); + sendOnRegion(tpTarget, msg); + sendOnRegion(tpRequester, msg); } }); } + private static int maxPassengersFor(Vehicle vehicle) { + if (vehicle instanceof Boat) return 2; + if (vehicle instanceof Camel) return 2; + return 1; + } + + private void doTeleportAndNotify(Player tpTarget, Player tpRequester) { + Location destination = tpTarget.getLocation().clone(); + boolean crossWorld = !tpRequester.getWorld().equals(tpTarget.getWorld()); + + // folia: vehicle/mount state lives in requester's region. hop there first. + this.foliaLib.getScheduler().runAtEntity(tpRequester, task -> { + // reject if requester and target share a vehicle + if (sharesVehicleWithRequester(tpRequester, tpTarget)) { + sendOnRegion(tpTarget, Component.text(tpRequester.getName(), NamedTextColor.RED) + .append(Component.text(" and you are in the same vehicle. The request was cancelled.", NamedTextColor.RED))); + sendOnRegion(tpRequester, Component.text( + "Teleportation cancelled: you and ", NamedTextColor.RED) + .append(Component.text(tpTarget.getName(), NamedTextColor.RED)) + .append(Component.text(" are in the same vehicle.", NamedTextColor.RED))); + return; + } + + CompletableFuture result = teleportRequesterWithVehicle(tpRequester, destination, crossWorld); + result.whenComplete((success, ex) -> { + boolean ok = success != null && success && ex == null; + if (ok) { + sendOnRegion(tpTarget, Component.text(tpRequester.getName()) + .append(Component.text(" teleported to you!", NamedTextColor.GOLD))); + sendOnRegion(tpRequester, Component.text("Teleported to ", NamedTextColor.GOLD) + .append(Component.text(tpTarget.getName())) + .append(Component.text("!"))); + } else { + if (ex != null) { + Log.warn("Teleport failed with exception: " + ex.getMessage()); + } + TextComponent msg = Component.text( + "Teleportation failed.", + NamedTextColor.RED); + sendOnRegion(tpTarget, msg); + sendOnRegion(tpRequester, msg); + } + }); + }); + } + + private static boolean sharesVehicleWithRequester(Player requester, Player target) { + Entity mount = requester.getVehicle(); + if (!(mount instanceof Vehicle v)) return false; + for (Entity passenger : v.getPassengers()) { + if (passenger.getUniqueId().equals(target.getUniqueId())) return true; + } + return false; + } + + public void sendOnRegion(Player player, Component message) { + if (player.isOnline()) { + this.foliaLib.getScheduler().runAtEntity(player, task -> player.sendMessage(message)); + } + } + private CompletableFuture teleportRequesterWithVehicle(Player requester, Location destination, boolean crossWorld) { Entity mount = requester.getVehicle(); @@ -266,6 +402,12 @@ private CompletableFuture teleportRequesterWithVehicle(Player requester return requester.teleportAsync(destination, PlayerTeleportEvent.TeleportCause.COMMAND); } + // passengers dismount and tp alone. only the driver brings the vehicle + if (!isDriverOf(requester, mount)) { + requester.leaveVehicle(); + return requester.teleportAsync(destination, PlayerTeleportEvent.TeleportCause.COMMAND); + } + if (!Config.teleportMountedEntities()) { requester.leaveVehicle(); return requester.teleportAsync(destination, PlayerTeleportEvent.TeleportCause.COMMAND); @@ -285,46 +427,52 @@ private CompletableFuture teleportRequesterWithVehicle(Player requester 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)); + teleportFutures.add(passenger.teleportAsync(destination, cause)); } return combineFutures(teleportFutures) .thenApply(success -> { if (success) { - remountPassengers(mount, passengerSnapshots); + // remount on the mounts (now destination) region + this.foliaLib.getScheduler().runAtEntity(mount, + task -> remountPassengers(mount, passengerSnapshots)); } return success; }); } + // same dim: RETAIN_PASSENGERS already moves passengers, don't double tp them 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); + this.foliaLib.getScheduler().runAtEntity(mount, + task -> remountPassengers(mount, passengerSnapshots)); } return success; }); } + /** + * first Player in the passenger list is the "driver" + */ + private static boolean isDriverOf(Player requester, Entity vehicle) { + if (!(vehicle instanceof Vehicle v)) return false; + for (Entity passenger : v.getPassengers()) { + if (passenger instanceof Player) { + return passenger.getUniqueId().equals(requester.getUniqueId()); + } + } + return true; + } + private static void remountPassengers(Entity root, List snapshots) { Map> parentToChildren = new HashMap<>(); @@ -395,16 +543,20 @@ private static CompletableFuture combineFutures(List[] futureArray = futures.toArray(new CompletableFuture[0]); - return CompletableFuture.allOf(futureArray) - .thenApply(ignored -> futures.stream().allMatch(future -> future.getNow(false))); + // getNow() rethrows on exceptional completion, masking the overall result + List> snapshot = List.copyOf(futures); + return CompletableFuture.allOf(snapshot.toArray(new CompletableFuture[0])) + .thenApply(ignored -> snapshot.stream().allMatch(f -> { + if (f.isCompletedExceptionally()) return false; + return Boolean.TRUE.equals(f.getNow(false)); + })); } /** * Clears old teleport requests based on configured timeout. */ public void clearOldRequests() { - RequestManager.clearOldRequests(Config.requestTimeoutSeconds()); + RequestManager.clearOldRequests(this, Config.requestTimeoutSeconds()); } /** diff --git a/src/main/java/lol/hub/hubtp/RequestManager.java b/src/main/java/lol/hub/hubtp/RequestManager.java index 6a5d009..373d473 100644 --- a/src/main/java/lol/hub/hubtp/RequestManager.java +++ b/src/main/java/lol/hub/hubtp/RequestManager.java @@ -15,21 +15,24 @@ public class RequestManager { private static final Map pendingRequests = new ConcurrentHashMap<>(); - static void clearOldRequests(int timeoutValue) { + static void clearOldRequests(Plugin plugin, int timeoutValue) { long time = System.currentTimeMillis(); pendingRequests.forEach((request, requestTime) -> { if (((time - requestTime) / 1000L) > timeoutValue) { pendingRequests.remove(request); Player requester = Bukkit.getPlayer(request.requester().uuid()); if (requester != null) { - requester.sendMessage( + plugin.sendOnRegion(requester, Component.text("Your teleport request to ", NamedTextColor.GOLD) .append(Component.text(request.target().name())) .append(Component.text(" timed out."))); } + // skip target's timeout message if they're ignoring the requester. + // They never saw the request, so a timeout notification would be weird. Player target = Bukkit.getPlayer(request.target().uuid()); - if (target != null) { - target.sendMessage( + if (target != null + && !Ignores.get(target.getUniqueId(), request.requester().uuid())) { + plugin.sendOnRegion(target, Component.text("The teleport request from ", NamedTextColor.GOLD) .append(Component.text(request.requester().name())) .append(Component.text(" timed out."))); diff --git a/src/main/java/lol/hub/hubtp/commands/AskCmd.java b/src/main/java/lol/hub/hubtp/commands/AskCmd.java index 6ceaeb7..ee6efed 100644 --- a/src/main/java/lol/hub/hubtp/commands/AskCmd.java +++ b/src/main/java/lol/hub/hubtp/commands/AskCmd.java @@ -27,14 +27,6 @@ public void run(Player commandSender, String targetName) { return; } - if (Ignores.get(target.getUniqueId(), commandSender.getUniqueId())) { - commandSender.sendMessage( - Component.text(target.getName(), NamedTextColor.RED) - .append(Component.text(" is ignoring your tpa requests!")) - ); - return; - } - if (Ignores.get(commandSender.getUniqueId(), target.getUniqueId())) { commandSender.sendMessage( Component.text("You are ignoring ", NamedTextColor.RED) @@ -103,27 +95,31 @@ public void run(Player commandSender, String targetName) { .append(Component.text(".")) ); - target.sendMessage( - Component.text(commandSender.getName()) - .append(Component.text(" wants to teleport to you. ", NamedTextColor.GOLD)) - .append( - Component.text("[ACCEPT]", NamedTextColor.GREEN) - .hoverEvent(Component.text("Accept the teleport").asHoverEvent()) - .clickEvent(ClickEvent.suggestCommand("/tpy " + commandSender.getName())) - ) - .append(Component.text(" ", NamedTextColor.GOLD)) - .append( - Component.text("[DENY]", NamedTextColor.RED) - .hoverEvent(Component.text("Deny the teleport").asHoverEvent()) - .clickEvent(ClickEvent.suggestCommand("/tpn " + commandSender.getName())) - ) - .append(Component.text(" ", NamedTextColor.GOLD)) - .append( - Component.text("[IGNORE]", NamedTextColor.GRAY) - .hoverEvent(Component.text("Ignore the requester").asHoverEvent()) - .clickEvent(ClickEvent.suggestCommand("/tpi " + commandSender.getName())) - ) - ); + // if target is ignoring the requester, skip the notification but still queue + // the request. it'll time out normally and the requester won't know. + if (!Ignores.get(target.getUniqueId(), commandSender.getUniqueId())) { + target.sendMessage( + Component.text(commandSender.getName()) + .append(Component.text(" wants to teleport to you. ", NamedTextColor.GOLD)) + .append( + Component.text("[ACCEPT]", NamedTextColor.GREEN) + .hoverEvent(Component.text("Accept the teleport").asHoverEvent()) + .clickEvent(ClickEvent.suggestCommand("/tpy " + commandSender.getName())) + ) + .append(Component.text(" ", NamedTextColor.GOLD)) + .append( + Component.text("[DENY]", NamedTextColor.RED) + .hoverEvent(Component.text("Deny the teleport").asHoverEvent()) + .clickEvent(ClickEvent.suggestCommand("/tpn " + commandSender.getName())) + ) + .append(Component.text(" ", NamedTextColor.GOLD)) + .append( + Component.text("[IGNORE]", NamedTextColor.GRAY) + .hoverEvent(Component.text("Ignore the requester").asHoverEvent()) + .clickEvent(ClickEvent.suggestCommand("/tpi " + commandSender.getName())) + ) + ); + } RequestManager.addRequest(target, commandSender); } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 1bc56fe..178c6a6 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -5,6 +5,7 @@ website: "https://github.com/zeroBzeroT/HubTP/" authors: [ 'nothub', 'AlexProgrammerDE', 'MaybeEther', 'SooStrator', 'bierdosenhalter', 'v0ee' ] main: 'lol.hub.hubtp.Plugin' api-version: '1.20' +folia-supported: true commands: tpa: