feat(heights): mount larger players without consent
Release / release (push) Successful in 4m6s
CI / build (push) Successful in 2m7s

This commit is contained in:
dmg
2026-09-12 08:35:29 -04:00
parent df44bba9c6
commit a949fd99c4
16 changed files with 585 additions and 206 deletions
@@ -15,7 +15,7 @@ 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. */
/** Bukkit boundary for rider-initiated carrying. 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;
@@ -24,6 +24,8 @@ public final class BukkitCarry implements Listener, CarryService.Port, AutoClose
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 record Interaction(UUID rider, UUID carrier) {}
private final java.util.Set<Interaction> interactions = new java.util.HashSet<>();
private UUID mountingCarrier, mountingRider;
private boolean closed;
@@ -31,36 +33,40 @@ public final class BukkitCarry implements Listener, CarryService.Port, AutoClose
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) {
Consumer<Runnable> nextTick, Logger logger, ToDoubleFunction<Player> scales) {
this.players = players; this.nextTick = nextTick; this.logger = logger;
this.scales = scales;
service = new CarryService(this, () -> settings.get().adjustmentStep(), clock);
service = new CarryService(this, () -> settings.get().carryMinimumScaleGap());
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void interact(PlayerInteractEntityEvent event) {
if (event.isCancelled() || event instanceof PlayerInteractAtEntityEvent
|| event.getHand() != EquipmentSlot.HAND || !event.getPlayer().isSneaking()
if (closed || event.isCancelled() || event.getHand() != EquipmentSlot.HAND
|| !(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."));
}
Interaction interaction = new Interaction(sender.getUniqueId(), target.getUniqueId());
// Consume duplicates of a handled attempt, even if that attempt was refused or mounted.
if (interactions.contains(interaction)) { event.setCancelled(true); return; }
// Do not suppress other plugins' interaction handlers when this is not a valid carry attempt.
if (!service.canAttemptRide(interaction.rider(), interaction.carrier())) { return; }
event.setCancelled(true);
// A client interaction can dispatch AtEntity and then Entity in the same tick.
// Coalesce that pair even on native refusal; this is not a remount cooldown.
interactions.add(interaction);
nextTick.accept(() -> interactions.remove(interaction));
sender.sendMessage(service.ride(interaction.rider(), interaction.carrier()));
}
// AtEntity has its own HandlerList; registering only the superclass method misses it.
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void interactAt(PlayerInteractAtEntityEvent event) { interact(event); }
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";
default -> "Usage: /heights carry stop";
});
}
@Override public CarryService.State state(UUID id) {
@@ -169,6 +175,7 @@ public final class BukkitCarry implements Listener, CarryService.Port, AutoClose
public void tick() { if (!closed) { service.tick(); } }
@Override public void close() {
closed = true;
interactions.clear();
int unresolved = service.close();
if (unresolved != 0) { logger.severe("Heights disable: " + unresolved
+ " native dismount(s) were refused by the server; no cancellation was bypassed."); }
@@ -4,13 +4,12 @@ package games.dmg.spigotheights;
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) {
public static boolean eligible(double carrier, double rider, double gap) {
if (!Double.isFinite(carrier) || !Double.isFinite(rider) || !Double.isFinite(gap)
|| carrier <= 0 || rider <= 0 || gap < 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;
return carrier - rider + tolerance >= gap;
}
}
@@ -4,7 +4,6 @@ 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 {
@@ -18,53 +17,44 @@ public final class CarryService {
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 DoubleSupplier gap;
private final Map<UUID, Ride> rides = new HashMap<>();
private final java.util.Set<UUID> mounting = new java.util.HashSet<>();
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 CarryService(Port port, DoubleSupplier gap) {
this.port = port; this.gap = gap;
}
public String request(UUID sender, UUID recipient) {
/** Whether this interaction belongs to Heights; does not mutate or notify either player. */
public boolean canAttemptRide(UUID rider, UUID carrier) {
return !closed && !involved(rider) && !involved(carrier)
&& !mounting.contains(rider) && !mounting.contains(carrier)
&& !rider.equals(carrier) && eligible(carrier, rider, true) && port.nearby(rider, carrier);
}
/** Only the smaller rider may initiate; the target can never force a pickup. */
public String ride(UUID rider, UUID carrier) {
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.";
if (involved(rider) || involved(carrier) || mounting.contains(rider) || mounting.contains(carrier)) {
return "A player already has a ride.";
}
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.";
if (rider.equals(carrier) || !eligible(carrier, rider, true) || !port.nearby(rider, carrier)) {
return "Right-click an available larger player within 4 blocks; the configured scale gap is required.";
}
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());
boolean mounted;
mounting.add(carrier); mounting.add(rider);
try { mounted = port.mount(carrier, rider); }
finally { mounting.remove(carrier); mounting.remove(rider); }
if (!mounted && !port.linked(carrier, rider)) { return "The server refused the ride."; }
Ride ride = new Ride(carrier, rider);
rides.put(ride.carrier(), ride); rides.put(ride.rider(), ride);
if (!mounted) {
if (!mounted || closed) {
String stopped = stop(ride.rider());
String message = "The server produced an invalid ride. " + stopped;
String message = (closed ? "Carrying is shutting down. " : "The server produced an invalid ride. ") + stopped;
if (involved(ride.rider())) { notify(ride, message); }
return message;
}
@@ -76,35 +66,10 @@ public final class CarryService {
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());
&& CarryPolicy.eligible(a.scale(), b.scale(), gap.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())) {
@@ -119,7 +84,6 @@ public final class CarryService {
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())) {
@@ -131,14 +95,12 @@ public final class CarryService {
}
}
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;
}
@@ -6,9 +6,21 @@ public record HeightSettings(
double adjustmentStep,
double launcherThreshold,
double launcherSpeed,
int launcherCooldownTicks) {
int launcherCooldownTicks,
double carryMinimumScaleGap) {
public static final double DEFAULT_CARRY_MINIMUM_SCALE_GAP = 0.1;
public HeightSettings(double minimum, double maximum, double adjustmentStep,
double launcherThreshold, double launcherSpeed, int launcherCooldownTicks) {
this(minimum, maximum, adjustmentStep, launcherThreshold, launcherSpeed, launcherCooldownTicks,
DEFAULT_CARRY_MINIMUM_SCALE_GAP);
}
public HeightSettings {
if (!Double.isFinite(carryMinimumScaleGap) || carryMinimumScaleGap < 0) {
throw new IllegalArgumentException("carry.minimum-scale-gap must be a finite nonnegative number");
}
requirePositiveFinite("height.minimum", minimum);
requirePositiveFinite("height.maximum", maximum);
requirePositiveFinite("height.adjustment-step", adjustmentStep);
@@ -18,8 +18,8 @@ public final class HeightsCommand implements TabExecutor {
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");
private static final String CARRY_USAGE = "Usage: /heights carry stop";
private static final List<String> CARRY_ACTIONS = List.of("stop");
@FunctionalInterface
public interface HeightSetter {
@@ -31,7 +31,7 @@ public final class LiveHeightSettings implements Supplier<HeightSettings> {
bound.equals("min") ? value : current.minimum(),
bound.equals("max") ? value : current.maximum(),
current.adjustmentStep(), current.launcherThreshold(),
current.launcherSpeed(), current.launcherCooldownTicks());
current.launcherSpeed(), current.launcherCooldownTicks(), current.carryMinimumScaleGap());
}
public void setBound(String bound, double value) throws IOException {
@@ -73,12 +73,16 @@ public final class SpigotHeightsPlugin extends JavaPlugin {
}
static HeightSettings loadSettings(FileConfiguration config) {
Object configuredGap = config.get("carry.minimum-scale-gap", HeightSettings.DEFAULT_CARRY_MINIMUM_SCALE_GAP);
if (!(configuredGap instanceof Number gap)) {
throw new IllegalArgumentException("carry.minimum-scale-gap must be a finite nonnegative number");
}
return new HeightSettings(
config.getDouble("height.minimum"),
config.getDouble("height.maximum"),
config.getDouble("height.adjustment-step"),
config.getDouble("launcher.maximum-player-scale-exclusive"),
config.getDouble("launcher.speed"),
config.getInt("launcher.cooldown-ticks"));
config.getInt("launcher.cooldown-ticks"), gap.doubleValue());
}
}