feat(heights): mount larger players without consent
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user