feat(heights): carry consenting players with native dismounts
Release / release (push) Successful in 2m26s
CI / build (push) Successful in 54s

This commit is contained in:
dmg
2026-09-10 23:48:51 -04:00
parent 47d5060dac
commit ff0523860c
11 changed files with 929 additions and 6 deletions
@@ -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<UUID, Player> players;
private final Consumer<Runnable> nextTick;
private final Logger logger;
private final ToDoubleFunction<Player> scales;
private final CarryService service;
private final java.util.Set<UUID> dismounting = new java.util.HashSet<>();
private final java.util.Set<UUID> queuedStops = new java.util.HashSet<>();
private UUID mountingCarrier, mountingRider;
private boolean closed;
public BukkitCarry(Server server, Supplier<HeightSettings> settings, Consumer<Runnable> 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<UUID, Player> players, Supplier<HeightSettings> settings,
Consumer<Runnable> nextTick, Logger logger, ToDoubleFunction<Player> 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."); }
}
}
@@ -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;
}
}
@@ -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<UUID, Request> requests = new HashMap<>();
private final Map<UUID, Ride> rides = new HashMap<>();
private final java.util.Set<UUID> 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); }
}
@@ -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<org.bukkit.entity.Player, String> carry;
private static final String CARRY_USAGE = "Usage: /heights carry accept|decline|stop";
private static final List<String> 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<org.bukkit.entity.Player, String> 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<String> 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]);
@@ -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();
}