From ff0523860c0873d0aeceda27410d6703ea7b5f6f Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Thu, 10 Sep 2026 23:48:51 -0400 Subject: [PATCH] feat(heights): carry consenting players with native dismounts --- README.md | 22 ++ .../games/dmg/spigotheights/BukkitCarry.java | 176 +++++++++++ .../games/dmg/spigotheights/CarryPolicy.java | 16 + .../games/dmg/spigotheights/CarryService.java | 146 +++++++++ .../dmg/spigotheights/HeightsCommand.java | 29 +- .../spigotheights/SpigotHeightsPlugin.java | 11 +- src/main/resources/plugin.yml | 5 +- .../dmg/spigotheights/BukkitCarryTest.java | 290 ++++++++++++++++++ .../dmg/spigotheights/CarryPolicyTest.java | 20 ++ .../dmg/spigotheights/CarryServiceTest.java | 189 ++++++++++++ .../dmg/spigotheights/HeightsCommandTest.java | 31 ++ 11 files changed, 929 insertions(+), 6 deletions(-) create mode 100644 src/main/java/games/dmg/spigotheights/BukkitCarry.java create mode 100644 src/main/java/games/dmg/spigotheights/CarryPolicy.java create mode 100644 src/main/java/games/dmg/spigotheights/CarryService.java create mode 100644 src/test/java/games/dmg/spigotheights/BukkitCarryTest.java create mode 100644 src/test/java/games/dmg/spigotheights/CarryPolicyTest.java create mode 100644 src/test/java/games/dmg/spigotheights/CarryServiceTest.java diff --git a/README.md b/README.md index 5a0fa4b..40304e3 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,28 @@ Heights tracks only its loaded clouds and checks them every five ticks, dispatch **Verification:** automated domain and Bukkit-boundary tests cover this implementation. Actual brewing, cloud delivery, client appearance, and logout/restart gameplay on Purpur 26.2 build 2618 still require manual acceptance checks. +## Consensual carrying (US-010; live acceptance pending) + +Sneak-right-click another player with your main hand to request a ride or pickup. The larger player is always the carrier, regardless of who asks. The other player has **30 seconds** to explicitly accept: + +```text +/heights carry accept +/heights carry decline +/heights carry stop +``` + +These commands require **no administrator permission**; settings and player-height commands still require `spigotheights.admin`. Console cannot carry players. One outstanding request per participant prevents replacement/ambiguous consent. Either participant can withdraw a pending request with `stop`; `decline` rejects an incoming request. Requests and carry tracking are not persisted. + +Both players must be available and within **4 blocks**, in the same world, when requesting and accepting. Their **effective** scale difference must be at least **three current adjustment steps** (normally `0.3`), including other plugins' attribute modifiers. Neither may already have a vehicle or passengers. Heights permits only one rider per carrier, and blocks additional/nested mounts involving an active pair. + +Either player can stop with **normal Minecraft dismounting**. Heights calls the rider's native `leaveVehicle()` and allows ordinary sneak-dismounts, leaving placement, falling, damage, and environmental hazards to Minecraft. There is no safe-ground requirement or search, rescue teleport, saved-position/spawn fallback, motion/fall-distance reset, invulnerability, or recovery system. Getting off above a drop is a gameplay risk. + +Eligibility is rechecked every five ticks, including temporary potion application/expiry, administrator edits and external scale modifiers. Death, disconnect, plugin disable and loss of eligibility attempt native cleanup. For observed teleport events, Heights detaches the pair and lets the original teleport continue, without replaying or redirecting it. Native carrier teleports that refuse passengers before emitting an event may still require `/heights carry stop` first. + +Server/plugin cancellation remains authoritative: a refused dismount leaves accurate tracking and is reported, not bypassed or called successful. A cancelled voluntary stop can be retried by either player; automatic eligibility cleanup continues checking and reports refusal once per ride rather than spamming. Lifecycle/disable refusals are logged. Native dismount events are reconciled after dispatch so a later cancellation is honored. An unrelated vehicle is never ejected during stale-pair cleanup. + +**Verification:** automated domain and constructed Bukkit-event tests are not live-server evidence. Purpur 26.2 build 2618 still needs checks for scaled rider positioning and movement, ordinary dismount placement/falling, cancellation interoperability, both-role death/disconnect, portal/cross-world teleports, plugin disable and eligibility changes. No throwing or added launch force, player items, storage, or changes to throwable-potion opt-in behavior are included. + ## Tiny-player launchers A player below the configured scale threshold can walk onto a hopper whose output points into a dispenser. If the block in front of that dispenser is passable, the player is moved there and launched in the direction the dispenser faces. The launcher does not require redstone. diff --git a/src/main/java/games/dmg/spigotheights/BukkitCarry.java b/src/main/java/games/dmg/spigotheights/BukkitCarry.java new file mode 100644 index 0000000..6d73a3f --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/BukkitCarry.java @@ -0,0 +1,176 @@ +package games.dmg.spigotheights; + +import java.util.UUID; +import java.util.function.*; +import java.util.logging.Logger; +import org.bukkit.GameMode; +import org.bukkit.Server; +import org.bukkit.attribute.Attribute; +import org.bukkit.attribute.AttributeInstance; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerInteractAtEntityEvent; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.inventory.EquipmentSlot; + +/** Bukkit boundary for the consent state machine. All methods run on the server thread. */ +public final class BukkitCarry implements Listener, CarryService.Port, AutoCloseable { + private final Function players; + private final Consumer nextTick; + private final Logger logger; + private final ToDoubleFunction scales; + private final CarryService service; + private final java.util.Set dismounting = new java.util.HashSet<>(); + private final java.util.Set queuedStops = new java.util.HashSet<>(); + private UUID mountingCarrier, mountingRider; + private boolean closed; + + public BukkitCarry(Server server, Supplier settings, Consumer nextTick, Logger logger) { + this(server::getPlayer, settings, nextTick, logger, player -> { + AttributeInstance attribute = player.getAttribute(Attribute.SCALE); + return attribute == null ? Double.NaN : attribute.getValue(); + }, () -> System.nanoTime() / 1_000_000); + } + BukkitCarry(Function players, Supplier settings, + Consumer nextTick, Logger logger, ToDoubleFunction scales, LongSupplier clock) { + this.players = players; this.nextTick = nextTick; this.logger = logger; + this.scales = scales; + service = new CarryService(this, () -> settings.get().adjustmentStep(), clock); + } + + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void interact(PlayerInteractEntityEvent event) { + if (event.isCancelled() || event instanceof PlayerInteractAtEntityEvent + || event.getHand() != EquipmentSlot.HAND || !event.getPlayer().isSneaking() + || !(event.getRightClicked() instanceof Player target)) { return; } + event.setCancelled(true); + Player sender = event.getPlayer(); + String result = service.request(sender.getUniqueId(), target.getUniqueId()); + sender.sendMessage(result); + if (result.equals("Carry request sent.")) { + target.sendMessage(sender.getName() + (scales.applyAsDouble(sender) > scales.applyAsDouble(target) + ? " wants to carry you." : " wants to ride with you.")); + } + } + public void command(Player player, String action) { + UUID id = player.getUniqueId(); + player.sendMessage(switch (action) { + case "accept" -> service.accept(id); + case "decline" -> service.decline(id); + case "stop" -> service.stop(id); + default -> "Usage: /heights carry accept|decline|stop"; + }); + } + @Override public CarryService.State state(UUID id) { + Player player = players.apply(id); + if (player == null) { return null; } + return new CarryService.State(scales.applyAsDouble(player), + player.isOnline() && player.isValid() && !player.isDead() && !player.isSleeping() + && player.getGameMode() != GameMode.SPECTATOR, + !player.isInsideVehicle() && player.getPassengers().isEmpty()); + } + @Override public boolean nearby(UUID first, UUID second) { + Player a = players.apply(first), b = players.apply(second); + return a != null && b != null && a.getWorld().getUID().equals(b.getWorld().getUID()) + && a.getLocation().toVector().distanceSquared(b.getLocation().toVector()) <= 16; + } + @Override public boolean mount(UUID carrier, UUID rider) { + Player a = players.apply(carrier), b = players.apply(rider); + if (a == null || b == null) { return false; } + mountingCarrier = carrier; mountingRider = rider; + try { return a.addPassenger(b) && attached(carrier, rider); } + finally { mountingCarrier = null; mountingRider = null; } + } + @Override public boolean linked(UUID carrier, UUID rider) { + Player b = players.apply(rider); + return b != null && b.getVehicle() != null && b.getVehicle().getUniqueId().equals(carrier); + } + @Override public boolean attached(UUID carrier, UUID rider) { + Player a = players.apply(carrier), b = players.apply(rider); + return a != null && b != null && b.getVehicle() != null && b.getVehicle().getUniqueId().equals(carrier) + && !a.isInsideVehicle() && b.getPassengers().isEmpty() && a.getPassengers().size() == 1 + && a.getPassengers().getFirst().getUniqueId().equals(rider) + && a.getWorld().getUID().equals(b.getWorld().getUID()); + } + @Override public boolean dismount(UUID carrier, UUID rider) { + Player b = players.apply(rider); + if (b == null) { + Player a = players.apply(carrier); + if (a != null) { + b = a.getPassengers().stream().filter(entity -> entity.getUniqueId().equals(rider)) + .filter(Player.class::isInstance).map(Player.class::cast).findFirst().orElse(null); + } + } + if (b == null || b.getVehicle() == null || !b.getVehicle().getUniqueId().equals(carrier)) { + return true; // Already detached; never disturb an unrelated vehicle. + } + if (!dismounting.add(rider)) { return false; } + try { + b.leaveVehicle(); + } finally { + dismounting.remove(rider); + } + // Native API return values do not reliably reflect cancellation. Inspect the relationship. + return b.getVehicle() == null || !b.getVehicle().getUniqueId().equals(carrier); + } + @Override public void message(UUID id, String message) { + Player player = players.apply(id); + if (player != null && player.isOnline()) { player.sendMessage(message); } + } + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void mountEvent(org.bukkit.event.entity.EntityMountEvent event) { + UUID rider = event.getEntity().getUniqueId(), carrier = event.getMount().getUniqueId(); + if (rider.equals(mountingRider) && carrier.equals(mountingCarrier)) { return; } + if (service.involved(rider) || service.involved(carrier) + || rider.equals(mountingCarrier) || carrier.equals(mountingRider) + || rider.equals(mountingRider) || carrier.equals(mountingCarrier)) { event.setCancelled(true); } + } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void dismountEvent(org.bukkit.event.entity.EntityDismountEvent event) { + UUID id = event.getEntity().getUniqueId(); + if (event.isCancelled() || dismounting.contains(id) || !service.involved(id) + || closed || !queuedStops.add(id)) { return; } + // The event precedes native detachment. Reconcile actual attachment next tick; + // never cancel sneak-dismount or force a second dismount if another listener cancels. + nextTick.accept(() -> { + queuedStops.remove(id); + if (!closed && !event.isCancelled()) { service.tick(); } + }); + } + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) + public void teleportEvent(org.bukkit.event.player.PlayerTeleportEvent event) { + if (event.isCancelled()) { return; } + Player player = event.getPlayer(); + UUID id = player.getUniqueId(); + // No rescue/replay teleport: detach through the native API, then allow the original. + // If another plugin refuses detachment, do not move an attached pair behind its back. + if (!service.cleanup(id)) { + event.setCancelled(true); + player.sendMessage("The server refused the dismount; stop carrying before teleporting."); + } + } + @EventHandler(priority = EventPriority.MONITOR) + public void quit(org.bukkit.event.player.PlayerQuitEvent event) { + UUID id = event.getPlayer().getUniqueId(); + if (!service.cleanup(id)) { reportUnresolved(id, "disconnect"); } + } + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void death(org.bukkit.event.entity.PlayerDeathEvent event) { + if (event.isCancelled()) { return; } + UUID id = event.getEntity().getUniqueId(); + if (!service.cleanup(id)) { reportUnresolved(id, "death"); } + } + private void reportUnresolved(UUID id, String cause) { + logger.severe("Heights " + cause + ": carry involving " + id + + " remains attached because the server refused native dismounting; no cancellation was bypassed."); + } + public void tick() { if (!closed) { service.tick(); } } + @Override public void close() { + closed = true; + int unresolved = service.close(); + if (unresolved != 0) { logger.severe("Heights disable: " + unresolved + + " native dismount(s) were refused by the server; no cancellation was bypassed."); } + } +} diff --git a/src/main/java/games/dmg/spigotheights/CarryPolicy.java b/src/main/java/games/dmg/spigotheights/CarryPolicy.java new file mode 100644 index 0000000..abbdbac --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/CarryPolicy.java @@ -0,0 +1,16 @@ +package games.dmg.spigotheights; + +/** Server-independent carry eligibility, using effective rather than base scale. */ +public final class CarryPolicy { + private CarryPolicy() {} + + public static boolean eligible(double carrier, double rider, double step) { + if (!Double.isFinite(carrier) || !Double.isFinite(rider) || !Double.isFinite(step) + || carrier <= 0 || rider <= 0 || step <= 0 || carrier <= rider) { + return false; + } + double required = 3 * step; + double tolerance = 4 * Math.ulp(Math.max(carrier, rider)); + return Double.isFinite(required) && carrier - rider + tolerance >= required; + } +} diff --git a/src/main/java/games/dmg/spigotheights/CarryService.java b/src/main/java/games/dmg/spigotheights/CarryService.java new file mode 100644 index 0000000..abb2882 --- /dev/null +++ b/src/main/java/games/dmg/spigotheights/CarryService.java @@ -0,0 +1,146 @@ +package games.dmg.spigotheights; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.DoubleSupplier; +import java.util.function.LongSupplier; + +/** Tick-thread state machine; ports must not perform blocking I/O. */ +public final class CarryService { + public record State(double scale, boolean available, boolean free) {} + public interface Port { + State state(UUID player); + boolean nearby(UUID first, UUID second); + boolean mount(UUID carrier, UUID rider); + boolean attached(UUID carrier, UUID rider); + boolean linked(UUID carrier, UUID rider); + boolean dismount(UUID carrier, UUID rider); + void message(UUID player, String message); + } + private record Request(UUID sender, UUID recipient, UUID carrier, UUID rider, long sentAt) {} + private record Ride(UUID carrier, UUID rider) {} + private final Port port; + private final DoubleSupplier step; + private final LongSupplier clock; + private final Map requests = new HashMap<>(); + private final Map rides = new HashMap<>(); + private final java.util.Set reportedRefusals = new java.util.HashSet<>(); + private boolean closed; + + public CarryService(Port port, DoubleSupplier step, LongSupplier clock) { + this.port = port; this.step = step; this.clock = clock; + } + + public String request(UUID sender, UUID recipient) { + if (closed) { return "Carrying is shutting down."; } + expireRequests(); + if (busy(sender) || busy(recipient)) { return "A player already has a carry request or ride."; } + State a = port.state(sender), b = port.state(recipient); + if (sender.equals(recipient) || a == null || b == null) { return "Players are unavailable."; } + UUID carrier = a.scale() > b.scale() ? sender : recipient; + UUID rider = carrier.equals(sender) ? recipient : sender; + if (!eligible(carrier, rider, true) || !port.nearby(sender, recipient)) { + return "Carry requires nearby, available players differing by at least three adjustment steps."; + } + Request request = new Request(sender, recipient, carrier, rider, clock.getAsLong()); + requests.put(recipient, request); + port.message(recipient, "Carry request: /heights carry accept or /heights carry decline within 30 seconds."); + return "Carry request sent."; + } + + public String accept(UUID recipient) { + Request request = requests.remove(recipient); + if (request == null) { return "No pending request."; } + if (clock.getAsLong() - request.sentAt() >= 30_000 + || involved(request.carrier()) || involved(request.rider()) + || !eligible(request.carrier(), request.rider(), true) + || !port.nearby(request.carrier(), request.rider())) { + return "Carry request expired or players are no longer eligible/nearby."; + } + boolean mounted = port.mount(request.carrier(), request.rider()); + if (!mounted && !port.linked(request.carrier(), request.rider())) { return "The server refused the ride."; } + Ride ride = new Ride(request.carrier(), request.rider()); + rides.put(ride.carrier(), ride); rides.put(ride.rider(), ride); + if (!mounted) { + String stopped = stop(ride.rider()); + String message = "The server produced an invalid ride. " + stopped; + if (involved(ride.rider())) { notify(ride, message); } + return message; + } + notify(ride, "Carry started. Either player can /heights carry stop."); + return "Carry started."; + } + + private boolean eligible(UUID carrier, UUID rider, boolean free) { + State a = port.state(carrier), b = port.state(rider); + return a != null && b != null && a.available() && b.available() + && (!free || (a.free() && b.free())) + && CarryPolicy.eligible(a.scale(), b.scale(), step.getAsDouble()); + } + + private boolean busy(UUID player) { + return involved(player) || requests.values().stream() + .anyMatch(request -> request.sender().equals(player) || request.recipient().equals(player)); + } + + private void expireRequests() { + requests.values().removeIf(request -> clock.getAsLong() - request.sentAt() >= 30_000); + } + + public String decline(UUID recipient) { + Request request = requests.remove(recipient); + if (request == null) { return "No pending request."; } + port.message(request.sender(), "Carry request declined."); + return "Carry request declined."; + } + public String stop(UUID player) { + var pending = requests.values().iterator(); + while (pending.hasNext()) { + Request request = pending.next(); + if (request.sender().equals(player) || request.recipient().equals(player)) { + pending.remove(); + port.message(request.sender().equals(player) ? request.recipient() : request.sender(), + "Carry request cancelled."); + return "Carry request cancelled."; + } + } + Ride ride = rides.get(player); + if (ride == null) { return "No active ride."; } + if (!port.dismount(ride.carrier(), ride.rider())) { + return "The server refused the dismount. The ride is still active; retry /heights carry stop."; + } + rides.remove(ride.carrier()); rides.remove(ride.rider()); + reportedRefusals.remove(ride.rider()); + notify(ride, "Carry ended."); + return "Carry ended."; + } + private void notify(Ride ride, String message) { + port.message(ride.carrier(), message); port.message(ride.rider(), message); + } + public void tick() { + expireRequests(); + for (Ride ride : java.util.Set.copyOf(rides.values())) { + if (!eligible(ride.carrier(), ride.rider(), false) + || !port.attached(ride.carrier(), ride.rider())) { + String result = stop(ride.rider()); + if (involved(ride.rider()) && reportedRefusals.add(ride.rider())) { + notify(ride, result); + } + } + } + } + public boolean cleanup(UUID player) { + requests.values().removeIf(request -> request.sender().equals(player) || request.recipient().equals(player)); + stop(player); + return !involved(player); + } + /** Returns server-refused dismounts; never bypasses cancellation or claims false detachment. */ + public int close() { + closed = true; + requests.clear(); + for (Ride ride : java.util.Set.copyOf(rides.values())) { stop(ride.rider()); } + return rides.size() / 2; + } + public boolean involved(UUID player) { return rides.containsKey(player); } +} diff --git a/src/main/java/games/dmg/spigotheights/HeightsCommand.java b/src/main/java/games/dmg/spigotheights/HeightsCommand.java index e112e3e..72b3d6e 100644 --- a/src/main/java/games/dmg/spigotheights/HeightsCommand.java +++ b/src/main/java/games/dmg/spigotheights/HeightsCommand.java @@ -17,6 +17,9 @@ public final class HeightsCommand implements TabExecutor { private final Logger logger; private final PlayerHeights players; private final HeightSetter heightSetter; + private final java.util.function.BiConsumer carry; + private static final String CARRY_USAGE = "Usage: /heights carry accept|decline|stop"; + private static final List CARRY_ACTIONS = List.of("accept", "decline", "stop"); @FunctionalInterface public interface HeightSetter { @@ -28,14 +31,31 @@ public final class HeightsCommand implements TabExecutor { } public HeightsCommand(LiveHeightSettings settings, PlayerHeights players, Logger logger, HeightSetter heightSetter) { + this(settings, players, logger, heightSetter, + (player, action) -> player.sendMessage("Carrying is unavailable.")); + } + + public HeightsCommand(LiveHeightSettings settings, PlayerHeights players, Logger logger, HeightSetter heightSetter, + java.util.function.BiConsumer carry) { this.settings = settings; this.players = players; this.logger = logger; this.heightSetter = heightSetter; + this.carry = carry; } @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + if (args.length > 0 && args[0].equalsIgnoreCase("carry")) { + if (!(sender instanceof org.bukkit.entity.Player player)) { + sender.sendMessage("Only players can use carry commands."); + } else if (args.length != 2 || !CARRY_ACTIONS.contains(args[1].toLowerCase(Locale.ROOT))) { + sender.sendMessage(CARRY_USAGE); + } else { + carry.accept(player, args[1].toLowerCase(Locale.ROOT)); + } + return true; + } if (!sender.hasPermission(PERMISSION)) { sender.sendMessage("You do not have permission to manage Spigot Heights."); return true; @@ -96,11 +116,16 @@ public final class HeightsCommand implements TabExecutor { @Override public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + boolean player = sender instanceof org.bukkit.entity.Player; + if (player && args.length == 2 && args[0].equalsIgnoreCase("carry")) { + return matching(CARRY_ACTIONS.stream(), args[1]); + } if (!sender.hasPermission(PERMISSION)) { - return List.of(); + return player && args.length == 1 ? matching(Stream.of("carry"), args[0]) : List.of(); } if (args.length == 1) { - return matching(Stream.of("settings", "set", "player"), args[0]); + return matching(player ? Stream.of("carry", "settings", "set", "player") + : Stream.of("settings", "set", "player"), args[0]); } if (args.length == 2 && args[0].equalsIgnoreCase("player")) { return matching(players.names().stream(), args[1]); diff --git a/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java b/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java index 4bd5e21..222d8fc 100644 --- a/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java +++ b/src/main/java/games/dmg/spigotheights/SpigotHeightsPlugin.java @@ -8,6 +8,7 @@ import org.bukkit.plugin.java.JavaPlugin; public final class SpigotHeightsPlugin extends JavaPlugin { private BukkitStatureService stature; private PotionRecipes potions; + private BukkitCarry carry; @Override public void onEnable() { saveDefaultConfig(); @@ -30,7 +31,12 @@ public final class SpigotHeightsPlugin extends JavaPlugin { BukkitPlayerHeights onlinePlayers = new BukkitPlayerHeights(getServer()); PlayerHeights playerHeights = new PlayerHeights(liveSettings, store, onlinePlayers::find, onlinePlayers::names); stature = new BukkitStatureService(this, liveSettings, store); - HeightsCommand executor = new HeightsCommand(liveSettings, playerHeights, getLogger(), stature::set); + carry = new BukkitCarry(getServer(), liveSettings, + task -> getServer().getScheduler().runTask(this, task), getLogger()); + getServer().getPluginManager().registerEvents(carry, this); + // Includes external attribute modifiers and US-009 expiry/admin updates; no movement-event scans. + getServer().getScheduler().runTaskTimer(this, carry::tick, 5L, 5L); + HeightsCommand executor = new HeightsCommand(liveSettings, playerHeights, getLogger(), stature::set, carry::command); PluginCommand command = Objects.requireNonNull(getCommand("heights"), "Missing heights command declaration"); command.setExecutor(executor); command.setTabCompleter(executor); @@ -54,6 +60,9 @@ public final class SpigotHeightsPlugin extends JavaPlugin { @Override public void onDisable() { + if (carry != null) { + carry.close(); + } if (stature != null) { stature.close(); } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 73de37e..97d13b3 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -6,9 +6,8 @@ description: Craftable player stature potions and tiny-player dispenser launcher author: dmg.games commands: heights: - description: View and change stature limits and online player heights. - usage: /heights settings | /heights set min|max | /heights player [scale] - permission: spigotheights.admin + description: Request consensual carrying or administer stature settings and player heights. + usage: /heights carry accept|decline|stop | /heights settings | /heights set min|max | /heights player [scale] permissions: spigotheights.admin: description: View and change Spigot Heights settings and player heights. diff --git a/src/test/java/games/dmg/spigotheights/BukkitCarryTest.java b/src/test/java/games/dmg/spigotheights/BukkitCarryTest.java new file mode 100644 index 0000000..9b9ec0d --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/BukkitCarryTest.java @@ -0,0 +1,290 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.*; +import java.util.logging.Logger; +import org.bukkit.*; +import org.bukkit.entity.*; +import org.bukkit.event.player.PlayerInteractEntityEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("removal") // Construct declared-Purpur events without installing a server. +class BukkitCarryTest { + private final UUID worldId = UUID.randomUUID(); + private final World world = BukkitDoubles.proxy(World.class, (p, m, a) -> { + if (m.getName().equals("getUID")) { return worldId; } + throw new UnsupportedOperationException(m.getName()); + }); + private final Actor small = new Actor("Small", 0.4, 0), large = new Actor("Large", 1, 1); + private final List scheduled = new ArrayList<>(); + private final List warnings = new ArrayList<>(); + private final Logger logger = new Logger("carry-test", null) { + @Override public void warning(String message) { warnings.add(message); } + @Override public void severe(String message) { warnings.add(message); } + }; + private boolean dismountAllowed = true, reenterStop, invalidMountTopology; + private int nativeDismounts; + private final BukkitCarry carry = new BukkitCarry(id -> id.equals(small.id) ? small.player + : id.equals(large.id) ? large.player : null, + () -> new HeightSettings(0.4, 2, 0.1, 0.5, 1.5, 20), scheduled::add, logger, + p -> actor(p).scale, () -> 0); + + private Actor actor(Player p) { return p == small.player ? small : large; } + private final class Actor { + final UUID id = UUID.randomUUID(); + final List messages = new ArrayList<>(); + final List passengers = new ArrayList<>(); + final Player player; + Entity vehicle; + Location location; + double scale; + boolean sneaking = true, online = true, dead; + Actor(String name, double scale, double x) { + this.scale = scale; location = new Location(world, x, 64, 0); + player = BukkitDoubles.proxy(Player.class, (p, m, a) -> switch (m.getName()) { + case "getUniqueId" -> id; + case "getName" -> name; + case "getWorld" -> world; + case "getLocation" -> location.clone(); + case "isOnline", "isValid" -> online; + case "isDead" -> dead; + case "isSleeping" -> false; + case "getGameMode" -> GameMode.SURVIVAL; + case "isSneaking" -> sneaking; + case "isInsideVehicle" -> vehicle != null; + case "getVehicle" -> vehicle; + case "getPassengers" -> List.copyOf(passengers); + case "addPassenger" -> { + Player rider = (Player) a[0]; passengers.add(rider); actor(rider).vehicle = (Entity) p; + if (invalidMountTopology) { + UUID otherId = UUID.randomUUID(); + vehicle = BukkitDoubles.proxy(Entity.class, (entity, method, args) -> { + if (method.getName().equals("getUniqueId")) { return otherId; } + throw new UnsupportedOperationException(method.getName()); + }); + } + yield true; + } + case "leaveVehicle" -> { + nativeDismounts++; + if (reenterStop) { + reenterStop = false; + carry.command((Player) p, "stop"); + } + if (vehicle != null) { + var event = new org.bukkit.event.entity.EntityDismountEvent((Player) p, vehicle, true); + if (!dismountAllowed) { event.setCancelled(true); } + carry.dismountEvent(event); + if (!event.isCancelled()) { + actor((Player) vehicle).passengers.removeIf(e -> e == p); vehicle = null; + } + } + yield true; // Intentionally misleading: adapter must check actual native attachment. + } + case "sendMessage" -> { messages.add((String) a[0]); yield null; } + case "equals" -> p == a[0]; + case "hashCode" -> id.hashCode(); + case "toString" -> name; + case "teleport", "setFallDistance", "setVelocity", "setInvulnerable", "eject", "removePassenger" -> + throw new AssertionError("Native dismount must not rescue, reset motion, or eject other passengers"); + default -> throw new UnsupportedOperationException(m.getName()); + }); + } + } + private PlayerInteractEntityEvent click(Actor from, Actor to, EquipmentSlot hand, boolean cancelled) { + PlayerInteractEntityEvent event = new PlayerInteractEntityEvent(from.player, to.player, hand); + event.setCancelled(cancelled); carry.interact(event); return event; + } + private void start() { + click(small, large, EquipmentSlot.HAND, false); carry.command(large.player, "accept"); + assertSame(large.player, small.vehicle); + } + private void drain() { + List tasks = List.copyOf(scheduled); scheduled.clear(); tasks.forEach(Runnable::run); + } + @Test + void startsWithoutSafeGroundAndNativeStopDoesNotTeleportOrResetMotion() { + start(); + Location before = small.location.clone(); + carry.command(small.player, "stop"); + assertEquals(1, nativeDismounts); + assertNull(small.vehicle); + assertEquals(before, small.location); + } + @Test + void carrierCanAlsoStopUsingOnlyTheRidersNativeDismount() { + start(); + carry.command(large.player, "stop"); + assertEquals(1, nativeDismounts); + assertNull(small.vehicle); + } + @Test + void preventsNestedMountsButDoesNotCancelNaturalSneakDismount() { + start(); + var nested = new org.bukkit.event.entity.EntityMountEvent(large.player, small.player); + carry.mountEvent(nested); + assertTrue(nested.isCancelled()); + var exit = new org.bukkit.event.entity.EntityDismountEvent(small.player, large.player, true); + carry.dismountEvent(exit); + assertFalse(exit.isCancelled()); + small.player.leaveVehicle(); + drain(); + assertNull(small.vehicle); + assertEquals(1, nativeDismounts); + start(); // The native detach also cleared Heights' pair tracking. + } + @Test + void allowedTeleportDetachesWithoutCancellingOrReplayingTeleport() { + start(); + var event = new org.bukkit.event.player.PlayerTeleportEvent( + large.player, large.location, new Location(world, 50, 70, 50)); + event.setCancelled(true); carry.teleportEvent(event); drain(); + assertSame(large.player, small.vehicle); + event.setCancelled(false); carry.teleportEvent(event); + assertFalse(event.isCancelled()); + assertNull(small.vehicle); + assertEquals(1, nativeDismounts); + assertEquals(50, event.getTo().getX()); + } + @Test + void nativeCancellationIsNotMistakenForDetachmentAndCanBeRetried() { + start(); dismountAllowed = false; + carry.command(small.player, "stop"); + assertSame(large.player, small.vehicle); + assertTrue(small.messages.stream().anyMatch(m -> m.contains("refused"))); + dismountAllowed = true; + carry.tick(); + assertEquals(1, nativeDismounts, "A cancelled voluntary stop is not an automatic rescue/retry job"); + carry.command(large.player, "stop"); + assertNull(small.vehicle); + assertEquals(2, nativeDismounts); + } + @Test + void refusedDismountPreventsTeleportFromMovingTheActivePair() { + start(); dismountAllowed = false; + var event = new org.bukkit.event.player.PlayerTeleportEvent( + large.player, large.location, new Location(world, 50, 70, 50)); + carry.teleportEvent(event); + assertTrue(event.isCancelled()); + assertSame(large.player, small.vehicle); + } + @Test + void quitCleansBothRolesIncludingAnOfflineRiderWithoutRescue() { + start(); + carry.quit(new org.bukkit.event.player.PlayerQuitEvent(large.player, (net.kyori.adventure.text.Component) null)); + assertNull(small.vehicle); + start(); small.online = false; + carry.quit(new org.bukkit.event.player.PlayerQuitEvent(small.player, (net.kyori.adventure.text.Component) null)); + assertNull(small.vehicle); + assertTrue(warnings.isEmpty()); + } + @Test + void cancelledPurpurDeathDoesNotStopRideButActualDeathDoesForEitherRole() { + for (Actor actor : List.of(large, small)) { + start(); + var death = new org.bukkit.event.entity.PlayerDeathEvent( + actor.player, null, List.of(), 0, (net.kyori.adventure.text.Component) null, true); + death.setCancelled(true); carry.death(death); + assertSame(large.player, small.vehicle); + death.setCancelled(false); actor.dead = true; carry.death(death); + assertNull(small.vehicle); + actor.dead = false; + } + assertEquals(2, nativeDismounts); + } + @Test + void effectiveScaleChangesUseNativeCleanupAndRespectRefusals() { + start(); small.scale = 0.9; dismountAllowed = false; + carry.tick(); + assertSame(large.player, small.vehicle); + dismountAllowed = true; carry.tick(); + assertNull(small.vehicle); + } + @Test + void disableUsesNativeDismountAndReportsServerRefusalsWithoutClaimingSuccess() { + start(); dismountAllowed = false; + carry.close(); + assertSame(large.player, small.vehicle); + assertTrue(warnings.stream().anyMatch(w -> w.contains("refused"))); + dismountAllowed = true; carry.close(); + assertNull(small.vehicle); + } + @Test + void nonCancellableNativeDismountNeedsNoRescueOrWarning() { + start(); + var event = new org.bukkit.event.entity.EntityDismountEvent(small.player, large.player, false); + carry.dismountEvent(event); + assertFalse(event.isCancelled()); + large.passengers.clear(); small.vehicle = null; // Native forced detachment after event dispatch. + drain(); + assertEquals(0, nativeDismounts); + assertTrue(warnings.isEmpty()); + start(); + } + @Test + void partialNativeMountIsTrackedUntilItsInvalidRelationshipCanBeDetached() { + invalidMountTopology = true; dismountAllowed = false; + click(small, large, EquipmentSlot.HAND, false); carry.command(large.player, "accept"); + assertSame(large.player, small.vehicle); + assertEquals(1, nativeDismounts, "Attempt native cleanup of a partially successful invalid mount"); + assertFalse(large.messages.getLast().contains("started")); + dismountAllowed = true; + carry.command(small.player, "stop"); + assertNull(small.vehicle); + assertEquals(2, nativeDismounts); + assertNotNull(large.vehicle, "Do not eject an unrelated relationship owned by another plugin"); + } + @Test + void synchronousPluginReentryCannotRecursivelyDismountTheSameRider() { + start(); reenterStop = true; + carry.command(small.player, "stop"); + assertEquals(1, nativeDismounts); + assertNull(small.vehicle); + assertEquals("Carry ended.", small.messages.getLast()); + } + @Test + void cancelledNaturalDismountIsNotRetriedAndLateCancellationIsObserved() { + start(); dismountAllowed = false; + small.player.leaveVehicle(); + drain(); + assertSame(large.player, small.vehicle); + assertEquals(1, nativeDismounts); + var lateCancelled = new org.bukkit.event.entity.EntityDismountEvent(small.player, large.player, true); + carry.dismountEvent(lateCancelled); + lateCancelled.setCancelled(true); + drain(); + assertSame(large.player, small.vehicle); + assertEquals(1, nativeDismounts); + } + @Test + void stalePairDoesNotEjectRiderFromAnUnrelatedVehicle() { + start(); + UUID otherId = UUID.randomUUID(); + Entity unrelated = BukkitDoubles.proxy(Entity.class, (p, method, args) -> { + if (method.getName().equals("getUniqueId")) { return otherId; } + throw new UnsupportedOperationException(method.getName()); + }); + large.passengers.clear(); small.vehicle = unrelated; + carry.tick(); + assertSame(unrelated, small.vehicle); + assertEquals(0, nativeDismounts); + carry.command(large.player, "stop"); + assertEquals("No active ride.", large.messages.getLast()); + } + @Test + void onlySneakingUncancelledMainHandRequestsMountAfterAcceptance() { + click(small, large, EquipmentSlot.HAND, true); + click(small, large, EquipmentSlot.OFF_HAND, false); + small.sneaking = false; click(small, large, EquipmentSlot.HAND, false); small.sneaking = true; + assertTrue(large.messages.isEmpty()); + assertTrue(click(small, large, EquipmentSlot.HAND, false).isCancelled()); + assertFalse(large.messages.isEmpty()); + assertTrue(large.passengers.isEmpty()); + carry.command(large.player, "accept"); + assertSame(large.player, small.vehicle); + assertEquals(List.of(small.player), large.passengers); + carry.command(large.player, "stop"); + assertNull(small.vehicle); + } +} diff --git a/src/test/java/games/dmg/spigotheights/CarryPolicyTest.java b/src/test/java/games/dmg/spigotheights/CarryPolicyTest.java new file mode 100644 index 0000000..a053831 --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/CarryPolicyTest.java @@ -0,0 +1,20 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.*; +import org.junit.jupiter.api.Test; + +class CarryPolicyTest { + @Test + void requiresThreeCurrentStepsWithDecimalBoundaryTolerance() { + assertTrue(CarryPolicy.eligible(1.0, 0.7, 0.1)); + assertTrue(CarryPolicy.eligible(0.7, 0.4, 0.1)); + assertFalse(CarryPolicy.eligible(1.0, 0.700001, 0.1)); + assertFalse(CarryPolicy.eligible(1.0, 0.7, 0.2)); + assertFalse(CarryPolicy.eligible(0.7, 1.0, 0.1)); + for (double invalid : new double[] {0, -1, Double.NaN, Double.POSITIVE_INFINITY}) { + assertFalse(CarryPolicy.eligible(invalid, 0.4, 0.1)); + assertFalse(CarryPolicy.eligible(1, invalid, 0.1)); + assertFalse(CarryPolicy.eligible(1, 0.4, invalid)); + } + } +} diff --git a/src/test/java/games/dmg/spigotheights/CarryServiceTest.java b/src/test/java/games/dmg/spigotheights/CarryServiceTest.java new file mode 100644 index 0000000..8930136 --- /dev/null +++ b/src/test/java/games/dmg/spigotheights/CarryServiceTest.java @@ -0,0 +1,189 @@ +package games.dmg.spigotheights; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.*; +import org.junit.jupiter.api.Test; + +class CarryServiceTest { + private final UUID small = UUID.randomUUID(), large = UUID.randomUUID(), third = UUID.randomUUID(); + private final Map states = new HashMap<>(Map.of( + small, new CarryService.State(0.4, true, true), + large, new CarryService.State(1, true, true), + third, new CarryService.State(2, true, true))); + private long now; + private double step = 0.1; + private boolean near = true, mountAllowed = true, stopAllowed = true, attached; + private int mounts, stops; + private UUID mountedCarrier, mountedRider; + private final List messages = new ArrayList<>(); + private final CarryService service = new CarryService(new CarryService.Port() { + public CarryService.State state(UUID id) { return states.get(id); } + public boolean nearby(UUID a, UUID b) { return near; } + public boolean mount(UUID carrier, UUID rider) { + mounts++; mountedCarrier = carrier; mountedRider = rider; + attached = mountAllowed; return mountAllowed; + } + public boolean attached(UUID carrier, UUID rider) { return attached; } + public boolean linked(UUID carrier, UUID rider) { return attached; } + public boolean dismount(UUID carrier, UUID rider) { + stops++; + if (stopAllowed) { attached = false; } + return stopAllowed; + } + public void message(UUID id, String text) { messages.add(text); } + }, () -> step, () -> now); + + @Test + void consentExpiresAtThirtySecondsAndRechecksLiveConditions() { + service.request(small, large); + now = 30_000; + assertFalse(service.accept(large).contains("started")); + assertEquals(0, mounts); + for (int change = 0; change < 4; change++) { + near = true; step = 0.1; + states.put(small, new CarryService.State(0.4, true, true)); + service.request(small, large); + switch (change) { + case 0 -> near = false; + case 1 -> step = 0.3; + case 2 -> states.put(small, new CarryService.State(0.8, true, true)); + case 3 -> states.put(small, new CarryService.State(0.4, false, true)); + default -> throw new AssertionError(); + } + assertFalse(service.accept(large).contains("started")); + assertEquals(0, mounts); + } + near = true; step = 0.1; + states.put(small, new CarryService.State(0.4, true, true)); + service.request(small, large); + now += 29_999; + assertTrue(service.accept(large).contains("started")); + } + + @Test + void eitherParticipantCanWithdrawPendingConsentWithStop() { + for (UUID caller : List.of(small, large)) { + assertTrue(service.request(small, large).contains("sent")); + assertTrue(service.stop(caller).contains("cancelled")); + assertTrue(service.accept(large).contains("No pending")); + assertEquals(0, mounts); + } + } + + @Test + void automaticNativeRefusalIsReportedOnceRatherThanSilentlyRetried() { + service.request(small, large); service.accept(large); + messages.clear(); + states.put(small, new CarryService.State(0.9, true, true)); + stopAllowed = false; + service.tick(); service.tick(); + assertEquals(2, messages.size(), "Notify both participants once, not every tick"); + assertTrue(messages.stream().allMatch(message -> message.contains("refused"))); + assertTrue(service.involved(small)); + stopAllowed = true; service.tick(); + assertFalse(service.involved(small)); + } + + @Test + void reservesOneRequestPerPlayerAndDeclineReleasesBoth() { + assertTrue(service.request(small, large).contains("sent")); + assertFalse(service.request(third, large).contains("sent")); + assertFalse(service.request(small, third).contains("sent")); + assertTrue(service.decline(large).contains("declined")); + assertTrue(service.request(small, third).contains("sent")); + now = 30_000; + service.tick(); + assertTrue(service.request(small, large).contains("sent")); + } + + @Test + void rejectsExistingPassengersVehiclesAndActiveRidesAndFailedMounts() { + for (UUID busy : List.of(small, large)) { + CarryService.State before = states.get(busy); + states.put(busy, new CarryService.State(before.scale(), true, false)); + assertFalse(service.request(small, large).contains("sent")); + states.put(busy, before); + } + service.request(small, large); + states.put(small, new CarryService.State(0.4, true, false)); + assertFalse(service.accept(large).contains("started")); + states.put(small, new CarryService.State(0.4, true, true)); + service.request(small, large); + mountAllowed = false; + assertFalse(service.accept(large).contains("started")); + assertFalse(service.involved(small)); + mountAllowed = true; + service.request(small, large); + service.accept(large); + assertFalse(service.request(third, large).contains("sent")); + assertFalse(service.request(small, third).contains("sent")); + } + + @Test + void lossOfEligibilityAttemptsNativeStopAndRefusedVoluntaryStopsRemainAccuratelyTracked() { + service.request(small, large); service.accept(large); + stopAllowed = false; + states.put(small, new CarryService.State(0.9, true, true)); + service.tick(); + assertEquals(1, stops); + assertTrue(service.involved(small)); + assertTrue(service.stop(large).contains("refused")); + // No pending rescue job: if eligibility recovers, a voluntary stop can be requested again. + states.put(small, new CarryService.State(0.4, true, true)); + stopAllowed = true; + service.tick(); + assertTrue(service.involved(small)); + assertTrue(service.stop(small).contains("ended")); + assertFalse(service.involved(small)); + assertFalse(service.involved(large)); + } + + @Test + void lifecycleClearsRequestsAndRidesAndReportsUnresolvedDisable() { + service.request(small, large); + assertTrue(service.cleanup(small)); + assertTrue(service.accept(large).contains("No pending")); + service.request(small, large); service.accept(large); + assertTrue(service.cleanup(large)); + assertFalse(service.involved(small)); + service.request(small, large); service.accept(large); + stopAllowed = false; + assertFalse(service.cleanup(small)); + assertEquals(1, service.close()); + assertTrue(service.involved(small)); + assertFalse(service.request(third, large).contains("sent")); + stopAllowed = true; + assertEquals(0, service.close()); + } + + @Test + void unavailableOrDetachedParticipantsTriggerCleanup() { + service.request(small, large); service.accept(large); + attached = false; + service.tick(); + assertEquals(1, stops); + assertFalse(service.involved(large)); + service.request(small, large); service.accept(large); + states.put(large, new CarryService.State(1, false, false)); + service.tick(); + assertEquals(2, stops); + assertFalse(service.involved(large)); + } + + @Test + void eitherDirectionRequiresOtherPlayersExplicitConsent() { + for (boolean pickup : new boolean[] {false, true}) { + UUID sender = pickup ? large : small, recipient = pickup ? small : large; + assertTrue(service.request(sender, recipient).contains("sent")); + assertEquals(0, mounts); + assertTrue(service.accept(sender).contains("No pending")); + assertTrue(service.accept(recipient).contains("started")); + assertEquals(large, mountedCarrier); + assertEquals(small, mountedRider); + assertTrue(service.involved(small)); + assertTrue(service.involved(large)); + assertTrue(service.stop(sender).contains("ended")); + mounts = 0; + } + } +} diff --git a/src/test/java/games/dmg/spigotheights/HeightsCommandTest.java b/src/test/java/games/dmg/spigotheights/HeightsCommandTest.java index 0443d62..5849cfa 100644 --- a/src/test/java/games/dmg/spigotheights/HeightsCommandTest.java +++ b/src/test/java/games/dmg/spigotheights/HeightsCommandTest.java @@ -37,6 +37,37 @@ class HeightsCommandTest { command = new HeightsCommand(live, players, Logger.getAnonymousLogger()); } + @Test + void nonAdminPlayersCanUseOnlySafeCarryActionsAndCompletion() throws Exception { + List actions = new ArrayList<>(); + org.bukkit.entity.Player player = BukkitDoubles.proxy(org.bukkit.entity.Player.class, (p, method, args) -> { + if (method.getName().equals("hasPermission")) { return false; } + if (method.getName().equals("sendMessage")) { messages.add((String) args[0]); return null; } + throw new UnsupportedOperationException(method.getName()); + }); + command = new HeightsCommand(live, players, Logger.getAnonymousLogger(), + (name, value, reply) -> { throw new AssertionError("admin setter reached"); }, + (p, action) -> actions.add(action)); + for (String action : List.of("accept", "decline", "stop")) { + command.onCommand(player, null, "heights", new String[] {"CaRrY", action.toUpperCase()}); + } + assertEquals(List.of("accept", "decline", "stop"), actions); + for (String[] args : new String[][] {{"carry"}, {"carry", "throw"}, {"carry", "stop", "extra"}, + {"player", "Steve", "0.5"}, {"settings"}}) { + command.onCommand(player, null, "heights", args); + } + command.onCommand(sender(true), null, "heights", new String[] {"carry", "accept"}); + assertEquals(3, actions.size()); + assertEquals(List.of("carry"), complete(player, "")); + assertEquals(List.of("accept", "decline", "stop"), complete(player, "carry", "")); + assertEquals(List.of("stop"), complete(player, "carry", "s")); + assertEquals(List.of(), complete(player, "player", "")); + org.bukkit.configuration.file.YamlConfiguration yaml = org.bukkit.configuration.file.YamlConfiguration + .loadConfiguration(new java.io.File("src/main/resources/plugin.yml")); + assertEquals(null, yaml.getString("commands.heights.permission")); + assertEquals("op", yaml.getString("permissions.spigotheights.admin.default")); + } + @Test void runtimeHeightSetterCanPersistAsynchronouslyWithoutBypassingPermissions() { List> replies = new ArrayList<>();